🏠

家電もチャットでコントロール!LLMで実現するスマートホーム生活

2024/11/01に公開

家電をチャット形式で制御できたらいいなと思い作ってみました。

Sematic KernelのPlugin機能を使って実装しています。

https://twitter.com/tw_kotatu/status/1851949317600493773

🛠️概要

"制御できるデバイスは?"、"扇風機をつけて"といった感じで指示します。

システム図

利用技術

主要なものを列挙します。

Semantic Kernel

セマンティック カーネルは、AI エージェントを簡単に構築し、AIモデルを C#、Python、または Java コードベースに統合できる、軽量のオープンソース開発キットです。

いろんなことができるので、以前に投稿した記事を参考いただければと思います。

https://zenn.dev/zead/articles/semantic_kernel_getting_started

今回は、プラグイン機能を利用して、家電に対し制御を行います。
プラグイン機能自体の説明は↓となります。

https://learn.microsoft.com/ja-jp/semantic-kernel/concepts/plugins/?pivots=programming-language-csharp

Azure OpenAI Service(AOAI)

Microsoft Azure上でOpenAIのAIモデルを利用できる唯一のパブリックサービスです。
Semantic Kernelに対応している言語モデルならどれでもいいと思いますが、
本記事では、"gpt-4o-mini"を利用しています。

secrets.json

API KEY/Model名/Endpointは、secrets.jsonで指定する前提です。
自身の環境に合わせて作成してください。

secrets.json
{
  "API_KEY": "xxx",
  "MODEL_NAME": "xxx",
  "ENDPOINT": "xxx"
}

Blazor

Blazor は、HTML、CSS、C# をベースにした最新のフロントエンド Web フレームワークです。
今回初めて使ってみました。

また、チャットアプリを作成するにあたり、以下のサイトを参考にさせていただきました。

https://prota-p.com/csharp_web_blazor_ex2_chatgpt_api/

自作の照明オンオフ装置(ラズパイピコ)

下記の記事で記載した内容です。

https://zenn.dev/kotaproj/articles/pico_light_sg90

サーボモータ(SG90)を利用して、物理スイッチのオンオフを行います。
照明状態の取得と照明の制御がAPIとして定義しています。

今回は、↓の制御を利用します。

メソッド エンドポイント 説明 リクエスト内容 レスポンス内容
GET /status 照明状態の取得 なし {"light": "<状態>"}onまたはoff
POST /light 照明の制御 {"light": "<状態>"}onまたはoff 成功: {"status": "success", "light": "<状態>"}
失敗: {"status": "error", "message": "Invalid action"}

Nature Remo Cloud API

Nature Remo Cloud API を利用することで、Nature Remoシリーズのセンサーから得られる情報を取得する、Nature Remoから赤外線を送信する、などのアクションを行うことができます。

詳しくは、公式サイトを参照してください

https://developer.nature.global/

FansPlugin.csにて、上記で取得したTokenおよび制御用のIDを指定しています。

プロパティ名 内容
NatureToken 公式 - OAuth2 の{TOKEN}に該当
PowerId 今回制御する扇風機の電源ON/OFFのID

💻環境

  • Visual Studio Community 2022 (64 ビット)
    • Markdig : 0.38.0
    • Microsoft.SemanticKernel : 1.25.0

📝手順

全体のコードは、↓に置いてあります。

https://github.com/kotaproj/SmartHomeChat

主要ファイル

主なファイルは以下となります

path name role note
.\ Program.cs エントリーポイント DIコンテナにサービス登録
.\Components\Pages\ Chat.razor 表示ページ -
.\Services\ ChatService.cs チャットサービス -
.\Plugins\ LightsPlugin.cs 自作照明装置のプラグイン -
.\Plugins\ AirConsPlugin.cs エアコンのプラグイン モックです
.\Plugins\ FansPlugin.cs 扇風機のプラグイン -
.\Helper\ MarkdownHelper.cs マークダウン変換ヘルパー 表示用

ポイントを記載します。

  • プラグインの作成
  • プラグインの追加
  • チャットの受信と送信
  • チャットの表示

プラグインの作成

自作の照明オンオフ装置を例に説明します。

具体的なアクションをSemantic Kernelに提供するために、プラグインを作成します。
自作の装置は、照明のオンオフしかできないため、以下が制御できれば良いと考えます。

  • 提供する機能 - LightsPlugin
