Welcome to telecotele.com » Projects

リンクプラスをBLEから操作する

「Panasonic アドバンスシリーズ リンクプラス」のスイッチをBLEとM5Stackで操作します。

tags: house

リンクプラスをBLEから操作するのカバー画像

はじめに

将来、戸建ての注文住宅を建てる際はフル2線式リモコン(やMESLなど)を導入したい。 これは学校やオフィスビルなどの非住宅1において照明2を制御するシステムです。 照明はリレーによって駆動され、リレーは信号線経由で制御装置と繋がり、制御装置は壁スイッチにより操作され……という構成を取っています。 一般に住宅で使われる電源線を直接ON/OFFするような構成とは異なり、プログラマブルな照明制御が実現可能です。

たとえば、将来間仕切りを考えると「(将来の)各部屋に個別の照明とスイッチが必要」ということで、引掛けシーリングか何かと片切スイッチのペアが部屋の数だけ用意されることでしょう。 ただ、間仕切りする前はただの大部屋とすると、部屋の照明を点けるたびに複数のスイッチを操作することになります。 え、別にそれくらい良いですか?SwitchBotで何とかする? 照明器具側にリモコンがあれば壁スイッチは操作しない?人感センサー付きの照明もあるので? または当初は3路スイッチなどにしてもらって、間仕切り壁の工事を行う際についでに電気工事してもらって片切スイッチに変更する(それが可能なように当初から計画しておく)? 案としてはいろいろありますが、フル2線式リモコンを導入していればそれほど考えずとも、片切スイッチ相当にしたり3路や4路スイッチ相当にできるでしょう。 また、既存照明を制御するスイッチを後から追加するのは通常厄介(諦める)ですが、フル2線式リモコンならちょっと厄介(できればやりたくない)3くらいまで下がるんじゃないでしょうか。

前置きが長い。 うっかり将来への思いをはせすぎました。 つまり家を建てるときにはフル2線式リモコンを導入したいということです。 いや……でも……ちょっと待ってください。 これってはしかのようなもの、たとえばマイナーなLinuxディストリビューションを触りたくなるようなものではないでしょうか? それにフル2線式リモコンはやはり非住宅向けであり、ちゃんと住宅向けの製品を使ったほうが金額も安く施工業者も慣れていてスムーズに進むのでは?

そこで代替品として「Panasonic アドバンスシリーズ リンクプラス」のスイッチ(次図)をいまの家に導入し、評価します。

Panasonic アドバンスシリーズ リンクプラス Panasonic アドバンスシリーズ リンクプラス(丁寧な暮らし 2024年3月号より)

これはBluetooth Low Energy (BLE)または独自の920MHz帯無線を介して、遠隔で照明制御や状態取得が行える製品です。 フル2線式リモコンほどではないかも知れませんが、もしかしたらそこそこ柔軟に照明を制御できるんじゃないでしょうか?

ただ、BLEからの操作は専用のスマホアプリが必要で、その通信仕様は公開されていません。 独自の920MHz帯無線についても仕様が公開されていないのは同じですが、別途「無線アダプタ」4を導入すればECHONET Liteに変換してくれてそこ経由でアクセスできます。 とはいえ、このアダプタが2.5万円ほどと結構な値段でちょっと検証するにしてはためらう価格です。 それに、すでにECHONET Liteでアクセスできることが分かっている以上、検証しても「仕様どおりECHONET Liteでアクセスできました」で終わる話でしょう。

そこで、無線アダプタを導入せずにBLE経由でアクセスできないか?ということが気になってきます。 前述のとおり本来は専用のスマホアプリからアクセスされる想定だと思いますが、 調査を進めたところ外部のデバイス (M5Stack) から照明について通電状態の取得とON/OFFの操作が行える、つまりその気になれば3路や4路スイッチ相当にできることを確認しました。 (ただし、先にネタバレしておくと状態取得は簡単な一方で、操作については根気が必要です。)

本稿ではリンクプラス自体の説明と調査の結果判明した仕様を述べ、 さらにこの調査過程と実際にM5Stackからスイッチの状態取得と操作を行う様子について述べていきます。

リンクプラスについて

まず「Panasonic アドバンスシリーズ リンクプラス」について述べます。 「アドバンスシリーズ」というだけあって、「アドバンス」の一種です。 アドバンスは「コスモシリーズワイド21」や「フルカラー配線器具」などと同様に壁スイッチやコンセントから構成される配線器具です。

アドバンスには「リンクモデル」と「リンクプラス」と呼ばれるスイッチが含まれます。 字面が紛らわしいですが、それぞれの特徴は次のとおりです。

リンクモデル
独自の426MHz帯無線から照明を制御できる。別途無線アダプタと連携することで、スマートフォンから操作可能。照明用の電源配線とは別にスイッチ自体にの電源配線が必要(3線または4線式)。
リンクプラス
後発。BLEおよび独自の920MHz帯無線から照明を制御できる。BLEでスマートフォンから直接操作ができるほか、別途無線アダプタを介した操作も可能。型番にもよるが、照明用の電源配線のみで動くタイプもある(2線式)。

