方言を話すおしゃべり猫型ロボット『ミーア』をリリースしました(こちらをクリック)

【Flutter × ESP32】AWS Device Shadow・MQTT経由でおやすみモードの時間設定を同期

この記事は約18分で読めます。

はじめに

前回の記事「眠りモードに至るまでの時間をアプリ側(Flutter)で設定して、サーバー側(Go)に反映させる。」では、ユーザーがアプリで設定した「おやすみモード」までの時間をサーバー側のユーザーデータベースに反映させる方法について記載した。

今回はその次のステップとして、データベースに反映された時間設定をデバイス(ESP32)側に通知するプロセスを開発。具体的には、AWSのDevice ShadowサービスとMQTTプロトコルを使用してデバイスとの通信を行う。

AWS Device ShadowとMQTTプロトコルについて

AWS Device Shadowとは

AWS Device Shadowは、IoTデバイスの状態をクラウド上に保存・同期するサービス。Device Shadowを使用すると、Deviceがオフラインの場合でもその最新の状態を保存しておくことができる。Deviceが再びオンラインになった時に、クラウド側のデータと同期を行い、デバイスの状態を最新のものに更新することが可能。

https://docs.aws.amazon.com/ja_jp/iot/latest/developerguide/iot-device-shadows.html

MQTT・トピックとは

MQTT(Message Queuing Telemetry Transport)は、軽量なメッセージングプロトコルで、ネットワーク帯域が限られた環境やリモートシステム間の通信に適している。IoTデバイス間の通信や、デバイスとサーバー間の通信によく使用される。

Publisher(送信者), Broker(仲介者), Subscriber(購読者)の3者がいて、PublisherがBroker経由で、Subscriber(1:1 or 1: 複数。選べる)にメッセージを送信する。

また、MQTTにはトピックという仕組みがある。トピックは、メッセージが送信される際に使用されるラベルのこと。

トピック名は、スラッシュ (/) 文字を使用して階層のレベルを区切ることにより、情報の階層を表すことができる。たとえば、このトピック名は、部屋 1 の温度センサーを表すことができる。

  • sensor/temperature/room1

下記でAPP 1 がsensor/2/temperatureトピックをサブスクライブすると、このトピックに公開しているセンサー 2 からメッセージを受信する。

出典:https://www.emqx.com/ja/blog/advanced-features-of-mqtt-topics

実装概要:アプリ→サーバー→Device shadow→ESP32

アプリからのリクエストに応じて、デバイスシャドウを介してESP32デバイスの設定を更新するプロセスの概要。

アプリからの設定更新リクエスト:

ユーザーがアプリを通じて設定を更新すると、その更新リクエストはバックエンドサーバーを通じてAWS IoT Coreのデバイスシャドウのdesiredセクションに反映される。

デバイスシャドウのdesiredセクションの更新:

バックエンドサーバーは、受け取った設定更新リクエストに基づいてデバイスシャドウのdesiredセクションを更新する。このdesiredセクションは、デバイスに適用されるべき望ましい設定を表す。

ESP32デバイスへの通知:

デバイスシャドウのdesiredセクションが更新されると、その情報はMQTTメッセージとしてESP32デバイスに通知される。この通知は、デバイスシャドウの/update/deltaトピックを通じて送信される。

ESP32デバイスによる差分の取得:

ESP32デバイスは、/update/deltaトピックからメッセージを受け取り、その内容を解析して、desiredセクションとデバイスの現在の設定(reportConfig)との間の差分を取得する。

設定の同期と反映:

ESP32デバイスは、取得した差分に基づいて自身の設定を更新する。このプロセスでは、デバイスの現在の設定(reportConfig)がdesiredセクションに記述された設定に合わせて変更される。

更新後の状態の報告:

設定の更新が完了した後、ESP32デバイスは新しい設定をデバイスシャドウのreportedセクションに報告する。これにより、デバイスシャドウのreportedセクションはデバイスの実際の状態を正確に反映するようになる。

この手順に合わせて、実装していく。

実装:サーバー側(Go言語)

DeviceConfig 構造体に SleepTransitionTime フィールドを追加

この変更により、DeviceConfig 構造体には SleepTransitionTime フィールドが含まれるようになり、UserToDeviceConfig 関数では User インスタンスからこの新しいフィールドの値を DeviceConfig に反映させることができる。

SleepTransitionTime を含む新しいデバイス設定は、JSON形式で、デバイスの望ましい状態(desired state)を表す。更新されたDevice Shadowは、MQTTの通信プロトコルを使用してESP32にJSON形式で送信される。

Go
// device_shadow.go
package clocky_be

import (
	"encoding/json"

	"github.com/EarEEG-dev/clocky_be/types"
)