メソッド 説明
GetLightsAsync() 制御可能なデバイスのリストを返す
GetStateAsync() 指定したデバイスの状態を返す
ChangeStateAsync() 指定したデバイスの状態を変更する
GetLightStatusAsync() デバイスに対し、現在の状態を取得する(GETメソッドが該当)
PostLightStatusAsync() デバイスに対し、現在の状態を変更する(POSTメソッドが該当)
  • デバイスの情報 - LightModel
プロパティ 説明 備考
Name デバイスの名前 -
IsOn 照明の状態 false:OFF, true:ON, null:不明
IpAddress デバイスIPアドレス ラズパイピコ
割り振られたIPアドレス

コードは以下となります。

LightsPlugin.cs
public class LightsPlugin
{
    private readonly List<LightModel> lights = new()
    {
        new LightModel { Id = 1, Name = "Kitchen Light", IsOn = null, IpAdress = "192.168.XX.YY" },
    };
    private static readonly HttpClient client = new HttpClient();


    [KernelFunction("get_lights")]
    [Description("Gets a list of lights and their current state")]
    [return: Description("An array of lights")]
    public async Task<List<LightModel>> GetLightsAsync()
    {
        Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {nameof(LightsPlugin)}.{nameof(GetLightsAsync)} - run");

        foreach (var light in lights)
        {
            light.IsOn = await GetLightStatusAsync(light.Id);
        }

        return lights;
    }

    [KernelFunction("get_state")]
    [Description("Gets the state of a particular light")]
    [return: Description("The state of the light")]
    public async Task<LightModel?> GetStateAsync([Description("The ID of the light")] int id)
    {
        Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {nameof(LightsPlugin)}.{nameof(GetStateAsync)} - run");

        // Get the state of the light with the specified ID
        return lights.FirstOrDefault(light => light.Id == id);
    }

    [KernelFunction("change_state")]
    [Description("Changes the state of the light")]
    [return: Description("The updated state of the light; will return null if the light does not exist")]
    public async Task<LightModel?> ChangeStateAsync(int id, LightModel LightModel)
    {
        Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {nameof(LightsPlugin)}.{nameof(ChangeStateAsync)} - run");

        var light = lights.FirstOrDefault(light => light.Id == id);
        if (light == null)
        {
            return null;
        }

        bool isSuccess = await PostLightStatusAsync(id, LightModel.IsOn);
        if (!isSuccess)
        {
            light.IsOn = null;
            return null;
        }

        // Update the light with the new state
        light.IsOn = LightModel.IsOn;

        return light;
    }

    public async Task<bool?> GetLightStatusAsync(int id)
    {
        Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {nameof(LightsPlugin)}.{nameof(GetLightStatusAsync)} - run");

        var url = $"http://{lights.FirstOrDefault(light => light.Id == id)?.IpAdress}/status";

        try
        {
            var response = await client.GetAsync(url);
            response.EnsureSuccessStatusCode();

            // レスポンス内容を文字列として読み取る
            var responseContent = await response.Content.ReadAsStringAsync();

            // JSONデータを解析してライトの状態を取得
            var jsonDocument = JsonDocument.Parse(responseContent);
            var lightStatus = jsonDocument.RootElement.GetProperty("light").GetString();

            // ライトの状態に応じて true または false を返す
            return lightStatus == "on" ? true : lightStatus == "off" ? false : null;
        }
        catch (HttpRequestException)
        {
            // 失敗の場合は null を返す
            return null;
        }
    }

    public async Task<bool> PostLightStatusAsync(int id, bool? turnOn)
    {
        if (!turnOn.HasValue)
        {
            return false;
        }

        var url = $"http://{lights.FirstOrDefault(light => light.Id == id)?.IpAdress}/light";
        var lightStatus = turnOn.Value ? "on" : "off";
        var content = new StringContent($"light={lightStatus}", Encoding.UTF8, "application/x-www-form-urlencoded");

        try
        {
            var response = await client.PostAsync(url, content);
            response.EnsureSuccessStatusCode();
            Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {nameof(LightsPlugin)}.{nameof(PostLightStatusAsync)} - ok!");
            return true;
        }
        catch (HttpRequestException)
        {
            Console.WriteLine($"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {nameof(LightsPlugin)}.{nameof(PostLightStatusAsync)} - error!");
            return false;
        }
    }
}