さらに、ともに遠隔から照明の点灯状態を取得することもできます。 この状態が取得できるというのは良いですね。 リンクモデルやリンクプラスとは別製品で、赤外線を用いて遠隔から照明を操作できる「とったらリモコン」という製品もありますが、これだと状態は取得できません。 しかもON/OFFの信号が同じであるためトグル動作のみ。必ずON側に倒したり必ずOFF側に倒すといった操作は不可です。 追記: 「必ずON」「必ずOFF」の機能はありました。赤外線リモコンの信号を見る#Panasonic壁スイッチ

また、赤外線を使うということであまり障害物には強くありません。 その点、リンクモデルやリンクプラスは電波による操作なので赤外線よりは障害物に強いでしょう。 電波最高!!電波のおかげで大切な仲間とも出会えたよ!!

さて、リンクモデルとリンクプラスを比べると、リンクプラスのほうが使いやすそうですね。 ということで、リンクプラスのスイッチを導入し評価していたわけです。 特に2線式で動くという点と、BLEから操作できるという点が良い。

2線式なのに(スイッチがOFFで本来照明側に電源を供給していないときでも)動く理由は、微弱電流を使っているかららしいです。 この辺はほたるスイッチと似た仕組みを使っているのでしょう。 仕様を確認すると消費電力は「0.2W(待機時)」5とあり、 ほたるスイッチの消費電力「0.04W以下」6と比べて増えてはいますが、それでも0.2WでBLEと920MHz帯の無線を動かして(さらに内部のマイコンやSSRか何らかのリレー、LEDやもちろん周辺回路も動かす)ということができるんですね……。 それとも実は妖精さんを閉じ込めてスイッチを操作させているのかな? 2線式のリンクプラスだと負荷は最大2Aまでであり、さらにLEDまたは白熱灯の照明負荷に限るとされていますが、最近の照明器具ならたいてい大丈夫でしょう。

BLEからの操作については専用のスマホアプリ「スイッチアプリ」が必要です。 設定を済ませれば、次図のような画面7から操作と状態取得ができるようになります。

「スイッチアプリ」の画面 「スイッチアプリ」の画面

これを専用アプリ以外の外部デバイスからでも操作や状態取得を行うようにする、というのが今回の目的です。

なお、ここで評価に使うスイッチとしては「埋込スイッチON/OFF(リンクプラス)(2線式) WTY2201H」を用意しました。 ボタンを1つ備え、それを押すと1つの負荷がON/OFFします。基本的なやつですね。 リンクプラスには2ボタンで2負荷に対応したスイッチや、調光に対応したものもありますが、それは扱いません。 また、子器スイッチを繋げられたりもしますが、それも特に何も繋ぎません。

通信仕様

本来、次の流れとしては調査を行い仕様をまとめた上で外部デバイスに実装して操作を試す……という感じでしょう。 ただ、調査を通じて判明したこともあれば、実装を進めて判明したこともあるという状況で、どこに何を書けば良いのか分からなくなってきました。 そこで、やや変則的ですが先にリンクプラスにおけるBLEの通信仕様をまとめて述べておきます。

なお、ここで述べる仕様については、WTY2201Hの調査によって判明したものです。 他のスイッチでも同様に当てはまるかは調査していません。 また、現時点(2024年5月)の調査に基づくものであり、今後のファームウェアアップデートなどにより仕様が変更となる可能性もありますのでご留意ください。 また、内部で利用されているBLEやAES自体の説明はしません(できません)。

まずLink Layerの話です。 リンクプラススイッチはペリフェラルとして動作し、PublicなBDアドレスが与えられています。 OUIのベンダーは “Panasonic Electric Works Co., Ltd.” です。

アドバタイズ

そのアドレスから約400ms間隔でチャネルを変えながらアドバタイズが飛んできます。 その中には “Panasonic Holdings Corporation” に割り当てられたCompany IDと2Bytesのデータを含むManufacturer Specificが存在します(次図)。

リンクプラスからのアドバタイズ リンクプラスからのアドバタイズ

この2Bytesのデータはスイッチの状態を示しています

  • 1Byte目
    • 最上位ビットはセントラルと接続中か否かを表す
    • それ以外の下位7bitは「BLE番号(1~50の数値。だいぶ後のほうにも出てくる)」と思われる
  • 2Byte目
    • 最上位ビットは照明のON/OFFを表す
    • それ以外の下位7bitは調光使用時の明るさ(0~100までの数値)と思われる

画像にある0180の場合だと、

  • セントラルとは接続中ではない (0x01 & 0x80 => false)
  • BLE番号は1 (0x01 & 0x7f => 1)
  • 照明ON (0x80 & 0x80 => true)
  • (調光スイッチではないので?)調光の明るさは0 (0x80 & 0x7f => 0)

ということが分かります。 アドバタイズは(電波が届けば)誰でも見れるので簡単に取得できますね。 これを利用すれば、たとえば実家向けの見守りサービスなんていうのも簡単に作れるんじゃないでしょうか?