type DeviceConfig struct {
	TalkFrequency           int                  `json:"talk_frequency"`
	BirthDate               types.NullDate       `json:"birth_date"`
	PhraseType              types.NullString     `json:"phrase_type"`
	TalkStartTime           types.NullHourMinute `json:"talk_start_time"`
	TalkEndTime             types.NullHourMinute `json:"talk_end_time"`
	WeatherAnnouncementTime types.NullHourMinute `json:"weather_announcement_time"`
	WorkStartTime           types.NullHourMinute `json:"work_start_time"`
	WorkEndTime             types.NullHourMinute `json:"work_end_time"`
	SleepTransitionTime     types.NullInt64      `json:"sleep_transition_time"` // 追加したフィールド
	Volume                  int                  `json:"volume"`
}

// ... 他の構造体と関数 ...

func UserToDeviceConfig(user *User) DeviceConfig {
	return DeviceConfig{
		TalkFrequency:           int(user.TalkFrequency.Int64),
		BirthDate:               user.BirthDate,
		PhraseType:              user.PhraseType,
		TalkStartTime:           user.TalkStartTime,
		TalkEndTime:             user.TalkEndTime,
		WeatherAnnouncementTime: user.WeatherAnnouncementTime,
		WorkStartTime:           user.WorkStartTime,
		WorkEndTime:             user.WorkEndTime,
		SleepTransitionTime:     user.SleepTransitionTime, // 新しいフィールドの値を設定
		Volume:                  int(user.Volume.Int64),
	}
}

// ... 他の関数 ...

実装:デバイス側(Platform IO)

ESP32でのシリアライズ/デシリアライズ処理

ESP32はJSON形式のデータを受け取り、それをデシリアライズ(JSONを解析して構造体や変数に変換)する必要がある。Goのように直接JSONから構造体に変換できる簡単にデシリアライズを行うライブラリが見つけられなかったので、愚直に実装。

sleep_transition_timeDeviceConfig 構造体に追加し、関連する読み取りと書き込み処理にも反映させるようにする。

DeviceConfig 構造体に sleep_transition_time フィールドを追加:

readDeviceConfig 関数に sleep_transition_time の読み取り処理を追加:

writeDeviceConfig 関数に sleep_transition_time の書き込み処理を追加:

isDeviceConfigChanged 関数に sleep_transition_time の変更検出処理を追加:

  • 2つの DeviceConfig インスタンス(デバイスの現在の設定(reportConfig)と、デバイスに適用されるべき新しい設定(desiredConfig))を比較して、sleep_transition_time を含むどの設定が変更されたかを検出する。

updateDeviceConfig 関数に sleep_transition_time の更新処理を追加:

  • 望ましい設定 (desiredConfig) に基づいて、デバイスが報告する設定 (reportConfig) を更新。この部分で、sleep_transition_time の新しい値がデバイスの動作設定に反映される。
C++
// src/json/device_config.h
#pragma once

#include "ArduinoJson.h"
#include "tl/expected.hpp"
#include "json/types.h"

struct DeviceConfig {
  std::optional<int> talk_frequency;
  std::optional<Time> weather_announcement_time;
  std::optional<Date> birth_date;
  std::optional<String> phrase_type;
  std::optional<Time> talk_start_time;
  std::optional<Time> talk_end_time;
  std::optional<Time> work_start_time;
  std::optional<Time> work_end_time;
  std::optional<int> sleep_transition_time;  // 追加されたフィールド
  std::optional<int> volume;
};

std::optional<DeviceConfig> readDeviceConfig(const JsonVariant &json, const char *key);

void writeDeviceConfig(const DeviceConfig &config, JsonObject &obj);

String serializeDeviceConfig(const DeviceConfig &config);

tl::expected<DeviceConfig, String> deserializeDeviceConfig(const String &payload);

bool isDeviceConfigChanged(const DeviceConfig &config1, const DeviceConfig &config2);

void updateDeviceConfig(std::optional<DeviceConfig> &reportConfig, std::optional<DeviceConfig> &desiredConfig);
C++
// src/json/device_config.cpp

#include "device_config.h"
#include "json_utils.h"

struct DeviceConfig {
  // ... 他のフィールド ...
  std::optional<int> sleep_transition_time; // 追加されたフィールド
  // ... 他のフィールド ...
};

std::optional<DeviceConfig> readDeviceConfig(const JsonVariant &json, const char *key) {
  if (json.containsKey(key)) {
    DeviceConfig state;
    // ... 他のフィールドの読み取り ...
    state.sleep_transition_time = read<int>(json[key], "sleep_transition_time"); // 追加
    return state;
  }
  return std::nullopt;
}

void writeDeviceConfig(const DeviceConfig &config, JsonObject &obj) {
  // ... 他のフィールドの書き込み ...
  writeOpt(obj, "sleep_transition_time", config.sleep_transition_time); // 追加
}

// ... 他の関数 ...