public class LightModel
{
    [JsonPropertyName("id")]
    public int Id { get; set; }

    [JsonPropertyName("name")]
    public string Name { get; set; } = string.Empty;

    [JsonPropertyName("is_on")]
    public bool? IsOn { get; set; }

    [JsonPropertyName("ipaddress")]
    public string IpAdress { get; set; } = string.Empty;
}

[KernelFunction] や [Description] のような角括弧内の属性は、メソッドやクラスにメタデータを追加するための C#の属性(Attributes) を示しています。
各属性にはそれぞれ役割があり、Semantic Kernelがこのメタデータを利用して特定の機能を実行します。

  • [KernelFunction("change_state")]
    • この属性はSemantic Kernelに特有のもの
    • メソッドをカーネルに登録するために使われる
    • KernelFunction 属性はこのメソッドがSemantic Kernelのプラグインとして提供
    • "change_state": この文字列は、このメソッドを呼び出すための識別子として機能
  • [Description("Changes the state of the light")]
    • Description属性は、メソッドやプロパティの内容を説明するためのコメントやドキュメントとして使用される
    • この情報は、コードの可読性を向上のため
      • Semantic Kernelにより、メソッドの説明として利用される
    • "Changes the state of the light": この文字列は、メソッドが何をするかを説明
      • ここでは「ライトの状態を変更する」という意味を持たせている
    • [return: Description("The updated state of the light; will return null if the light does not exist")]
      • 戻り値に関する説明を提供するために使用される
      • return:の後に続くDescription属性は、メソッドの戻り値に対して説明を追加している
      • "The updated state of the light; will return null if the light does not exist":
        • この文字列は、戻り値がライトの更新状態であること、およびライトが存在しない場合はnullが返されることを示している

プラグインの追加/自動選択

クラスを指定して登録できます。

var builder = new KernelBuilder();
builder.Plugins.AddFromType<LightsPlugin>("Lights")
Kernel kernel = builder.Build();

複数登録する場合は、下記のように指定できます。

var builder = new KernelBuilder();
builder.Plugins.AddFromType<LightsPlugin>("Lights")
builder.Plugins.AddFromType<AirConsPlugin>("AirCons")
builder.Plugins.AddFromType<FansPlugin>("Fans")
Kernel kernel = builder.Build();

また、下記の処理にて、プラグインがコールされるように設定します。

// Enable planning
OpenAIPromptExecutionSettings openAIPromptExecutionSettings = new()
{
    FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
};
  • FunctionChoiceBehavior.Auto()
    • このモードでは、Semantic Kernelがユーザーの要求に対して適切なプラグインまたはスキルを自動的に選択するように機能する
    • これにより、ユーザーが特定のスキルを明示的に指定しなくても、Kernelが内容に基づいて適切なアクションを判断し、最適な結果を返すように動作

チャットの送信と受信

コードは下記となります。

public async Task<string> Ask(string message)
{
    // Enable planning
    OpenAIPromptExecutionSettings openAIPromptExecutionSettings = new()
    {
        FunctionChoiceBehavior = FunctionChoiceBehavior.Auto()
    };

    // Create a history store the conversation
    chatHistory!.AddUserMessage(message);

    // Print the request
    Console.WriteLine("You > " + message);

    var chatCompletionService = _kernel.GetRequiredService<IChatCompletionService>();

    // Get the response from the AI
    var result = await chatCompletionService.GetChatMessageContentAsync(
       chatHistory,
       executionSettings: openAIPromptExecutionSettings,
       kernel: _kernel);

    // Print the results
    Console.WriteLine("Assistant > " + result);

    // Add the message from the agent to the chat history
    chatHistory.AddAssistantMessage(result.ToString());

    return result.ToString();
}
  • 利用者の入力は、messageに渡されます
    • 本メソッド自体は、Chat.razor - SendQuery()から呼ばれます
  • アシスタント側の出力が戻り値としています
  • また、上記を履歴として保持します

チャットの表示

チャットの結果は、マークダウン形式で返ってきます。
そのため、リストをそのまま表示するとみにくいため、コンバートして表示することにします。

