背景
- MQTTの勉強を兼ねて、IoTデバイスからのデータをMQTTでホームネットワーク内の機器に配信するアプリをRasPi上に作りたい
ユースケース
- IoT機器から無線経由でデータを受け取る
- SwitchBotの温湿度計からブロードキャストされているデータをBLE経由で受け取る
- (自宅にあったIoT機器でメッセージのフォーマットが公開されているのがこれしかなかった)
- SwitchBotの温湿度計からブロードキャストされているデータをBLE経由で受け取る
- 受け取ったデータを解析して通知するかどうかを判断する
- 通知が必要だと判断したらMQTTでデータPublishする
システム構成
- IotDeviceHub
- アプリ全体のふるまいを管理するクラス
- BluezAbstructLayerからデータを受け取り、DeviceDataObserver(仮)に通知する
- BluezAbstructLayer
- Dbusを介してBluezとのやり取りを担当するクラス
- SensoerDeviceParser
- Dbusからの情報を解析するための抽象クラス
- BluezAbstructLayerに埋め込まれる
- WoTHDeviceParser
- SensoerDeviceParserの具象クラス
- SwitchBotの温湿度計のブロードキャストメッセージに含まれている温度と湿度のデータを取り出す
- それ以外のコンポーネントは今後詳細を決める
BluezAbstructLayer
機能
- BlueZと呼ばれるBluetoothプロトコルスタックがRaspberry Pi上あるそうなのでこれを利用する
- BlueZを使うにはLinuxのDbusと呼ばれるIPCの機能を使うことが一般的なようなので、これも利用する
- ふるまいとしてはBLEスキャン->温湿度計のデバイスを見つける->温湿度を取得する->スキャン停止
BLE scan
- Dbusはメッセージを(オブジェクト)パスと呼ばれる宛先に送ることで操作ができる
- パスはインターフェースと呼ばれるアクセスポイントを持っており、インターフェースはオブジェクトを操作するためのメソッドを持っている
- 今回でいうと、"/org/bluez/hci0"にBlueZのオブジェクトパスが登録されており、そこに"org.bluez.Adapter1"インターフェースを持っており、そのインターフェースが"StartDiscovery"や"StopDiscovery"メソッドを持っている
- スキャン開始は以下のように実装した
bool BluezAbstructLayer::start_scan()
{
if (!m_conn) {
std::cerr << "DBus connection not initialized" << std::endl;
return false;
}
// m_conn: Connection to DBus, has obtained by dbus_bus_get(DBUS_BUS_SYSTEM, &err);
// m_adapter_path: "/org/bluez/hci0"
// BLUEZ_ADAPTER : "org.bluez.Adapter1"
DBusMessage* reply = m_send_dbus_message(m_conn, m_adapter_path, BLUEZ_ADAPTER, "StartDiscovery");
if (!reply) {
std::cerr << "Failed to start discovery" << std::endl;
return false;
}
dbus_message_unref(reply);
return true;
}
DBusMessage* BluezAbstructLayer::m_send_dbus_message(DBusConnection* conn, const std::string& path, const std::string& interface, const std::string& method)
{
// Create dbus message
// BLUEZ_SERVICE: "org.bluez"
DBusMessage* msg = dbus_message_new_method_call(BLUEZ_SERVICE.c_str(), path.c_str(), interface.c_str(), method.c_str());
if (!msg)
{
std::cerr << "Failed method call" << std::endl;
return nullptr;
}
DBusError err;
dbus_error_init(&err);
// Send message via dbus as block request
DBusMessage* reply = dbus_connection_send_with_reply_and_block(conn, msg, -1, &err);
dbus_message_unref(msg);
if (dbus_error_is_set(&err))
{
std::cerr << "DBus error: " << err.message << std::endl;
dbus_error_free(&err);
return nullptr;
}
return reply;
}
ブロードキャストメッセージの取得
- BLEスキャンをすると発見されたデバイスがdbusの/org/bluez/hci0パスの下に/dev_<MAC address>の形式で登録される (MACアドレスの':'は'_'に変換する必要がある)
- 今回は温湿度計のMACアドレスを事前に調べておき、プログラム内にハードコードすることにしたが、将来的にはService UUIDなどを調べて自動で検出できるようにしたい
- Dbusはオブジェクト内の値を取得できる"org.freedesktop.DBus.Properties"というインターフェースがあり、このインターフェースの"GetAll"メソッドで温湿度計からのブロードキャストメッセージを取得できる
- GetAllメソッドは対象のオブジェクトのインターフェースを引数で渡す必要があり、スキャンされたデバイスのインターフェースである"org.bluez.Device1"を設定する
- 参照: https://github.com/luetzel/bluez/blob/master/doc/device-api.txt
- このあたりことのはまだ理解が追い付いていないので説明が間違っているかもしれない
- 今のところオブジェクトは複数のインターフェースを持つことができ、このBlueZのデバイスオブジェクトはプロパティ一般を取得するための"org.freedesktop.DBus.Properties"とBlueZ固有の"org.bluez.Device1"を持っていると理解した
- Dbusからデータが返ってきたら後述のパーサに渡して温湿度を抽出する
- ブロードキャストデータ取得は以下のように実装した
std::vector<uint8_t> BluezAbstructLayer::get_adv_data()
{
std::vector<uint8_t> byte_data={};
if (!m_conn) {
std::cerr << "DBus connection not initialized" << std::endl;
return byte_data;
}
// BLUEZ_SERVICE: "org.bluez"
// m_device_path: /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX
// DBUS_PROPATIES" "org.freedesktop.DBus.Properties"
DBusMessage* msg = dbus_message_new_method_call(BLUEZ_SERVICE.c_str(), m_device_path.c_str(), DBUS_PROPERTIES.c_str(),"GetAll");
if (!msg) {
std::cerr << "Failed to create DBus message\n";
return byte_data;
}
// BLUEZ_DEVICE: "org.bluez.Device1"
const char* interface_name = BLUEZ_DEVICE.c_str();
dbus_message_append_args(msg, DBUS_TYPE_STRING, &interface_name, DBUS_TYPE_INVALID);
DBusError err;
dbus_error_init(&err);
// Send message via dbus as block request
DBusMessage* reply = dbus_connection_send_with_reply_and_block(m_conn, msg, -1, &err);
dbus_message_unref(msg);
if (dbus_error_is_set(&err))
{
std::cerr << "DBus error: " << err.message << std::endl;
dbus_error_free(&err);
return byte_data;
}
if (!reply)
{
std::cerr << "Failed to get properties for " << m_device_path << "\n";
return byte_data;
}
// Parse reply and get temperature and humidity (Later)
byte_data = m_sensorDataParser->parse_reply(reply);
dbus_message_unref(reply);
return byte_data;
}
SensoerDeviceParser
機能
- Dbusからアドバタイズデータを取得しても、おそらくデバイスごとにパースのやり方が変わってくるはずである
- 今後使用するIoT機器を変えることもあると思うので、BluezAbstructLayerはパーサを抽象クラストして持ちコンストラクタで具体的なパーサを受け取るようにする
BluezAbstructLayer::BluezAbstructLayer(SensorDataParser *sensorDataParser)
{
m_sensorDataParser = std::move(sensorDataParser);
:
}
WoTHDeviceParser
機能
- SensoerDeviceParserの具象クラスで、SwitchBotの温湿度計のブロードキャストメッセージに含まれる温度と湿度を取得する
- 端末からこのコマンドを送るとBluezAbstructLayerのget_adv_data()相当のことができる
dbus-send --system --print-reply --dest=org.bluez /org/bluez/hci0/dev_XX_XX_XX_XX_XX_XX (MAC) org.freedesktop.DBus.Properties.GetAll string:"org.bluez.Device1"
- 色々と出力されるがSwitchBotのAPIドキュメントによると"ServiceData"に情報が含まれているようなので、これを取り出したい
dict entry(
string "ServiceData"
variant array [
dict entry(
string "0000fd3d-0000-1000-8000-00805f9b34fb"
variant array of bytes [
54 00 e4 07 8e 2a #これを取り出したい
]
)
]
)
- DBusのDBusMessageIterを使ってパースするのだが、本来の目的はBLE/DBusではなくMQTTの理解であるため、今回はChatGPTに書いてもらった
- 生成されたコードを見る限り、かなり深くネストされた場所に目的のデータがあるので何個もイテレータを作って回しているようだ
- このパーサの正確な理解は今後の課題とする
動作確認
- 本来はIotDeviceHubからBleAbstructLayerを実行するのだが、現時点ではまずは動作確認をしたいのでmainから直接実行する
- BluezAbstructLayerをWoSensorTHDataParserをパーサーにして生成し、Dbusに接続するためのinit()を実行し、スキャンを開始する
- デバイスが見つかるのに少し時間がかかるので10秒ほどスリープさせ、その後アドバタイズデータを取得・表示(後述)させる
- 表示が終わったらスキャンを停止させる
int main() {
BluezAbstructLayer bluez(new WoSensorTHDataParser());
bluez.init();
bluez.start_scan();
sleep(10); //wait to scan devices
print_temp_humid(bluez.get_adv_data());
bluez.stop_scan();
return 0;
}
- データの表示部分は以下のように作った
void print_temp_humid(const std::vector<uint8_t>& data)
{
if(data.size() > SERVICEDATA_LEN) //6
{
std::cerr << "Service Data length is longer than " << SERVICEDATA_LEN << "bytes" << std::endl;
return;
}
uint8_t temp = data[4]&BIT_0_6_MASK;
std::cout << "Temperature: " << ((data[4]&BIT_7_MASK) ? "" : "-") << (int)temp << "°C" << std::endl;
// Notice: API document explains Bit[7] is Templature Scale and Bit[6:0] is Humidity Value
// But as far as I checked with actual value from device, whole Bit[7:0] expresse humidity value, and Bit[7] is not temperature scale
// https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/meter.md#broadcast-mode
uint8_t humid = data[5];
std::cout << "Humidity: " << (int)humid << "%" << std::endl;
}
SwitchBotの公式ドキュメントによると、
Byte: 5 humidity value (WoSensorTH)
- Bit[7] – Temperature Scale
- 0: Celsius scale (°C)
- 1: Fahrenheit scale (°F)
- Bit[6:0] – Humidity Value 000 0000 – 110 0011: 0~99%
とあるが、実際にはByte 5のBit7~0を10進数に直した値とデバイスに表示されている湿度が一致した
°Cと°Fの判定はどのBitに移動したかはわからないが、少なくともドキュメントのByte 5の記載とは合わない結果が得られた
参照: https://github.com/OpenWonderLabs/SwitchBotAPI-BLE/blob/latest/devicetypes/meter.md#broadcast-mode
- 実行結果は以下の通り
$ ./IotDeviceHub
Service UUID: 0000fd3d-0000-1000-8000-00805f9b34fb
Data: 54 00 64 03 92 24
Temperature: 18°C
Humidity: 36%
今後のタスク
- BleAbstructLayerからの温湿度データを監視するObserverクラスの作成
- 温湿度データをMQTTでPublishするクラスの作成
リポジトリ