TP-Link製スマートホーム製品のAPIにアクセスするためのクライアントおよびサンプルコード。

KasaスマートLEDランプKL130およびWi-FiスマートプラグHS105の操作や、デバイス情報・現在の状態等の取得ができる。

ここで掲載しているコードをライブラリ化・NuGetパッケージ化したものを現在Smdn.TPSmartHomeDevices.Kasaとしてリリースしています。

ここで掲載しているコードは今後更新しない予定のため、上記ライブラリをご利用ください。 また、Tapoスマートホーム製品に対応したSmdn.TPSmartHomeDevices.Tapoもリリースしています。

他にも機能を追加していますので、詳しくはSmdn.TPSmartHomeDevicesをご覧ください。

本実装はTP-Link Smarthome WiFi APIの実装の一部分をC#/.NETに移植したものです。 実機での動作確認はしていますが、APIやプロトコルを独自に解析・調査して実装したものではありません。

動作確認済み製品

以下の製品にて、以下の操作が機能することを確認済み。

KL130 (firmware version 1.8.11時点)
オンオフ操作、色調・明るさ操作、各種状態取得、デバイス情報取得、電力使用量取得
HS105 (firmware version 1.5.7時点)
オンオフ操作、各種状態取得、デバイス情報取得

実機での動作は未確認ながらKL110の操作もできるものと思われます。

・KL130

・KL110

・HS105

動作確認済み環境

以下の環境にて動作確認済み。 要.NET Core 3.1 Runtime

  • Raspbian 9.11 (Raspberry Pi 3 Model B+)
  • Ubuntu 18.04

未確認ながらWindows等でも問題なく動作すると思われます。

制限事項・注意事項

本クライアントで使用するAPIは、デバイスと同一ネットワーク内からのみ使用可能。 そのため、インターネットからの遠隔操作等はできない。

本クライアントで使用するAPIは正規に公開されているAPIではないため、ファームウェアアップデート等により使用できなくなる可能性がある。

他のデバイスとの連携や、ネットワーク外からのアクセス、また正規のAPIはKasaのIFTTT serviceを使用する必要がある。


本クライアントは設定の取得・変更等、製品仕様の範囲内での操作のみを行うものであり、ファームウェアの改変・修正および製品の改造や製品仕様の変更に相当する結果は引き起こさないものの、製品使用上の許諾事項に抵触する可能性は否定できないため、その点ご留意ください。

参考資料

サンプルコード・実行結果例

以下のサンプルコードで使用するクライアント実装SimpleClient自体のコードは、§.クライアント実装に掲載。

KL130

ライトのオン/オフ・明るさ・色調の変更

ライトのオン/オフのほか、明るさと色調も同時に変更できる。 色調の変更は、色温度(color_temp)での指定、もしくは色相(hue)と彩度(saturation)での指定のどちらかで行う。

また、遷移時間(transition_period)を指定すれば、即座にオンにするだけでなく、オフ状態から徐々に明るくしてオンにするような変更もできる。

KL130 transition_light_state
using System;
using System.Text.Json;
using System.Threading.Tasks;

using Smdn.TPLinkKasaClient;

class Program
{
  static async Task Main(string[] args)
  {
    var client = new SimpleClient("192.168.xxx.xxx");// KL130のアドレスまたはホスト名を指定する

    const string module = "smartlife.iot.smartbulb.lightingservice";
    const string method = "transition_light_state";

    var response = await client.SendAsync(
      module,
      method,
      new (string, object) [] {
        ("on_off", 1),                // 1でオン・0でオフにする
        ("ignore_default", 1),        // 「アプリでオンにした場合」の動作設定を無視して変更する
        ("brightness", 100),          // 明るさ0〜100[%]を指定
        //("color_temp", 7500),       // 色温度2000〜9500[K]を指定
        ("color_temp", 0),            //   色調を色相・彩度で指定する場合は色温度を0にする
        ("hue", 280),                 // 色相0〜360[°]を指定する
        ("saturation", 60)            // 彩度0〜100[%]を指定する
        ("transition_period", 1000),  // 遷移時間[ミリ秒]を指定、0を指定するか省略した場合は即座に反映される
      }
    );

    // レスポンスを解析する必要がなければ、以下は省略できる
    if (!response.RootElement.TryGetProperty(module, out var moduleResponse))
      throw new ApplicationException("unexpected response: module element not found");

    if (!moduleResponse.TryGetProperty(method, out var methodResponse))
      throw new ApplicationException("unexpected response: method element not found");

    Console.WriteLine(methodResponse.GetRawText());
  }
}