正直こういった形でアドバタイズされているのは嬉しいとは思わないですが…… たとえば照明のON/OFFから在宅状況や生活リズムが予測できてしまう。 いや別に外から窓を見ればON/OFFくらい分かる?まぁそうかも知れませんが……でもなぜ? 専用のスマホアプリだと、起動後にスイッチに接続してその接続が終わってから状態が画面に反映されます。 つまり(接続せずに)状態だけ取得するということは出来ず、なら状態はアドバタイズには載せずに接続してから取得するような仕様でも良かったんじゃないかと思います。 もちろん何らかの理由はあるはずで、たとえば将来的にPanasonicが見守りサービスか何かを提供する予定だったとか……?

なお、スイッチに接続できるセントラルは1つだけです。 セントラルが接続しているとアドバタイズがADV_IND(接続可能)からADV_NONCONN_IND(接続不可)に変わりますが、このときでも状態のデータは載ってきます。

ペアリング

スイッチと接続し操作を行うにあたって、Link Layerレベルの暗号化が必要です。 正確には本当に接続するだけなら暗号化無しでも可能ですが、この状態で操作に必要なCharacteristicsを読み書きしようとするとInsufficient Encryption(暗号化要求)のエラーが返され操作できません。

暗号化といっても、ペアリングを “Just Works”(暗号化はするが認証無し)方式にするだけです。 次から示すCharacteristicsの読み書きも、Just Worksでペアリングを行ったのちにアクセスする想定でいます。

GATT

Just Worksでペアリングして接続した後の話です。 スイッチを操作するには、次のUUIDを扱います8

  • 1d5b003b-69a4-4380-b38a-4ca27c9ebdc7(サービス)
  • 554ef462-f8ba-4359-badd-76c6f88b3af0(読み取り用Characteristics)
  • b0d1d65d-d14e-42bc-961c-43c4299eb19c(書き込み用Characteristics)

大まかな操作の流れとしては、

  1. 読み取り用CharacteristicsからのNotifyを受け取れるよう設定する
  2. 書き込み用Characteristicsにデータを書く(レスポンス要求あり)
  3. 読み取り用CharacteristicsからのNotifyを受け取る
  4. もしまだ書きたいデータがあれば2.に戻る

という感じです。

やり取りされるデータは(Link Layerとは別に)アプリケーション側によって暗号化されていたりされていなかったりします。 今回の調査では読み取り用Characteristicsのデータについては注目しておらず、ここでは「書き込み用Characteristicsに何を書き込めばスイッチの操作ができるのか?」のみに注目して述べます。

まず、書き込むデータのフォーマットは次に示すとおりです。

データフォーマット データフォーマット

CircuitNo (1 Byte)
回路番号。`1`か`2`で基本`1`の模様。今回のWTY2201Hを使った調査ではすべて`1`だった。(手元に無いため推測だが)WTY2202Wのように負荷回路を2つ備えたスイッチにおいて、2回路目の負荷を操作したい場合は`2`となると思われる。
Counter (8 Bytes)
カウンタ。コンテキストによって「スイッチの累計操作回数?(と思われる値)」「アプリ側からPanasonicのサーバに接続した回数?かサーバが把握しているスイッチの累計操作回数?(と思われる値)」「現在の接続におけるSeqNo相当」など意味が変わる。暗号化におけるNonceの一部を共有するために使われていると思われる。
EncFlag (1 Byte)
ペイロード暗号化のフラグ。`0x01`なら事前に共有した鍵でAES-128-GCMによる暗号化を行う。`0x00`なら暗号化しない(、より正確にいえばペイロードはスイッチアプリが関与しない方式によって暗号化済みと思われるので「俺は暗号化処理を行っていない」)。
SeqNo (1 Byte)
シーケンス番号。`0`から始まり、データを書き込むたびに`1`を加算する。
Length (1 Byte)
ペイロードの長さ。ただし、長さそのままの値ではなく「ペイロードの本当の長さ - 15」がLengthとして設定される。
Cmd (1 Byte)
コマンド番号。
Payload (n Bytes)
各コマンドに応じたペイロード。長さは可変。

