iPhoneのホームアプリとHomebridgeでLチカしよう

iPhoneのホームアプリや、AndroidのGoogle Homeアプリを使うと、家の中のIoTを制御できるのですが、HomebridgeというNode.jsアプリを立ち上げると、簡単に制御できたので、備忘録として残しておきます。

IoTとして、M5StickCを使い、そこにモーション検知のためにPIRを付けたり、カラーで光らせるためにRGB LEDを付けます。また、M5StickCに備わっているLEDとLCDも制御できるようにします。

M5Stack用RGB LEDユニット(SK6812)
　https://www.switch-science.com/catalog/6550/

M5StickC PIR Hat（AS312搭載）
　https://www.switch-science.com/catalog/5756/

ソースコードはGitHubに上げておきました。

poruruba/Homebridge_Test

https://github.com/poruruba/Homebridge_Test

iPhoneのホームアプリでは以下のように表示され操作できるようになります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/3d42038f-59ac-6d94-66cc-8607f84b69ac.png)

#Homebridgeのインストール

npmを使うやり方と、Dockerを使うやり方があります。
ただ、npmを使う場合でNode.jsのバージョンコントロールとしてnvmを使っているとうまくいかないようだったので、Dockerにしました。

以下のDockerを使いました。

oznu/homebridge

https://hub.docker.com/r/oznu/homebridge/dockerfile

考慮した点としては、以下の環境変数を設定したことぐらいです。

```
HOMEBRIDGE_CONFIG_UI=1
HOMEBRIDGE_CONFIG_UI_PORT=8581
TZ=Asia/Tokyo
```

インストールが完了したら、ポート番号8581でブラウザを開けば、admin/adminでログインできます。

#Homebridgeプラグインのインストール

今回利用するプラグインは以下です。

arachnetech/homebridge-mqttthing

https://www.npmjs.com/package/homebridge-mqttthing

MQTTのトピックに応答できるように作れば、なんでもスマートホームデバイスになれます。
（ということで、言い遅れましたが、MQTTブローカが必要です。こちらも参考にしてください。[AWS IoTにMosquittoをブリッジにしてつなぐ](https://qiita.com/poruruba/items/084317e01b9fababc02e)）

Supereg/homebridge-http-switch

https://www.npmjs.com/package/homebridge-http-switch

単純なOn/OffボタンのデバイスをHTTP Get呼び出しに対応付けられます。

プラグインは、Homebridgeの画面からプラグインタブを選択して表示される画面から登録します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/eb5e0f0f-8fb3-a376-96fc-72a975504ef7.png)

以下が、プラグインインストール後です。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/de03b5a9-ae2d-b748-174a-89a8a540af88.png)

あとは、それぞれの設定リンクをクリックすると表示されるダイアログで入力していきます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/261826/1b01926c-bc87-b989-e7cc-73d0f0aabb16.png)

入力画面は省略しますが、結果として以下のようになるように設定しました。一覧はコンフィグタブで確認できます。

```json
        {
            "type": "switch",
            "name": "スイッチ",
            "url": "mqtt://【MQTTブローカのホスト名】:1883",
            "logMqtt": true,
            "topics": {
                "getOn": "switch/getOn",
                "setOn": "switch/setOn"
            },
            "startPub": [
                {
                    "topic": "switch/startup"
                }
            ],
            "accessory": "mqttthing"
        },
        {
            "type": "motionSensor",
            "name": "モーション",
            "url": "mqtt:// 【MQTTブローカのホスト名】:1883",
            "logMqtt": true,
            "topics": {
                "getMotionDetected": "motion/getMotionDetected"
            },
            "accessory": "mqttthing"
        },
        {
            "type": "lockMechanism",
            "name": "鍵",
            "url": "mqtt:// 【MQTTブローカのホスト名】:1883",
            "topics": {
                "getLockCurrentState": "key/getLockCurrentState",
                "setLockTargetState": "key/setLockTargetState"
            },
            "startPub": [
                {
                    "topic": "key/startup"
                }
            ],
            "accessory": "mqttthing"
        },
        {
            "type": "lightbulb",
            "name": "照明",
            "url": "mqtt:// 【MQTTブローカのホスト名】:1883",
            "topics": {
                "setHSV": "light/setHSV",
                "getHSV": "light/getHSV",
                "getOn": "light/getOn",
                "setOn": "light/setOn"
            },
            "startPub": [
                {
                    "topic": "light/startup"
                }
            ],
            "accessory": "mqttthing"
        },
```

今回は4種類のデバイスを作成しようと思います。