ライトのオン/オフ・明るさ・色調の状態取得

KL130 get_light_state
using System;
using System.Text.Json;
using System.Threading.Tasks;

using Smdn.TPLinkKasaClient;

// ライトの状態を格納する構造体
struct LightState {
  public int on_off { get; set; }
  public string mode { get; set; }
  public int hue { get; set; }
  public int saturation { get; set; }
  public int color_temp { get; set; }
  public int brightness { get; set; }
  public int err_code { get; set; }
}

class Program
{
  static async Task Main(string[] args)
  {
    var client = new SimpleClient("192.168.xxx.xxx"); // KL130のアドレスまたはホスト名を指定する

    const string module = "smartlife.iot.smartbulb.lightingservice";
    const string method = "get_light_state";

    var response = await client.SendAsync(
      module,
      method
    );

    if (!response.RootElement.TryGetProperty(module, out var moduleResponse))
      throw new ApplicationException("unexpected response: module element not found");

    if (!moduleResponse.TryGetProperty(method, out var methodResponse))
      throw new ApplicationException("unexpected response: method element not found");

    var lightState = JsonSerializer.Deserialize<LightState>(methodResponse.GetRawText());

    Console.WriteLine($@"state: {(lightState.on_off == 0 ? "off" : "on")}");

    if (lightState.on_off != 0) {
      Console.WriteLine($@"brightness: {lightState.brightness}");

      if (lightState.color_temp == 0) {
        Console.WriteLine($@"hue: {lightState.hue}");
        Console.WriteLine($@"saturation: {lightState.saturation}");
      }
      else {
        Console.WriteLine($@"color temperature: {lightState.color_temp}");
      }
    }
  }
}
実行結果・オフの場合
state: off
実行結果・オンの場合・色調が色温度で設定されている場合
state: on
brightness: 55
color temperature: 3040
実行結果・オンの場合・色調が色相・彩度で設定されている場合
state: on
brightness: 87
hue: 263
saturation: 52

電力使用量の取得

特定の年における、月ごとの電力使用量を、ワット時[Wh]単位で取得することができる。

KL130 get_monthstat
using System;
using System.Text.Json;
using System.Threading.Tasks;

using Smdn.TPLinkKasaClient;

class Program
{
  static async Task Main(string[] args)
  {
    var client = new SimpleClient("192.168.xxx.xxx"); // KL130のアドレスまたはホスト名を指定する

    const string module = "smartlife.iot.common.emeter";
    const string method = "get_monthstat";

    var response = await client.SendAsync(
      module,
      method,
      new (string, object) [] {
        ("year", 2020), // 2020年の月毎の電力使用量を取得する
      }
    );

    if (!response.RootElement.TryGetProperty(module, out var moduleResponse))
      throw new ApplicationException("unexpected response: module element not found");

    if (!moduleResponse.TryGetProperty(method, out var methodResponse))
      throw new ApplicationException("unexpected response: method element not found");

    Console.WriteLine(methodResponse.GetRawText());
  }
}

JSONフォーマットで以下のような内容が得られる。 (見やすさのため整形を施してある)

get_monthstatで得られるレスポンス
{
  "month_list": [
    {
      "year": 2020,
      "month": 1,
      "energy_wh": 99999
    },
    {
      "year": 2020,
      "month": 2,
      "energy_wh": 99999
    },
    {
      "year": 2020,
      "month": 3,
      "energy_wh": 99999
    },
    {
      "year": 2020,
      "month": 4,
      "energy_wh": 99999
    }
  ],
  "err_code": 0
}