さて、このフォーマットに従ってどんなデータを書き込めばスイッチを操作できるのか?です。 「スイッチアプリ」では次に示す流れに沿ってデータを書き込むことでスイッチを操作しています。

  1. Panasonicのサーバに「スイッチID」を送信し、「サーバカウンタ」「ペアリングID」を取得
    • HTTPSで接続
    • リクエストにはスイッチアプリのTokenやクライアント証明書も必要だが、この辺は割愛
  2. スイッチに「サーバカウンタ」「ペアリングID」を書き込む
    • (BLEとは関係なく)アプリケーション側のペアリング?を実施
    • Counter := 「サーバカウンタ」
    • EncFlag := 0x00
    • SeqNo := 0
    • Cmd := 0x45 (ReqAuthApp)
    • Payload := 「ペアリングID」
  3. スイッチからのNotifyより「スイッチカウンタ」「認証コード」を読み取る
  4. Panasonicのサーバに「スイッチID」「スイッチカウンタ」「認証コード」を送信し、新たな「サーバカウンタ」と「携帯端末ID」を取得する
  5. スイッチに「サーバカウンタ」「携帯端末ID」を書き込む
    • Counter := 「サーバカウンタ」
    • EncFlag := 0x00
    • SeqNo := 1(1加算)
    • Cmd := 0x1e (ReqRegMobileID)
    • Payload := 「携帯端末ID」
  6. Panasonicのサーバに「スイッチID」を送信し、新たな「サーバカウンタ」と「暗号鍵」「サーバにより暗号化された暗号鍵」を取得
    • 鍵は毎回変わる
  7. ★スイッチに「サーバカウンタ」「サーバにより暗号化された暗号鍵」を書き込む★
    • Counter := 「サーバカウンタ」
    • EncFlag := 0x00
    • SeqNo := 2(1加算)
    • Cmd := 0x41 (ReqRegSessionKey)
    • Payload := 「サーバにより暗号化された暗号鍵」
  8. スイッチに「あるデータを6.で得た「暗号鍵」を使って暗号化したデータ」を書き込む
    • Counter := 0x8000000000000000(暗号化の開始とともに固定値にリセット)
    • EncFlag := 0x01(暗号化開始)
    • SeqNo := 3(暗号化開始前に引き続き、1加算)
    • Cmd := 0x42 (ReqGetFWVersion)
    • Payload := 「あるデータを6.で得た「暗号鍵」を使って暗号化したデータ」
  9. スイッチに「別のあるデータを6.で得た「暗号鍵」を使って暗号化したデータ」を書き込む
    • Counter := 0x8000000000000001(1加算)
    • EncFlag := 0x01
    • SeqNo := 4(1加算)
    • Cmd := 0x2f (ReqSetTime)
    • Payload := 「別のあるデータを6.で得た「暗号鍵」を使って暗号化したデータ」
  10. ★スイッチに「操作データ(後述)を6.で得た「暗号鍵」を使って暗号化したデータ」を書き込む★
    • このコマンドにより照明のON/OFFが操作される
    • Counter := 0x8000000000000002(1加算)
    • EncFlag := 0x01
    • SeqNo := 5(1加算)
    • Cmd := 0x01 (ReqCtrlIndivSw)
    • Payload := 「操作データ(後述)を6.で得た「暗号鍵」を使って暗号化したデータ」

道のりが長い9。 そして、この処理をスイッチアプリ以外の野良デバイスから扱うのは手間ですね。 Panasonicのサーバにアクセスしたり、スイッチ側からのNotifyをまともに処理することになるなんて……。

ここで朗報ですが、操作に最低限必要なコマンドは ★マークのところだけ です。

毎回Panasonicのサーバに接続する必要はありません。 「7.」で書き込む「サーバカウンタ」「サーバにより暗号化された暗号鍵」は過去に取得したデータで大丈夫です。 「スイッチアプリ」でもインターネットに接続できない場合などでは、前回の実行で取得したデータを使っていました。 もし必ずサーバに接続しなければならないという仕様だと、通信障害やサーバ側の問題でアクセスできなかった場合にスイッチ操作もできないとなると困りますからね。 なお、このデータは直前で取得したデータに限らず、少なくとも1週間くらい前に(スイッチアプリの通信を観測して)取得したデータを使い回して(その後スイッチアプリを操作してデータを更新して)も操作はできました。 ただ、あまりに期間が伸びた場合や「同じデータで100回以上操作したらハネる」のような実装になっているかも知れませんが、その辺は未確認です。

でも、「10.」を送るためにデータを作って暗号化するので、暗号鍵は取得する必要はあるんでしょう? と、思いきや、少なくともここで扱うコマンドは同じ内容を書き込んでも受理されます。 つまりリプレイが可能10です。 そのため、スマホをインターネットからオフラインにした上で、スイッチアプリから書き込まれるデータを観測し、ここで得られたデータを書き込めば操作できます。簡単ですね。 さらに、スイッチアプリからはおそらく「7.」「8.」「9.」「10.」のデータを書き込んでいると思いますが、ここでSeqNoは進んでいれば受理される(同じ番号や過去の番号はダメ)ので「7.」と「10.」だけ取り出して書き込んでもうまくいくはずです。

とはいえ、単純なON/OFFだけでなく調光もしたい場合、調光レベルに応じた操作を何度もスイッチアプリから試して観測する必要があるため手間がかかります。 また、BLEで書き込まれるデータの観測が難しい場合もあるでしょう。 そうした場合は自前でデータを作って書き込むしかありません。

そこで、次からは主に「7.」と「10.」で利用されるコマンドとそのペイロードの詳細について述べていきます。

コマンド

ReqRegSessionKey

比較的簡単なコマンドです。 すでに示した内容と一部重複しますが、パラメータはこんな感じです。

CircuitNo := 状況によって異なる
Counter   := Panasonicのサーバから取得したカウンタ
EncFlag   := 0x00
SeqNo     := 状況によって異なる
Length    := 0x11
Cmd       := 0x41 
Payload   := Panasonicのサーバにより暗号化された暗号鍵 (32 Bytes)

「暗号化された暗号鍵」というのが紛らわしい。 この暗号化を解いた(生の)暗号鍵は、EncFlag == 0x01のときの鍵として使われ、その鍵を安全にスイッチへ渡すためにこのようなことをしていると思われます。

