フィールドオートメーションシステム
はじめに
920 MHz帯のLoRaを使い、電池駆動のセンサーとリレー制御を一つのメッシュネットワークにまとめ、OpenRemoteで一元管理・制御するシステムを構築。 システムを構成するデバイスの役割、通信経路、OpenRemote側との接続方法などをソースコードを交え説明します。
システムを構成するデバイスには共通して、STM32F103C8(Blue Pill)と DX-LR30 を採用しており、ファームウェアを共通化。デバイスの役割は、ジャンパ配線と追加部品、ESP32の有無から起動時に自動判定されます。
また、リレー指令(Gateway→Actuator)は、SipHash-2-4による署名と、Actuatorが発行する使い捨てのnonceで認証しています。詳細は「コマンド認証」の節にまとめました。
システム全体像
- 上り:センサーのデータは、必要に応じてRepeaterを経由してGatewayに届き、ESP32-C6がMQTTSでブローカーへ発行。
- 下り:ブローカーに発行されたリレー指令をGatewayが受け取り、LoRaでActuatorへ送信。
共通ハードウェア
全てのデバイスに共通する部分は次の通りです。
| 部品 | 役割 |
|---|---|
| STM32F103C8(Blue Pill) | メッシュ制御、スリープ管理 |
| DX-LR30(SX1262) | 920 MHz LoRa送受信 |
RF設定は 920 MHz、SF9、BW 125 kHz、CR 4/5、+13 dBm で、全デバイスで同一。
デバイスの種類と役割
追加する部品や配線によって、デバイスの役割を決定。
| 役割 | 追加するもの | 決まり方 | 用途 |
|---|---|---|---|
| Node | BMI160(加速度・ジャイロ) | ジャンパなし(デフォルト) | 電池駆動のセンサー |
| Repeater | なし | PB0 → GND | 中継 |
| Actuator | リレー、フィードバック入力 | PB1 → GND | リレー出力と状態通知 |
| Gateway | ESP32-C6 | 起動時のUART PINGにESP32が応答 | メッシュとMQTTの橋渡し |
Node(センサーノード)
I2C接続が可能で電池駆動に適したセンサモジュールを取り付けます。今回は手動でデータ取得頻度を上げることができる加速度センサモジュールBMI160を接続しました。センサの種類に応じてコードは書き換えて下さい。 動作仕様として、通常はSTOPモードで待機し、BMI160のAny-Motion割り込み(INT1)または定期ハートビートで起床、ACKとリトライ付きで送信します。ハートビート間隔の初期値は3600秒。 センサーノードは送信直後にしか受信できないため、下りのコマンドには非対応。
Repeater(中継器)
常時受信で、センサーパケット(MeshPacket)をホップ数制限と重複抑制つきで中継しますが、リレー指令とそのステータス応答を中継しません。
そのためActuatorはGatewayの直接通信圏内に置く必要があります。
Actuator(リレー出力)
常時受信で、リレー出力(PB4)と物理状態のフィードバック入力(PB5)を持ちます。ステータスは次の2つの値を独立して報告。
commandedState:Gatewayから指示された出力レベルfeedbackPinState:フィードバックピンの実際のHIGH/LOW
Actuatorは、受信したコマンドのタグとnonceを検証し、どちらも正しい場合だけリレーを動かします。ステータスには、次のコマンドに必要な使い捨てのnonceも載せます。
Gateway(ゲートウェイ)
STM32+DX-LR30にESP32-C6を接続した構成。STM32がメッシュのパケットを受信してACKを返し、UART(CRC8付きフレーム)でESP32-C6へ転送。ESP32-C6側はESP-IDFで実装されており、次を担当。
- Wi-Fi接続とSNTPによる時刻同期
- MQTTS(TLS)でのPublish / Subscribe
- STM32とのUARTブリッジ
- Actuatorのnonceの保持と、リレー指令への署名(SipHash-2-4)
結線は、STM32のPB10(TX)/PB11(RX)をESP32-C6のUARTにクロス接続し、GNDを共通。リセット線は接続しません。
リレー指令は、MQTTから受け取ったESP32-C6が、Actuatorのnonceとタグを付けたフレームにして、UARTでSTM32へ渡します。STM32は署名も検証もせず、そのままLoRaに載せて転送します。
OpenRemoteとの接続
OpenRemoteのDockerコンテナによる運用については、別途ブログでまとめます。
MQTTトピック設計
<prefix> はセットアップポータルで設定するトピックプレフィックスです。
| 方向 | トピック | 内容 |
|---|---|---|
| Publish | <prefix>/<node_id>/data |
ハートビート/モーションのJSON(RSSI、SNR、加速度・ジャイロなど) |
| Publish | <prefix>/<node_id>/status |
commanded_on、feedback_pin_high、last_cmd_seq_applied など |
| Subscribe | <prefix>/+/cmd/relay |
ON または 1 でON、それ以外はOFF |
メッセージはQoS 0で発行。
last_cmd_seq_applied は、適用されたコマンドのnonceです。コマンドが適用されるたびに値が変わります。
ブローカー構成
OpenRemoteと同じホスト内に用意したMosquittoを使い、TLSはHAProxyで終端する構成にしています(全てDockerコンテナ)。
ESP32-C6 --TLS 8884–> HAProxy (TLS終端) --平文 1883–> Mosquitto (パスワード認証 + ACL)
OpenRemote標準の8883のブロックには手を加えず、haproxy.cfg に8884用の listen ブロックを追加します。
公的CA(Let’s Encrypt等)の証明書ならESP32側の内蔵CAバンドルで検証できるため、デバイスに証明書ファイルを置く必要はなく、証明書更新時のファーム更新も不要です。
注意点は次のとおりです。
- TLS検証には有効期限の確認が含まれるため、MQTT開始前にSNTPで時刻を同期。
- ブローカーのホスト名は証明書のCN/SANと一致させます(IPアドレスでは検証できません)。
- Mosquittoの1883番ポートはホストに公開せず、Dockerネットワーク内でプロキシからのみ到達できるようにします。
Gateway専用のMosquittoユーザーを作り、ACLで必要最小限に絞ります。
user gw01
topic write lora/+/data
topic write lora/+/status
topic read lora/+/cmd/relay
コマンド認証(SipHash-2-4 + nonce)
LoRa区間そのものには、暗号化も認証もありません。何も対策しないと、電波の届く範囲の第三者が、リレー指令を偽造したり、盗聴した指令を再送したりできてしまいます。 そこで、Gateway→Actuatorのリレー指令に、署名とnonceによる認証を入れました。
仕組み
- Actuatorは、使い捨てのnonce(4バイト)を持ち、すべてのステータス(
StatusPacket)に載せて送ります。 - ESP32-C6は、ステータスから、Actuatorごとの最新のnonceを覚えます。
- MQTTの
cmd/relayを受け取ると、ESP32-C6が、覚えているnonceを入れて、SipHash-2-4で署名(8バイトのタグ)し、UARTでSTM32に渡します。 - STM32 Gatewayは、署名も検証もせず、そのままLoRaで送ります。
- Actuatorは、タグが正しく、nonceが現在のものと一致する場合だけ、リレーを動かします。直後にnonceを新しくして、結果のステータスと一緒に知らせます。
署名の対象は、magic0 から cmdValue までの10バイト(リトルエンディアン)です。宛先、nonce、コマンドの値が含まれるので、どれを書き換えても、タグが合わなくなります。
パケットサイズ
| 区間 | サイズ |
|---|---|
| UART下り(ESP32→STM32) | 19バイト: CC 33 target(2) type value nonce(4) tag(8) CRC8(CRC8の対象は target から tag まで) |
LoRa CmdPacket |
19バイト: tag[8] を追加。cmdSeq 欄には、Actuatorが発行したnonceを入れる |
LoRa StatusPacket |
19バイト: nonce(4バイト)を追加 |
nonceの更新
Actuatorは、次の3つのタイミングで、nonceを新しくします。
- コマンドを適用した後(結果のステータスと同時)
- 60秒ごとの定期ステータスの直前
- 起動時の最初のステータスの直前
nonceは、前回のnonce、カウンタ、micros()、RSSI、ADCの値を、共有鍵つきでハッシュして生成されます。
効果
- 盗聴した「ON」を、OFFにした後で再送しても、適用されません。nonceが、すでに更新されているためです。
- コマンドが適用された直後に、同じパケットが再送されても(リトライ)、再適用されません。ステータスだけが返り、Gatewayは結果を確認できます。
- 署名済みで、届かなかったコマンドは、最長60秒で失効。
- 値、宛先、タグの改変や、鍵の違いは、すべて拒否されます。
鍵の管理
署名には、16バイトの共有鍵を使います。ESP32-C6とActuatorで、同じ値に揃えます。
- STM32側:ビルド時に
stm32_dx-lr30/src/mesh_secret.hで埋め込みます。このファイルはコミットしません(.gitignoreに追記)。 - ESP32-C6側:セットアップポータルの「Relay command key」に、32文字のhexを入力し、NVSに保存します。ファームウェアにも、
sdkconfigにも入りません。 - 鍵の作成:
gen_mesh_key.pyが、乱数で鍵を作り、mesh_secret.hを書き出し、ESP32-C6に入れるhexを表示します。
$ python3 gen_mesh_key.py # 新しい鍵を作る(すでにあれば上書きしない)
$ python3 gen_mesh_key.py --force # 鍵を作り直す(ESP32と全STM32を更新し直す)
ESP32-C6に鍵が未設定、または不正な場合は、リレー指令を無効にして、ログに案内を出します。センサーデータの転送は、通常どおり動きます。
Gatewayのセットアップ
Wi-FiやMQTTの設定はファームウェアに埋め込まず、GatewayがAPモードで提供するWebフォームから入力し、NVSに保存。
- 初回起動、起動時のWi-Fi接続が30秒失敗したとき、または実行中にBOOTボタンを5秒長押ししたときに、セットアップモードに入ります。
- スマートフォンやPCで
LoRa-GW-XXXXに接続します(パスワードはKconfigの設定値。初期値は変更してください)。 http://192.168.4.1/を開きます。キャプティブポータルのDNSはないため、アドレスは手入力です。- SSID、Wi-Fiパスワード、
mqtts://で始まるブローカーURI、MQTTのユーザー名とパスワード、トピックプレフィックス、リレー指令用の鍵(32文字のhex)を入力し、保存して再起動します。
パスワード類と同じく、鍵の欄も空欄のままなら、保存済みの値を維持します。32文字のhex以外は、保存できません。ポータルは、WPA2のAPの上の平文HTTPなので、セットアップは、周囲に他人がいない状況で行ってください。
実行中にWi-Fiが切れてもセットアップモードには入らず、再接続を試み続けます。
現時点の制限と注意点
- 無線は半二重のため、Gatewayが送信中やActuatorの応答待ちの間にパケットを取りこぼすことがあります(Node側のACK/リトライで回復します)。
- MQTTはQoS 0で、切断中のデータは破棄されます(蓄積転送なし)。Gatewayがオフラインの間に発行した指令は失われ、Last Willによる死活トピックもありません。
- 認証が及ぶのは、Gateway→Actuatorのリレー指令だけです。センサーデータ(上り)とActuatorのステータスには署名がなく、偽造される可能性があります。また、暗号化はしていないので、内容は電波を傍受すれば読めます。
- 鍵は、NVSに平文で保存されます(NVS暗号化を有効にしない限り、基板を入手した人は読み出せます)。STM32側は、全デバイスが同じファームウェアなので、すべてのデバイスのFlashに同じ鍵が入ります。1台を入手されると、全体の鍵が分かります。
- ESP32-C6の再起動直後や、コマンドが失敗した直後は、Actuatorのnonceが分からないので、次のステータス(最長60秒)まで、指令が保留されます。通常は、結果のステータスで次のnonceが届くので、連続操作は、すぐに通ります。
- 認証を入れても、施錠や、安全に関わる負荷、無人での商用電源の開閉には、使わないでください。
- 920 MHz帯は法規制の対象です(日本ではARIB STD-T108:認証済みモジュール、キャリアセンス、送信時間制限)。地域の規則を確認してください。
詳細について
下記URLを参照願います。
https://forum.ficusonline.com/t/topic/568/14