また、特定の年月における、日ごとの電力使用量を取得することもできる。

KL130 get_daystat
using System;
using System.Text.Json;
using System.Threading.Tasks;

using Smdn.TPLinkKasaClient;

class Program
{
  static async Task Main(string[] args)
  {
    var client = new SimpleClient("192.168.xxx.xxx"); // KL130のアドレスまたはホスト名を指定する

    const string module = "smartlife.iot.common.emeter";
    const string method = "get_daystat";

    var response = await client.SendAsync(
      module,
      method,
      new (string, object) [] {
         // 2020年4月の日毎の電力使用量を取得する
        ("year", 2020),
        ("month", 4),
      }
    );

    if (!response.RootElement.TryGetProperty(module, out var moduleResponse))
      throw new ApplicationException("unexpected response: module element not found");

    if (!moduleResponse.TryGetProperty(method, out var methodResponse))
      throw new ApplicationException("unexpected response: method element not found");

    Console.WriteLine(methodResponse.GetRawText());
  }
}

JSONフォーマットで以下のような内容が得られる。 (見やすさのため整形を施してある) 必ずしも日付順での並びとはならない模様。

get_daystatで得られるレスポンス
{
  "day_list": [
    {
      "year": 2020,
      "month": 4,
      "day": 8,
      "energy_wh": 999
    },
    {
      "year": 2020,
      "month": 4,
      "day": 9,
      "energy_wh": 999
    },
        :
        :
      ✂中略
        :
        :
    {
      "year": 2020,
      "month": 4,
      "day": 1,
      "energy_wh": 999
    },
    {
      "year": 2020,
      "month": 4,
      "day": 2,
      "energy_wh": 999
    },
    {
      "year": 2020,
      "month": 4,
      "day": 3,
      "energy_wh": 999
    },
        :
        :
      ✂中略
        :
        :
  ],
  "err_code": 0
}

HS105

スイッチのオン/オフ

HS105 set_relay_state
using System;
using System.Text.Json;
using System.Threading.Tasks;

using Smdn.TPLinkKasaClient;

class Program
{
  static async Task Main(string[] args)
  {
    var client = new SimpleClient("192.168.xxx.xxx");// HS105のアドレスまたはホスト名を指定する

    const string module = "system";
    const string method = "set_relay_state";

    var response = await client.SendAsync(
      module,
      method,
      new (string, object) [] {
        ("state", 0) // 0でオフ、1でオンにできる
      }
    );

    // レスポンスを解析する必要がなければ、以下は省略できる
    if (!response.RootElement.TryGetProperty(module, out var moduleResponse))
      throw new ApplicationException("unexpected response: module element not found");

    if (!moduleResponse.TryGetProperty(method, out var methodResponse))
      throw new ApplicationException("unexpected response: method element not found");

    Console.WriteLine(methodResponse.GetRawText());
  }
}

スイッチのオン/オフ状態の取得

オン/オフ状態のほか、オン状態の場合はオンになってからの経過時間も取得できる。 get_sysinfoは、オン/オフ状態以外にも様々な情報を返す。

HS105 get_sysinfo
using System;
using System.Text.Json;
using System.Threading.Tasks;

using Smdn.TPLinkKasaClient;

struct SysInfo {
  public int relay_state { get; set; }
  public int on_time { get; set; }
}

class Program
{
  static async Task Main(string[] args)
  {
    var client = new SimpleClient("192.168.xxx.xxx");// HS105のアドレスまたはホスト名を指定する

    const string module = "system";
    const string method = "get_sysinfo";

    var response = await client.SendAsync(
      module,
      method
    );

    if (!response.RootElement.TryGetProperty(module, out var moduleResponse))
      throw new ApplicationException("unexpected response: module element not found");

    if (!moduleResponse.TryGetProperty(method, out var methodResponse))
      throw new ApplicationException("unexpected response: method element not found");

    var sysInfo = JsonSerializer.Deserialize<SysInfo>(methodResponse.GetRawText());

    Console.WriteLine($@"power state: {(sysInfo.relay_state == 0 ? "off" : "on")}");
    Console.WriteLine($@"power on time: {sysInfo.on_time} [secs]");
  }
}
実行結果・オンの場合
power state: on
power on time: 118 [secs]
実行結果・オフの場合
power state: off
power on time: 0 [secs]