〇スイッチ(type: switch)
　単純にOn/Offを切り替えます。

〇モーション(type: motionSensor)
　PIRを使って動きを検出するとiPhoneに通知されます。

〇鍵(type: lockMechanism)
　鍵をロック・アンロックします。状態変化の際にiPhoneに通知されます。

〇照明(type: lightbulb)
　照明をつけたり消したり、さらに色合いや輝度を変更できます。

上記デバイスのそれぞれの実装に必要なMQTTトピック名と役割をまとめておきます。デバイス側から見たときの視点で記載しています。

|デバイス名|トピック名|pub/sub|用途|
|---|---|---|---|
|スイッチ| switch/setOn|subscribe|Homebridgeがスイッチの状態の変更を要求するときに送信されます。|
||switch/startup|subscribe|Homebrigeが起動したときに送信されます。|
||switch/getOn|publish|Homebridgeにスイッチの状態を送信します。|
|モーション|motion/getMotionDetected|publish|Homebridgeに動きを検出したことを送信します。|
|鍵|key/setLockTargetState|subscribe|Homebridge鍵の状態の変更を要求するときに送信されます。|
||key/startup|subscribe| Homebrigeが起動したときに送信されます。|
||key/getLockCurrentState|publish| Homebridgeに鍵の状態を送信します。|
|照明|light/setHSV|subscribe|Homebridgeがライトの色の設定を要求するときに送信されます。
||light/setOn|subscribe|Homebridgeがライトの電源の変更を要求するときに送信されます。|
||light/getHSV|publish| Homebridgeにライトの色を送信します。|
||light/getOn|publish| Homebridgeにライトの電源OnOff状態を送信します。|
||light/startup|subscribe| Homebrigeが起動したときに送信されます。|

トピック名は自由ですが、デバイスを識別する名前/{set or get or startup}{設定対象} という命名規則で決めています。

getはデバイスからHomebridgeへのデータの方向です。デバイス側の状態が変わったときや、デバイスが起動した後の初期状態をHomebridgeに知らせます。
setはHomebrideからデバイスへのリクエストです。リクエストの処理が終わったら、getで変更後の状態を返してあげます。
startupはHomebridgeからデバイスへのデータ方向ですが、Homebrideの起動時に最初だけ送信されますので、現在状態を返してあげるようにデバイス側を実装します。

#デバイスの実装(Node.js編)

まずは、Node.jsで動きを確認しましょう。デバッグしやすいので、iPhoneのHomeアプリやHomebridgeの癖をつかみましょう。
まず、subscribeで待ち受けるトピックは以下の通りです。

```json:node\api\controllers\homebridge\mqtt.json
[
  {
    "topic": "light/setHSV"
  },
  {
    "topic": "light/setOn"
  },
  {
    "topic": "light/startup"
  },
  {
    "topic": "switch/setOn"
  },
  {
    "topic": "switch/startup"
  },
  {
    "topic": "key/setLockTargetState"
  },
  {
    "topic": "key/startup"
  }
]
```

そして、受信後の処理は以下です。
npmモジュールのmqttを使わせていただいています。

```js:node\api\controllers\homebridge\index.js
'use strict';

const HELPER_BASE = process.env.HELPER_BASE || '../../helpers/';

var status_light = {
  On: false,
  HSV: [0, 0, 0]
};

var status_switch = {
  On: false,
  readonly: true
};

var status_key = {
  State: 'L',
  readonly: false
};

var status_pir = {
  PIR : 0
}

function switch_publishOnOff(mqtt){
  mqtt.publish('switch/getOn', status_switch.On ? "true" : "false");
}

function key_publish(mqtt){
  mqtt.publish('key/getLockCurrentState', status_key.State);
}

function light_publishOnOff(mqtt) {
  mqtt.publish('light/getOn', status_light.On ? "true" : "false");
}

function light_publish(mqtt) {
  mqtt.publish('light/getHSV', status_light.HSV.map(item => Number(item)).join(','));
}

function motion_notify(mqtt){
  mqtt.publish('motion/getMotionDetected', (status_pir.PIR == 0) ? "true" : "false");
}

exports.handler = async (event, context) => {
  console.log(event);
  console.log(context);

  if (context.topic == "key/startup") {
    key_publish(context.mqtt);
  } else
  if( context.topic == 'light/startup'){
    light_publishOnOff(context.mqtt);
    light_publish(context.mqtt);
  }else
  if( context.topic == 'switch/startup'){
    switch_publishOnOff(context.mqtt);
  }else
  if( context.topic == "light/setOn" ){
    status_light.On = (event == "true") ? true : false;

    light_publishOnOff(context.mqtt);
  }else
  if( context.topic == "light/setHSV" ){
    status_light.HSV = event.split(',').map(item => parseInt(item));

    light_publish(context.mqtt);
  }else
  if( context.topic == "switch/setOn" ){
    if( !status_switch.readonly )
      status_switch.On = (event == "true") ? true : false;

    switch_publishOnOff(context.mqtt);
  }else
  if( context.topic == "key/setLockTargetState" ){
    if( !status_key.readonly )
      status_key.State = event;
    
    key_publish(context.mqtt);
  }
};
```