さらに紛らわしいことをいえば、「暗号化された暗号鍵の暗号化に使用した鍵」は分かりませんでした11。 分かってしまうと任意の鍵を登録し放題ですからね。 暗号化の方式はおそらくAES-128-GCM?Nonceは「サーバカウンタ」(を基にした)値? AES-128-GCMではNonceが使い回しかつ何か1つ十分な長さの既知平文と暗号文のペアを知れれば他の平文が分かります12。 ただ、ちゃんと「サーバカウンタ」は再利用されないようだったので解読はできません。 この鍵はPanasonicのサーバとスイッチの間で共有されているはずで、おそらくスイッチの初期設定の際に作成されるとか? もし初期設定の段階でスイッチアプリとPanasonicのサーバとの通信を観測できていれば何か分かったのかも知れません。

ReqCtrlIndivSw

スイッチを(個別)に操作するコマンドです。 パラメータはこんな感じです。

CircuitNo := 状況によって異なる
Counter   := 状況によって異なる(詳細後述)
EncFlag   := 0x01
SeqNo     := 状況によって異なる
Length    := 0x08
Cmd       := 0x01 
Payload   := 暗号化された操作データ (23 Bytes)

まずCouterについては、EncFlag == 0x01である場合のSeqNoのような動きで、暗号化に用いられるNonceの一部としても使われます。 0x8000000000000000を初期値としその後EncFlag == 0x01のデータを書き込むたびに1加算されますが、Nonceとして適切な値であれば何でも良い気はします(未検証)。

ペイロードについて、(暗号化前の)操作データは7 Bytesで内容はこんな感じです。

data[0~3] := いえID
data[4]   := BLE番号

# 調光を利用しない場合
data[5] := (0x00(消灯) or 0x40(点灯)) | 0x0a
data[6] := 0xff

# 調光の場合
data[5] := (0x00(消灯) or 0x40(点灯)) | 0x10(※さらに下位3bit目や下位1bit目を立てる場合もあると思われるが未調査)
data[6] := 調光レベル (0 ~ 100)

「BLE番号」についてはアドバタイズに含まれています(Panasonicのサーバとのやり取りにも含まれます)。 「いえID」は家に振られた固有のIDです。 これについては……Panasonicのサーバとのやり取りに含まれているのでそれを頑張って見るか、スイッチアプリが書き込んだReqCtrlIndivSwの暗号化済みデータを何らかの方法で復号(できるなら)して読むしかありません。

この操作データをAES-128-GCMにより暗号化します。 鍵はPanasonicのサーバとのやり取りを観測して得たものを使い、Nonce (96 bits)は「上位32bit分はスイッチID、以降64bit分はCounterの値」にします。 「スイッチID」はどうやって知る? これもPanasonicのサーバとのやり取りを観測したりして、どうにかこうにか頑張って取得するしかありません……。

ほか

ReqGetFWVersionやReqSetTimeについては、おそらく自分で叩くことはないため割愛します。 また、他にも照明のグループやシーン制御に関するコマンドもありましたが、同様に割愛です。

ブザーを制御できそうなコマンドもありましたが、ここでいう「制御」とは固定の音を設定時に鳴らすか否かを決めるもののようです。 任意のタイミング、任意の周波数で鳴らすことができれば楽器にしたかったですね。

あとは施工設定に使うコマンドで、スイッチ側から何らかの情報を引き出すようなものもありそうです。 もしかしてこれを使えば「スイッチID」くらい、いや他にも「いえID」などが分かったりするのでは? そもそも他のコマンドでもちゃんとNotifyを読めば何か書いているかも? という気はしますが、この辺は確認していません。

調査

通信仕様について述べたところで、この調査過程についても述べていきます。

BLE Sniffer

まず試したのは「Bluefruit LE Sniffer」です。

最後に使ったのは家庭用コーヒーメーカーの解析とHTCPCPの実装#スニファリングBLEのときで完全に忘れていましたが、

  • スマホアプリ側で接続を開始しても見えないのは、Snifferが違うチャネルを見ていたため
    • 何度かやり直すと、MasterSaveのやり取りが見えるはず
  • Wiresharkでは「インターフェースツールバー」からSnifferのツールバーをちゃんと表示する
    • そしてツールバーの “Device” から本当に見たいデバイスに絞って見る
    • (周辺のBLEデバイス全部見るのはしんどい)

という点には注意が必要です。

Snifferを使ったことにより、アドバタイズにスイッチの状態が含まれていることやLink Layerレベルで暗号化されていることが分かりました。 また、後述のM5Stackを使った操作の開発においても役立って便利です。 Sniffer最高!!Snifferのおかげで大切なアドバタイズとも出会えたよ!!

アプリの調査

「スイッチアプリ」についても調査しました。

メインとなる部分はXamarinで開発されているようで、DLL形式のファイルを探す必要があります。 これを(必要に応じて圧縮を展開するなどして)dotPeekで読み込んで調査しました。

dnSpyも試しましたが、dotPeekのほうが良かったです。 とはいえ、デコンパイルに失敗する箇所もあり、この辺は実際の動作と突き合わせながら想像し調査しました。

通信の調査

「スイッチアプリ」が行っているHTTPSの通信について調査しました。 アプリからPKCS#12形式のクライアント証明書を取り出し、HTTPSの通信を観測できるようにすると次図のような通信が行われていることが分かります。

アプリとPanasonicのサーバとの通信例 アプリとPanasonicのサーバとの通信例