bool isDeviceConfigChanged(const DeviceConfig &config1, const DeviceConfig &config2) {
  // ... 他のフィールドの比較 ...
  if (config1.sleep_transition_time != config2.sleep_transition_time) { // 追加
    return true;
  }
  // ... 他のフィールドの比較 ...
}

void updateDeviceConfig(std::optional<DeviceConfig> &reportConfig, std::optional<DeviceConfig> &desiredConfig) {
  // ... 他のフィールドの更新 ...
  if (desiredConfig->sleep_transition_time) { // 追加
    reportConfig->sleep_transition_time = desiredConfig->sleep_transition_time.value();
  }
  // ... 他のフィールドの更新 ...
}

// ... 他の関数 ...

これにより、sleep_transition_timeDeviceConfig 構造体の一部として扱われ、JSONの読み取り・書き込み、設定の比較・更新に対応できるようになる。

MQTTを介してデバイスシャドウの変更をリアルタイムに取得

sync_device_shadow.cppで、MQTTを介してデバイスシャドウの変更をリアルタイムに取得する。

メッセージハンドラの設定:

C++
// sync_device_shadow.cpp

// TopicごとにHandlerを呼び出す
  this->client->onMessage([this](const String &topic, const String &payload) {
    if (topic == topicGetAccepted) {
      if (auto result = handleShadowGetAccepted(payload); !result) {
        Serial.println(result.error().c_str());
      }
    } else if (topic == topicUpdateDelta) {
      if (auto result = handleShadowUpdateDelta(payload); !result) {
        Serial.println(result.error().c_str());
      }
    }
  });

設定値の同期:

C++
// デバイスシャドウの更新があった場合に呼ばれる
tl::expected<void, String> SyncDeviceShadow::handleShadowUpdateDelta(const String &payload) {
  auto resultDelta = deserializeSubscribeShadowUpdateDelta(payload);
  if (!resultDelta) {
    return tl::make_unexpected("can not deserialize delta shadow: " + resultDelta.error());
  }
  // 現在の設定
  const auto currentConfig = this->shadow.reported->config;
  // 更新された設定
  const auto updatedConfig = resultDelta->state->config;

  // コールバック
  const auto callbackResult = this->updateCallback(currentConfig, updatedConfig);
  if (!callbackResult) {
    return tl::make_unexpected("can not update config: " + callbackResult.error());
  }
  // 更新された設定を反映
  updateDeviceConfig(this->shadow.reported->config, resultDelta->state->config);
  if (const bool isReported = report(); !isReported) {
    return tl::make_unexpected("can not report state: " + resultDelta.error());
  }
  return {};
}

設定値へのアクセス:

C++
// main.cpp
auto const config = syncDeviceShadow->getConfig();

  // 設定した時刻の範囲外なら後続のアクションをスキップ
  if (!isWithinTalkTime(config->talk_start_time, config->talk_end_time, now_tm)) {
    // 設定した時刻の範囲外なら後続のアクションをスキップ
    Serial.println("out of talk time");
    return {};
  }

検証

アプリ側で、おやすみモード移行時間をデフォルトの10分後から、30分後に変更してみる。

アプリからサーバーに対して、下記のように変更リクエストが飛ぶ

C++
flutter: Request updateUser: {"sleep_transition_time":30}

サーバー側のログで、アプリ側でsleep_transition_time変更要求をしたデバイス(test_002)のDevice Shadowをupdateしたと通知

C++
published message to topic: $aws/things/test_002/shadow/update

デバイス側(ESP32)のログ

デバイスシャドウからの差分情報 (Delta Message):

C++
MQTTPubSubClient::onMessage: $aws/things/test_002/shadow/update/delta {"version":1080,"timestamp":1707014021,"state":{...}}
  • デバイスシャドウからデバイスに送信された差分(デルタ)メッセージを示している。
  • トピック $aws/things/test_002/shadow/update/delta は、test_002 のデバイスシャドウに変更があった場合に、その差分情報が送信されるトピック。
  • デルタメッセージは、デバイスシャドウの望ましい状態と報告された状態の間の差分を示す。この例では、phrase_typevolume、**sleep_transition_time**の値に変更があったことを示している。

デバイスによる設定の変更の確認:

・デバイスが設定の変更を検出し、ESP32側のコードで設定変更を反映したことをログに出力(自前で実装した分)

C++
device config changed

変更後の設定のデバイスシャドウへの報告 (Publish):

C++
MQTTPubSubClient::publish: $aws/things/test_002/shadow/update {"state":{...}}
  • デバイスが設定の変更を受け入れ、その新しい状態をデバイスシャドウに報告していることを示している。
  • ここで送信されたJSONデータには、新しい設定値が含まれており、デバイスシャドウの「reported」セクションにこれらの値が反映される。

AWS IoTのDevice Shadowドキュメント(AWS IoT > 管理 > モノ > device id > Device Shadowタブ)で、reportedセクションに、sleep_transition_timeが30に変更されていることを確認できた

コメント

タイトルとURLをコピーしました