MQTTメッセージを受信すると、``` exports.handler = async (event, context)```が呼び出されるようにプラットフォームを実装していますが、詳細はGitHubの「routing_mqtt.js」を参照してください。MQTTブローカー名は、.envに記載します。

#デバイス側の実装(ESP32編)

ESP32を搭載したM5StickCで実際に周辺デバイスを制御しながらHomebridgeと連携してみます。
PlatformIOで実装しました。
周辺デバイスとの対応付けは以下の通りです。

〇スイッチ
　M5StickC本体に搭載しているLEDのOnOff
　M5StickCのBボタンでトグルできるようにします。Homebridgeからの要求(iPhoneのHomeアプリからの要求)では変更できないようにしています。

〇鍵
　M5StickCのLCDに鍵の状態を表示します。
　Homebridgeからの要求(iPhoneのHomeアプリからの要求)に加えて、M5StickCのAボタンでもトグルできるようにします。

〇モーション
　M5StickCのハット部分にPIRセンサーを接続し、動き検出します。

〇照明
　M5StickCのGROVE端子にRGBユニットを接続しカラーで光らせます。Homebridgeからの要求(iPhoneのHomeアプリからの要求)で色を変えられます。

以下のライブラリを使わせていただきました。ありがとうございました。

* m5stack/M5StickC
* lovyan03/LovyanGFX
* knolleary/PubSubClient
* adafruit/Adafruit NeoPixel