リクエストやレスポンスには「いえID」「スイッチID」「BLE番号」といった情報が含まれることもあり、自前で操作データを作成する際などで必要な情報が得られます。

Bluetooth HCIログ

これまでに調査した情報をもとにgatttoolでスイッチ側へコマンドを書き込み!……してみましたが、うまく動きません。 まだ何か足りない? 「スイッチアプリ」が実際にスイッチとやり取りしているデータが見たくなってきます。 ただ、普段使っているのはiOS端末であり、Jailbreakでもしない限りそういったデータは見れないのでは?13

そんなとき、以前に人から貰ったAndroid端末があるのを思い出しました。 (貰って放置しているだけあって)古めのAndroidでしたがまだなんとか「スイッチアプリ」は動かせます。 Androidだと、開発者向けオプションを有効にした上で「Bluetooth HCIスヌープログ」を有効にすれば、アプリ側が送ったBLEのデータを見れるようです14

簡単すぎる。 早い段階からこれを使っていれば、わざわざアプリ調査とか通信調査もしなくて済んだのでは? なにはともあれ、これを利用しデータを確認して調査を進められました。

ほか

今回の調査では分解はしておらず、ハードウェアの構成は分かりません。 再度組み立てて使うのも怖いですからね。 また、スイッチ本体のファームウェアと思われる約200KBのファイルも見てみましたがよく分かりませんでした。いかがでしたか?

M5Stackによる操作

これまでに調査しまとめた仕様をもとに、専用アプリの外部からスイッチ操作します。 BLEを扱えるデバイスであれば何を使っても良いですが、ここではM5Stackを利用しました。

M5Stackを使うのは初めてで、見よう見まねで15書いたコードとしてはこんな感じです。

長いので折りたたみ
#include <M5Stack.h>
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#define LGFX_AUTODETECT
#include <LovyanGFX.hpp>
#include <LGFX_AUTODETECT.hpp>

BLEScan   *pBLEScan = nullptr;
BLEClient *pClient  = nullptr;
BLEAddress linkPlusAddr = BLEAddress("00:50:40:xx:xx:xx"); // YOUR BDADDR HERE
static BLEUUID serviceUUID("1d5b003b-69a4-4380-b38a-4ca27c9ebdc7");
static BLEUUID charWriteUUID("b0d1d65d-d14e-42bc-961c-43c4299eb19c");
static BLEUUID charReadUUID("554ef462-f8ba-4359-badd-76c6f88b3af0");

static LGFX lcd;
static LGFX_Sprite sprite(&lcd);


/*-----------*/
/*    LCD    */
/*-----------*/
void lcdUpdate(int connectable, int lightOnOff) {
  switch (lightOnOff) {
  case 0:
    sprite.fillScreen(TFT_BLACK);
    sprite.setTextColor(TFT_GREEN);
    break;
  case 1:
    sprite.fillScreen(TFT_GREEN);
    sprite.setTextColor(TFT_BLACK);
    break;
  default:
    sprite.fillScreen(TFT_RED);
    sprite.setTextColor(TFT_WHITE);
  }
  sprite.setFont(&fonts::Font4);
  sprite.setTextSize(1.5);

  sprite.setCursor(10, 71);
  sprite.printf("Connectable: %s", (connectable == 0) ? "No" : ((connectable == 1) ? "Yes" : "???"));

  sprite.setCursor(10, 71+39+10);
  sprite.printf("Light: %s", (lightOnOff == 0) ? "Off" : ((lightOnOff == 1) ? "On" : "???"));

  sprite.setTextSize(1);
  sprite.setCursor(47, 214);
  sprite.printf("Off");
  sprite.setCursor(237, 214);
  sprite.printf("On");

  sprite.pushSprite(0, 0);
}

/*----------------*/
/*    BLE Scan    */
/*----------------*/
class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
  std::string prevManufactureData = "";

  void onResult(BLEAdvertisedDevice advertisedDevice) {
    if (!linkPlusAddr.equals(advertisedDevice.getAddress())) return;
    if (!advertisedDevice.haveManufacturerData()) return;

    std::string manufactureData = advertisedDevice.getManufacturerData();
    if (manufactureData.length() != 4) return;
    if (!(manufactureData[0] == 0x3a && manufactureData[1] == 0x00)) return;
    if ((prevManufactureData != "") && (prevManufactureData == manufactureData)) return;

    prevManufactureData = manufactureData;
    lcdUpdate((manufactureData[2] & 0x80)?0:1, (manufactureData[3] & 0x80)?1:0);

    Serial.printf("Detect Device %s\n", advertisedDevice.getAddress().toString().c_str());
    Serial.printf("AppConnect: %s\n", (manufactureData[2] & 0x80)?"Yes":"No");
    Serial.printf("     Light: %s\n", (manufactureData[3] & 0x80)?"On":"Off");
  }
};

void scanCompleteCB(BLEScanResults scanResults) {
  if (pBLEScan != nullptr) pBLEScan->clearResults();
}

void ble_scan_start() {
  pBLEScan = BLEDevice::getScan();

  pBLEScan->setActiveScan(false); // passive scan
  pBLEScan->setInterval(500);
  pBLEScan->setWindow(500);

  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks(), true);
  pBLEScan->start(0, scanCompleteCB, true);
}