MarkdownHelper.cs
public static string RenderMarkdown(string markdown)
{
    var pipeline = new Markdig.MarkdownPipelineBuilder().UseAdvancedExtensions().Build();
    return Markdig.Markdown.ToHtml(markdown, pipeline);
}

下記のように表示をしています

Chat.razor
<div>
    @foreach (var message in _messages)
    {
        <div class="message">@((MarkupString)message)</div>
        <hr>
    }
</div>

🔍確認

LightsPluginのログメッセージを見ながら、実際に確認してみます。

制御できるデバイスの一覧を確認

早速、"一覧を表示して"と投げかけます

今回登録しているデバイスすべてが表示されています。

You > 一覧を表示して
[2024-10-31 20:04:39] LightsPlugin.GetLightsAsync - run
[2024-10-31 20:04:39] LightsPlugin.GetLightStatusAsync - run
Assistant > 以下は制御可能なデバイスの一覧です:

### ライト
- **キッチンライト**
  - 状態: OFF
  - IPアドレス: 192.168.xx.xx

略

何か特定のデバイスを操作したい場合はお知らせください!

ログを見ると、利用者の会話をトリガーにプラグインを通じて、
デバイスの状態を取得していることがわかります。

照明のオン・オフを確認する

"照明をつけて"/"ライトを消して"と投げかけます。

最初のメッセージで、装置のモータが動作し、照明がつきました。
次のメッセージで、同じくモータが動作し、照明が消えました。

You > 照明をつけて
[2024-10-31 20:11:15] LightsPlugin.ChangeStateAsync - run
[2024-10-31 20:11:15] LightsPlugin.PostLightStatusAsync - ok!
Assistant > キッチンライトを点灯しました。現在の状態は ON です。何か他に操作したいことはありますか?
You > ライトを消して
[2024-10-31 20:13:02] LightsPlugin.ChangeStateAsync - run
[2024-10-31 20:13:02] LightsPlugin.PostLightStatusAsync - ok!
Assistant > キッチンライトを消しました。現在の状態は OFF です。その他に何かお手伝いできることがありますか?

制御先がいない場合

装置の電源を落とし、アクセスできない状態にします

そのうえで、照明の状態を聞いてみます

You > ライトの状態教えて
[2024-10-31 20:26:26] LightsPlugin.GetLightsAsync - run
[2024-10-31 20:26:26] LightsPlugin.GetLightStatusAsync - run
Assistant > キッチンのライトについての情報です:

- **名前**: キッチンライト
- **状態**: 現在の状態は不明です(オン/オフの状態は確認できていません)。
- **IPアドレス**: 192.168.11.6

このライトの状態をさらに確認しますか?

ちゃんとデバイスに聞きに行って、不明であることを報告してくれます。

補足:例外について

デバイスがいない状態だと、例外が発生することが何度かありました。

warn: Microsoft.AspNetCore.Components.Server.Circuits.RemoteRenderer[100]
      Unhandled exception rendering component: HTTP 400 (content_filter)
      Parameter: prompt

      The response was filtered due to the prompt triggering Azure OpenAI's content management policy. Please modify your prompt and retry. To learn more about our content filtering policies please read our documentation: https://go.microsoft.com/fwlink/?linkid=2198766
      Microsoft.SemanticKernel.HttpOperationException: HTTP 400 (content_filter)
      Parameter: prompt
略

端的にいうと、Azure OpenAIのポリシーに反しているため、「プロンプトを修正して再試行してください」というもの。

これは、利用者が入力したプロンプトではなく、Semantic Kernelでラップされている部分での発生していると思われます。

利用者からみると同じプロンプトでも、正常処理時はエラーとならないため、直せそうな気はします。

😊さいごに

少ないコード量で、簡単に実現できるSemantic Kernelってすごいと思いました。

作りながら、そもそもルールベースでよくないかと思いましたが、
自然言語でもあいまいな指示を出せるのがいいところかなと思います。

  • "制御できるデバイス教えて"と聞けば、対応しているデバイスおよび制御できる項目がわかるのは、ドキュメントいらずですばらしい
  • 指示を間違えても提案してくれたりと、あいまいさを埋めてくれる

と作っていて感じたことことです。

他にもペット用の監視カメラやスマートプラグなど、異なるインターフェースをもつ機器でもまとめることができるのでいいですね。

GitHubで編集を提案
株式会社ジード テックブログ

Discussion