```c:arduino\HomebridgeDevice\src\main.cpp
#include <M5StickC.h>
#include <WiFi.h>
#include <PubSubClient.h>
#include <Adafruit_NeoPixel.h>
#define LGFX_AUTODETECT
#include <LovyanGFX.hpp>

const char *wifi_ssid = "【WiFiアクセスポイントのSSID】"; // WiFiアクセスポイントのSSID
const char *wifi_password = "【WiFiアクセスポイントのパスワード】"; // WiFiアクセスポイントのパスワード
const char *MQTT_BROKER_URL = "【MQTTブローカーのホスト名】";
const char *MQTT_CLIENT_NAME = "Homebridge"; // MQTTサーバ接続時のクライアント名

#define PIN_LED 10
#define PIN_RGB 32
#define NUM_PIXEL 3
#define PIN_PIR 36
#define RGB_BRIGHTNESS  64
#define MQTT_BROKER_PORT 1883 // MQTTサーバのポート番号(TCP接続)
#define LCD_BRIGHTNESS 64     // LCDのバックライトの輝度(0～255)
#define MQTT_BUFFER_SIZE 255  // MQTT送受信のバッファサイズ

// 扱うトピック名
const char *MQTT_TOPIC_KEY_STARTUP = "key/startup";
const char *MQTT_TOPIC_KEY_SETLOCK = "key/setLockTargetState";
const char *MQTT_TOPIC_KEY_GETLOCK = "key/getLockCurrentState";
const char *MQTT_TOPIC_SWITCH_STARTUP = "switch/startup";
const char *MQTT_TOPIC_SWITCH_GETON = "switch/getOn";
const char *MQTT_TOPIC_SWITCH_SETON = "switch/setOn";
const char *MQTT_TOPIC_LIGHT_STARTUP = "light/startup";
const char *MQTT_TOPIC_LIGHT_SETON = "light/setOn";
const char *MQTT_TOPIC_LIGHT_GETON = "light/getOn";
const char *MQTT_TOPIC_LIGHT_SETHSV = "light/setHSV";
const char *MQTT_TOPIC_LIGHT_GETHSV = "light/getHSV";
const char *MQTT_TOPIC_GetMotionDetected = "motion/getMotionDetected";

// Subscribe対象のトピック
const char *topic_subscribe_list[] = {
  MQTT_TOPIC_KEY_STARTUP,
  MQTT_TOPIC_KEY_SETLOCK,
  MQTT_TOPIC_SWITCH_STARTUP,
  MQTT_TOPIC_SWITCH_SETON,
  MQTT_TOPIC_LIGHT_STARTUP,
  MQTT_TOPIC_LIGHT_SETON,
  MQTT_TOPIC_LIGHT_SETHSV
};

// LCDに表示する画像ファイル
extern const uint8_t lock_png_start[] asm("_binary_data_lock_png_start");
extern const uint8_t lock_png_end[] asm("_binary_data_lock_png_end");
extern const uint8_t unlock_png_start[] asm("_binary_data_unlock_png_start");
extern const uint8_t unlock_png_end[] asm("_binary_data_unlock_png_end");

// グローバル変数
Adafruit_NeoPixel pixels = Adafruit_NeoPixel(NUM_PIXEL, PIN_RGB, NEO_GRB + NEO_KHZ800);
WiFiClient wifiClient;
PubSubClient mqttClient(wifiClient);
static LGFX lcd; // for LovyanGFX

// 関数宣言
void wifi_connect(const char *ssid, const char *password);
long parse_uint16Array(const char *p_message, unsigned int length, uint16_t *p_array, int size);

// デバイスの状態を保持する変数
bool status_light_ON = false;
float status_light_hue = 1.0;
float status_light_saturation = 1.0;
float status_light_value = 1.0;
bool status_switch_ON = false;
bool status_switch_readonly = true;
int status_pir = 0;
bool status_key_lock = true;
bool status_key_readonly = false;

// デバイスの状態を処理する関数
void switch_viewUpdate(void){
  digitalWrite(PIN_LED, status_switch_ON ? LOW : HIGH);
}

void key_viewUpdate(void){
  if (status_key_lock )
    lcd.drawPng(lock_png_start, lock_png_end - lock_png_start);
  else
    lcd.drawPng(unlock_png_start, unlock_png_end - unlock_png_start);
}

void light_viewUpdate(void){
  uint32_t hsv;
  if( status_light_ON )
    hsv = pixels.ColorHSV(status_light_hue * 65535, status_light_saturation * 255, status_light_value * 255);
  else
    hsv = pixels.ColorHSV(0.0, 0.0, 0.0);

  for( int i = 0 ; i < NUM_PIXEL ; i++ )
    pixels.setPixelColor(i, hsv);
  pixels.show();
}

void light_publishOnOff(void){
  mqttClient.publish(MQTT_TOPIC_LIGHT_GETON, status_light_ON ? "true" : "false");
}

void light_publish(void){
  String hsv_str = "";
  hsv_str += round(status_light_hue * 360);
  hsv_str += ",";
  hsv_str += round(status_light_saturation * 100);
  hsv_str += ",";
  hsv_str += round(status_light_value * 100);
  mqttClient.publish(MQTT_TOPIC_LIGHT_GETHSV, hsv_str.c_str());
}

void key_publish(void){
  mqttClient.publish(MQTT_TOPIC_KEY_GETLOCK, status_key_lock ? "S" : "U");
}

void switch_publishOnOff(void){
  mqttClient.publish(MQTT_TOPIC_SWITCH_GETON, status_switch_ON ? "true" : "false");
}

void motion_notify(void){
  mqttClient.publish(MQTT_TOPIC_GetMotionDetected, (status_pir != 0) ? "true" : "false");
}

// MQTTメッセージ受信後の処理
void mqtt_callback(char* topic, byte* payload, unsigned int length) {
  Serial.println("mqtt_callback");
  Serial.println(topic);
//  Serial.println((char*)payload);
//  Serial.println(length);

  if( strcmp(topic, MQTT_TOPIC_LIGHT_STARTUP) == 0 ){
    light_publishOnOff();
    light_publish();
  }else
  if( strcmp(topic, MQTT_TOPIC_KEY_STARTUP) == 0 ){
    key_publish();
  }else
  if( strcmp(topic, MQTT_TOPIC_SWITCH_STARTUP) == 0 ){
    switch_publishOnOff();
  }else
  if (strcmp(topic, MQTT_TOPIC_KEY_SETLOCK) == 0 ){
    if( !status_key_readonly )
      status_key_lock = (*payload == 'S') ? true : false;
    key_viewUpdate();
    key_publish();
  }else
  if (strcmp(topic, MQTT_TOPIC_SWITCH_SETON) == 0){
    if( !status_switch_readonly ){
      if (strncmp((char *)payload, "true", 4) == 0)
        status_switch_ON = true;
      else
        status_switch_ON = false;
    }
    switch_publishOnOff();
  }else
  if (strcmp(topic, MQTT_TOPIC_LIGHT_SETON) == 0){
    if (strncmp((char *)payload, "true", 4) == 0)
      status_light_ON = true;
    else
      status_light_ON = false;

    light_viewUpdate();
    light_publishOnOff();
  }
  else if (strcmp(topic, MQTT_TOPIC_LIGHT_SETHSV) == 0){
    uint16_t array[3];
    long ret = parse_uint16Array((const char*)payload, length, array, 3);
    if( ret != 0 ){
      Serial.println("parse_uint16Array error");
      return;
    }

    status_light_hue = array[0] / 360.0;
    status_light_saturation = array[1] / 100.0;
    status_light_value = array[2] / 100.0;

    Serial.printf("h=%f, s=%f, v=%f\n", status_light_hue, status_light_saturation, status_light_value);

    light_viewUpdate();
    light_publish();
  }
}

void setup(){
  // put your setup code here, to run once:
  M5.begin();
  M5.Axp.begin();

  lcd.init(); // M5StickCのLCDの初期化
  lcd.setBrightness(LCD_BRIGHTNESS);
  lcd.setRotation(0);
  lcd.fillScreen(BLACK);

  wifi_connect(wifi_ssid, wifi_password);

  pinMode(PIN_LED, OUTPUT);
  pinMode(PIN_PIR, INPUT_PULLUP);
  pixels.begin();
  pixels.setBrightness(RGB_BRIGHTNESS);

  light_viewUpdate();
  key_viewUpdate();
  switch_viewUpdate();

  // バッファサイズの変更
  mqttClient.setBufferSize(MQTT_BUFFER_SIZE);
  // MQTTコールバック関数の設定
  mqttClient.setCallback(mqtt_callback);
  // MQTTブローカに接続
  mqttClient.setServer(MQTT_BROKER_URL, MQTT_BROKER_PORT);
}

void loop() {
  // put your main code here, to run repeatedly:
  M5.update();
  mqttClient.loop();
  // MQTT未接続の場合、再接続
  while (!mqttClient.connected()){
    Serial.println("Mqtt Reconnecting");
    if (mqttClient.connect(MQTT_CLIENT_NAME)){
      for (int i = 0; i < sizeof(topic_subscribe_list) / sizeof(const char *); i++)
        mqttClient.subscribe(topic_subscribe_list[i]);

      switch_publishOnOff();
      key_publish();
      light_publishOnOff();
      light_publish();

      break;
    }

    delay(1000);
  }

  if( M5.BtnA.wasReleased() ){
    Serial.println("BtnA pressed");

    // 鍵の状態のトグル
    status_key_lock = !status_key_lock;

    key_viewUpdate();
    key_publish();

    delay(10);
  }
  if( M5.BtnB.wasReleased() ){
    Serial.println("BtnB pressed");

    // スイッチの状態のトグル
    status_switch_ON = !status_switch_ON;

    switch_viewUpdate();
    switch_publishOnOff();

    delay(10);
  }

  // モーションセンサーの読み出し
  int pir = digitalRead(PIN_PIR);
  if( status_pir != pir ){
    Serial.println("PIR changed");

    // 動き検出状態のトグル
    status_pir = pir;

    motion_notify();
  }

  delay(1);
}

// WiFiアクセスポイントへの接続
void wifi_connect(const char *ssid, const char *password)
{
  Serial.println("");
  Serial.print("WiFi Connenting");
  lcd.println("WiFi Connecting");

  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED)
  {
    Serial.print(".");
    lcd.print(".");
    delay(1000);
  }
  Serial.println("Connected");
  Serial.println(WiFi.localIP());
}

// カンマ(,)区切りの10進数の解析
long parse_uint16Array(const char *p_message, unsigned int length, uint16_t *p_array, int size){
  char temp[6];
  const char *p_str = p_message;
  for( int i = 0 ; i < size - 1 ; i++ ){
    char *ptr = strchr(p_str, ',');
    if (ptr == NULL)
      return -1;

    int len = ptr - p_str;
    if (len > sizeof(temp) - 1)
      return -2;

    memset(temp, '\0', sizeof(temp));
    memmove(temp, p_str, len);
    p_array[i] = atoi(temp);
    ptr++;
    p_str = ptr;
  }
  int len = length - (p_str - (char *)p_message);
  if (len > sizeof(temp) - 1)
    return -3;

  memset(temp, '\0', sizeof(temp));
  memmove(temp, p_str, len);
  p_array[size - 1] = atoi(temp);

  return 0;
}
```

#おわりに

Supereg/homebridge-http-switch
を使ったサンプルは次回にでも。
あと、AndroidやGoogleHomeアプリとも連携できるので、それも次回で。

以上