クライアント実装

APIクライアントSimpleClientおよびコマンドの暗号化・レスポンスの復号を行うCryptoUtilsのソースコード。 MITライセンス


APIクライアント実装SimpleClientは、コンストラクタに操作したいデバイスのIPアドレスまたはホスト名を指定してインスタンスを作成する。 ネットワークからデバイスを検出する機能は実装していないので、対象デバイスのIPアドレスまたはホスト名は固定されているか、別の手段で既知である必要がある

SimpleClientSendAsyncメソッドでデバイスに対してコマンドを送信する。 引数として、モジュール名module、メソッド名methodの2つと、オプションでkey-value形式のメソッドパラメータmethodParametersを指定する。 レスポンスはSystem.Text.Json.JsonDocumentで返される。

SimpleClient.cs
// 
// Copyright (c) 2020 smdn <smdn@smdn.jp>
// 
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// 
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

using System;
using System.Buffers;
using System.Collections.Generic;
using System.Net;
using System.Net.Sockets;
using System.Linq;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;

namespace Smdn.TPLinkKasaClient {
  /// <summary>
  /// C# implementation of TP-Link Kasa API client, ported from https://github.com/plasticrake/tplink-smarthome-api
  /// </summary>
  public class SimpleClient {
    public const int DefaultPort = 9999;

    public string Host { get; }
    public int Port { get; }

    public SimpleClient(
      string host,
      int port = DefaultPort
    )
    {
      this.Host = string.IsNullOrEmpty(host)
        ? throw new ArgumentException($"'{nameof(host)}' must be non-empty string", nameof(host))
        : host;
      this.Port = port < IPEndPoint.MinPort || IPEndPoint.MaxPort < port
        ? throw new ArgumentOutOfRangeException(nameof(port), port, $"must be in range of {IPEndPoint.MinPort}~{IPEndPoint.MaxPort}")
        : port;
    }

    public Task<JsonDocument> SendAsync(
      string module,
      string method
    )
      => SendAsync(
        module,
        method,
        methodParameters: (IEnumerable<(string, object)>)null
      );

    public Task<JsonDocument> SendAsync(
      string module,
      string method,
      IEnumerable<(string name, object value)> methodParameters
    )
      => SendCommandAsync(
        module,
        method,
        methodParameters?.Select(ConvertToJsonFormat)
      );

    public Task<JsonDocument> SendAsync(
      string module,
      string method,
      IEnumerable<KeyValuePair<string, object>> methodParameters
    )
      => SendCommandAsync(
        module,
        method,
        methodParameters?.Select(pair => ConvertToJsonFormat((pair.Key, pair.Value)))
      );

    private static (string, string) ConvertToJsonFormat(
      (string name, object value) prop
    )
      => (
        prop.name,
        prop.value switch {
          bool b => b ? "true" : "false",
          string s => string.Concat("\"", s.Replace("\"", "\\\""), "\""),
          //int i => i.ToString(),
          //double d => d.ToString(),
          null => "null",
          _ => Convert.ToString(prop.value),
        }
      );

    private async Task<JsonDocument> SendCommandAsync(
      string module,
      string method,
      IEnumerable<(string name, string value)> nullableMethodParameters)
    {
      string ConstructJsonDocument()
        => @$"{{
  ""{module}"":{{
    ""{method}"":{{
  {string.Join(",\n", nullableMethodParameters?.Select(p => $"\"{p.name}\":{p.value}") ?? Enumerable.Empty<string>())}
    }}
  }}
}}";