void ble_scan_stop() {
  pBLEScan->stop();
}

/*-------------------*/
/*    BLE Connect    */
/*-------------------*/
class MyClientCallbacks : public BLEClientCallbacks
{
  void onConnect(BLEClient *pclient)
  {
    Serial.println("onConnect");

    sprite.setCursor(0, 0);
    sprite.setTextColor(TFT_RED, TFT_WHITE);
    sprite.printf("Connect");
    sprite.pushSprite(0, 0);
  }

  void onDisconnect(BLEClient *pclient)
  {
    Serial.println("onDisconnect");
  }
};

void notifyCB(BLERemoteCharacteristic* pBLERemoteCharacteristic, uint8_t* pData, size_t length, bool isNotify) {
  Serial.print("Notify callback for characteristic ");
  Serial.print(pBLERemoteCharacteristic->getUUID().toString().c_str());
  Serial.print(" of data length ");
  Serial.println(length);
}

// https://github.com/espressif/arduino-esp32/issues/5316
class MySecurityCallbacks: public BLESecurityCallbacks {
  public:
  uint32_t onPassKeyRequest() override {
    Serial.printf("onPasskeyrequest\n");
    return 123456;
  }

  void onPassKeyNotify(uint32_t pk) override {
    Serial.printf("OnPassKeyNotify %d\n", pk);
  }

  bool onSecurityRequest() override {
    Serial.printf("onsecurity request\n");
    return true;
  }

  void onAuthenticationComplete(esp_ble_auth_cmpl_t auth_cmpl) override {
    Serial.printf("onAuthenticationComplete\n");
    Serial.printf("pair status = %s\n", auth_cmpl.success ? "success" : "fail");
    
    sprite.setCursor(0, 26);
    sprite.setTextColor(TFT_RED, TFT_WHITE);
    sprite.printf("Pairing: %s", auth_cmpl.success ? "SUCCESS" : "FAIL");
    sprite.pushSprite(0, 0);
  }

  bool onConfirmPIN(uint32_t pin) override {
    Serial.printf("onConfirmPin %d\n", pin);
    return true;
  }
};

// https://github.com/nkolban/esp32-snippets/blob/master/cpp_utils/tests/BLETests/Arduino/security/BLE_client/BLE_client_encrypted/BLE_client_encrypted.ino
void lightControl(bool isOn) {
  if (!(pClient->connect(linkPlusAddr))) {
    Serial.println("Failed to connect");
    return;
  }

  BLERemoteService* pRemoteService = pClient->getService(serviceUUID);
  if (pRemoteService == nullptr) {
    Serial.print("Failed to find service UUID: ");
    Serial.println(serviceUUID.toString().c_str());
    pClient->disconnect();
    return;
  }
  Serial.println(" - Found service");

  BLERemoteCharacteristic* pRemoteCharacteristicRead = pRemoteService->getCharacteristic(charReadUUID);
  if (pRemoteCharacteristicRead == nullptr) {
    Serial.print("Failed to find characteristicRead UUID: ");
    Serial.println(charReadUUID.toString().c_str());
    pClient->disconnect();
    return;
  }
  Serial.println(" - Found characteristicRead");

  if (!pRemoteCharacteristicRead->canNotify()) {
    Serial.print("Failed Notify\n");
    pClient->disconnect();
    return;
  }
  pRemoteCharacteristicRead->registerForNotify(notifyCB);

  BLERemoteCharacteristic* pRemoteCharacteristicWrite = pRemoteService->getCharacteristic(charWriteUUID);
  if (pRemoteCharacteristicWrite == nullptr || !pRemoteCharacteristicWrite->canWrite()) {
    Serial.print("Failed to find characteristicWrite UUID: ");
    Serial.println(charWriteUUID.toString().c_str());
    pClient->disconnect();
    return;
  }
  Serial.println(" - Found characteristicWrite");

  uint8_t keyData[45] = {0}; // YOUR KEY DATA HERE
  pRemoteCharacteristicWrite->writeValue(keyData, 45, true);

  if (isOn) {
    uint8_t onData[36] = {0x00};  // YOUR CONTROL DATA HERE
    pRemoteCharacteristicWrite->writeValue(onData, 36, true);
  } else {
    uint8_t offData[36] = {0x00}; // YOUR CONTROL DATA HERE
    pRemoteCharacteristicWrite->writeValue(offData, 36, true);
  }

  pClient->disconnect();
}

/*------------*/
/*    Main    */
/*------------*/
void setup(){
  M5.begin();
  M5.Power.begin();

  lcd.init();
  sprite.setColorDepth(8);
  sprite.createSprite(lcd.width(), lcd.height());
  lcdUpdate(-1, -1);

  BLEDevice::init("M5Stack BLE");
  BLEDevice::setEncryptionLevel(ESP_BLE_SEC_ENCRYPT_MITM);
  BLEDevice::setSecurityCallbacks(new MySecurityCallbacks());
  BLESecurity *pSecurity = new BLESecurity();
  pSecurity->setAuthenticationMode(ESP_LE_AUTH_REQ_SC_ONLY | ESP_LE_AUTH_BOND);
  pSecurity->setCapability(ESP_IO_CAP_NONE);
  pSecurity->setRespEncryptionKey(ESP_BLE_ENC_KEY_MASK | ESP_BLE_ID_KEY_MASK | ESP_BLE_LINK_KEY_MASK);

  pClient = BLEDevice::createClient();
  pClient->setClientCallbacks(new MyClientCallbacks());

  ble_scan_start();
}