      using (var socket = new Socket(AddressFamily.InterNetwork, SocketType.Stream, ProtocolType.Tcp)) {
        /*
        * connect
        */
        await socket.ConnectAsync(Host, Port).ConfigureAwait(false);

        /*
        * send
        */
        await socket.SendAsync(
          CryptoUtils.Encrypt(ConstructJsonDocument()),
          default(SocketFlags),
          default(CancellationToken)
        ).ConfigureAwait(false);

        /*
        * receive
        */
        SequenceSegment receivedSequenceHead = null;
        SequenceSegment receivedSequenceTail = null;

        for (;;) {
          const int bufferSize = 0x100;
          var buffer = (Memory<byte>)new byte[bufferSize];
          var len = await socket.ReceiveAsync(
            buffer,
            default(SocketFlags),
            default(CancellationToken)
          ).ConfigureAwait(false);

          if (len <= 0)
            break;

          if (receivedSequenceHead == null)
            receivedSequenceTail = receivedSequenceHead = new SequenceSegment(null, buffer);
          else
            receivedSequenceTail = new SequenceSegment(receivedSequenceTail, buffer);

          if (len < buffer.Length)
            break;
        }

        /*
        * parse
        */
        var receivedSequence = new ReadOnlySequence<byte>(
          receivedSequenceHead,
          0,
          receivedSequenceTail,
          receivedSequenceTail.Memory.Length
        );

        var response = CryptoUtils.Decrypt(receivedSequence);

        return JsonDocument.Parse(
          response,
          default(JsonDocumentOptions)
        );
      }
    }

    private class SequenceSegment : ReadOnlySequenceSegment<byte> {
      public SequenceSegment(SequenceSegment prev, ReadOnlyMemory<byte> memory)
      {
        Memory = memory;

        if (prev == null) {
          RunningIndex = 0;
        }
        else {
          RunningIndex = prev.RunningIndex + prev.Memory.Length;
          prev.Next = this;
        }
      }
    }
  }
}
CryptoUtils.cs
// 
// Copyright (c) 2020 smdn <smdn@smdn.jp>
// 
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the Software, and to permit persons to whom the Software is
// furnished to do so, subject to the following conditions:
// 
// The above copyright notice and this permission notice shall be included in
// all copies or substantial portions of the Software.
// 
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
// THE SOFTWARE.

using System;
using System.Buffers;
using System.Buffers.Binary;
using System.Text;

namespace Smdn.TPLinkKasaClient {
  /// <summary>
  /// C# implementation of TP-Link Kasa API crypto methods, ported from https://github.com/plasticrake/tplink-smarthome-crypto
  /// </summary>
  public static class CryptoUtils {
    private const int defaultKey = 0xAB;

    public static Memory<byte> Encrypt(
      string input,
      byte firstKey = defaultKey
    )
    {
      var data = Encoding.UTF8.GetBytes(input ?? throw new ArgumentNullException(nameof(input)));
      var memory = (Memory<byte>)new byte[4 + data.Length];

      BinaryPrimitives.WriteInt32BigEndian(memory.Span, data.Length);

      var span = memory.Span.Slice(4);
      var key = firstKey;

      for (var i = 0; i < data.Length; i++) {
        key = span[i] = (byte)(data[i] ^ key);
      }

      return memory;
    }

    public static Memory<byte> Decrypt(
      ReadOnlySequence<byte> input,
      byte firstKey = defaultKey
    )
    {
      var reader = new SequenceReader<byte>(input);

      if (!reader.TryReadBigEndian(out int length))
        throw new System.IO.InvalidDataException("input too short (expects at least 4 bytes of header)");

      if (reader.Remaining < length)
        throw new System.IO.InvalidDataException($"input too short (expects at least {length} bytes of data body, but is {reader.Remaining} bytes)");

      var decrypted = (Memory<byte>)new byte[length];
      var span = decrypted.Span;
      var key = firstKey;

      for (var i = 0; i < length; i++) {
        reader.TryRead(out var b);

        span[i] = (byte)(b ^ key);
        key = b;
      }

      return decrypted;
    }
  }
}

変更履歴

2023-05-02

本コードをライブラリ化したSmdn.TPSmartHomeDevicesを公開。

2020-04-26

初版

  • JSON形式のコマンド送信・レスポンス受信を実装、TCPのみサポート