void loop() {
  M5.update();

  if(M5.BtnA.wasReleasefor(100)) {
    Serial.printf("Press A\n");
    ble_scan_stop();
    lightControl(false);
    ble_scan_start();
    return;
  }

  if(M5.BtnC.wasReleasefor(100)) {
    Serial.printf("Press C\n");
    ble_scan_stop();
    lightControl(true);
    ble_scan_start();
    return;
  }
}

スイッチからのアドバタイズを取得し、「照明のON/OFF」「接続可能か(他のセントラルが繋いでいないか)」を画面に表示しています。 M5Stackのボタンを押すと、スイッチに接続して照明のON/OFFを操作します。

スイッチとの接続については2回目以降であれば早くなる……ことを想定していましたが特に変わっておらず、その辺は心残りです。 本当はライブラリを確認してBondingがどう処理されるかなどを見て、接続確立にかかる時間を減らせないか検討すべきでしょう。 また、スイッチからのNotifyをまともに処理していませんが、本来は「データ書き込み→Notifyを確認→次のデータを書き込み」といった流れにすべきです。 ここでは偶然うまくいきましたが、おそらくスイッチがNotifyを送る前に連続してデータを書き込むとうまく受理されないような……。 今回は検証だから!と言い訳してスルーしますが、もし常用するならブラッシュアップは必要だと思います。

このコードを動かして、実際にスイッチの状態取得と操作を行っている様子は次のとおりです。

まとめ

「Panasonic アドバンスシリーズ リンクプラス」のスイッチについてBLEでやり取りされる通信仕様を調査し、それに基づいてM5Stackからスイッチの状態取得と操作を行いました。

スイッチの状態取得についてはアドバタイズを見るだけでOKです。 操作については「スイッチアプリ」が送った内容をリプレイするか、「いえID」「スイッチID」「暗号鍵」をどうにかこうにか取得した上で仕様に沿ったデータを送る必要があります。 面倒ですね。個人的には状態取得だけで良いんじゃないかという気がしてきます。

冒頭の「はじめに」に戻ると、今回の調査に関する建前としては「リンクプラスがフルニ線式リモコンの代替になるか」というものでした。 それを思うと、自分はフルニ線式リモコンを使うとして実は状態取得さえできれば満足なのかも、ということで住宅で使う前提ならフル2線式リモコンの代替になりそうです。 いや……でも自分は住宅用スイッチを使うなら「神保電器 J・WIDE」のほうが好み……。

Footnotes

  1. とはいえ住宅に導入されている方もいます。事例1: 岡田家のネットワーク対応住宅建築記録フル2線式リモコン配線システムの選択と工事について、事例2: フル2線式リモコンによる照明制御 (Be-selfbuilders)

  2. ここでいう「照明」はなんか消費電力が小さくて明るいやつではなく、「電灯」であり「動力ではないやつ」を指している気がします。

  3. うまく信号線を引き回せれば。無理ならワイヤレスで飛ばす機構を考える必要がありそうですが、まだ建てていないので分かりません。

  4. AiSEG2でも良いようですが、よく分からないので割愛します。

  5. WTY2201Wの場合。

  6. 【スイッチ】ほたるスイッチの消費電力を教えてください。 - スイッチ - Panasonic

  7. 「浴室スイッチ」。浴室に導入して嬉しいことはありません。

  8. これとは別に “NoSecurity” という文字列に関連したUUIDも見つけましたが詳細は未確認です。調査略、調査は読者にお任せする、君の目で確かめてくれ!

  9. 「スイッチアプリ」のレビューでは「立ち上がりが遅い」という評価が多く、その理由が分かった気がします。仕方ないですが、アプリケーションレベルの通信だけでなくBLEの接続確立にかかる時間もあるので……。

  10. とはいえ関係者以外ではデータを観測することは難しく、たとえば第三者から操作されるリスクは低いと思います。ある時点では関係者(居住者orスイッチアプリから操作できるぐらい親密な関係)だったが、やがて退去やお塩して元関係者となっても操作できるか?アプリとスマホの紐付けを解除したり、スイッチを初期化するとどうなるか?未確認です。

  11. 別に知らなくてもスイッチの操作はできるので良いといえば良いです。

  12. AES-GCMはAES-CTRに改ざん検知用のMACを付けたもの。AES-CTRは鍵とNonceから擬似乱数列を作成し、これと平文とのXORを暗号文とする。これにより平文のパディングが不要という特徴がある。ただ、常に同じ鍵とNonceを使うなら乱数列も同じため「既知平文 XOR それに対する既知暗号文」で乱数列が判明する。結果「乱数列 XOR 別の暗号文」でそれに対する平文が分かる。

  13. この調査が終わった後に知ったのですが、開発者アカウントがあればiOSでも見れるようです……。

  14. 【Android】Bluetooth HCIスヌープログの取り方 #Android - Qiita

  15. Tool → Core Debug Levelでログを出したり出さなかったりしながら