600円の温湿度計(LYWSD03MMC)とESP32でIoT

Xiaomiから発売されているMijiaブランドの温湿度計"LYWSD03MMC"は600円程度と安価にもかかわらずBLEに対応している。 この温湿度計のデータをESP32で収集して、IoTサービス Ambient に記録した。

温湿度計
https://buy.mi.com/hk/item/3202200073

できること


温湿度の記録がブラウザからグラフ確認できるようになる。

graph

あらかじめやっておくこと


温湿度計の購入
Aliexpress

ESP32モジュールの購入
ス イッチサイエンス
秋 月電子

ESP32を常時稼働させるのでUSBアダプターと変換コネクタがあるとよい。
USBアダ プター
USB

変 換コネクタ
connector

Ambientのアカウント作成


目次


・温湿度計にカスタムファームウェアを適用
・AmbientのチャネルIDとライトキーの確認
・ESP32プログラム
・Ambientの設定


温湿度計にカスタムファームウェアを適用


LYWSD03MMCのBLEは暗号化されているため、これを解除するためのカスタムファームウェアを適用する。
詳細はこちら→ https://github.com/pvvx/ATC_MiThermometer

1. 温湿度計の電源を入れる。

2. Chromeブラウザを使用して chrome://flags/#enable-experimental-web-platform-features にアクセスする。

3. Experimental Web Platform features をEnableにしてブラウザを再起動する。
0000

4. https://github.com/pvvx/ATC_MiThermometer にアクセスする。

5. 「Fiemware」の項にある「LYWSD03MMC Custom Firmware Version 3.7」(.binファイル)を保存する。
0000b

6. https://pvvx.github.io/ATC_MiThermometer/TelinkMiFlasher.html にアクセスする。

7. 「Connect」を選択。
0001

8. しばらくすると「LYWSD03MMC」が表示されるので、これを選択して「ペア設定」を選択。
0002

9. 「Do Activation」を選択。
0003

10. Select Firmwareの「ファイルを選択」を選択。
0004

11. 先ほど保存した「ATC_v37b.bin」を開く。

12. 「Start Flashing」を選択。
0005

13. 数分後にファームウェアが適用され、Status:Disconnected となるので、もう一度「Connect」を選択。
0006

14.「ATC_?????? - ペア設定済み」が表示されるので、ATC_以降の6桁(ここでは97C5B1)をメモしておく。これは温湿度計のMACアドレス下6桁を表している。メモしたらキャンセル を選択して終了。
0007


AmbinetのチャネルIDとライトキーの確認


1. https://ambidata.io/ch/channels.html にアクセスしてログインする。

2. 「チャネルを作る」を選択。
00083. チャネルが作成されたら、「チャネルID」と「ライトキー」をメモしておく。
 これらはESP32のプログラム時に使用する。
0009


ESP32プログラム


1. ESP32モジュールの開発環境セットアップ
http://trac.switch-science.com/wiki/esp32_setup

2. Ambientライブラリーのインストール
https://ambidata.io/docs/esp8266/#library_import

3. ArduinoIDEを開き、「ツール」→「Partition Scheme」→「Huge APP(3MB No OTA/1MB SPIFFS)」を選択する。
0010

4. ESP32モジュールのArduinoプログラムはこちら↓


        LYWSD03MMC.ino (クリックでダウンロード)


5. プログラムの以下の箇所を修正する。
    (修正例)
    const char* ssid = "ssid";
    const char* password = "password";
    unsigned int channelId_01 = 48997;    //ここには" "を付けない
    const char*  writeKey_01  = "13fa17a451333efd";
    uint8_t mac_01[6] = {0xa4,0xc1,0x38,0x97,0xC5,0xB1};  //A4:C1:38:97:C5:B1 (ATC_97C5B1の場合)
    uint8_t mac_02[6] = {0xa4,0xc1,0x38,0xBB,0xBB,0xBB};  //A4:C1:38:BB:BB:BB
    uint8_t mac_03[6] = {0xa4,0xc1,0x38,0xCC,0xCC,0xCC};  //A4:C1:38:CC:CC:CC
    uint8_t mac_04[6] = {0xa4,0xc1,0x38,0xDD,0xDD,0xDD};  //A4:C1:38:DD:DD:DD

#include <HardwareSerial.h>
#include <BLEDevice.h>
#include <BLEUtils.h>
#include <BLEScan.h>
#include <BLEAdvertisedDevice.h>
#include <sstream>
#include <Ambient.h>

WiFiClient client;
Ambient ambient_01;
BLEScan* pBLEScan;

const char* ssid = "ここにWifiのSSIDを入力";
const char* password = "ここにWifiのパスワードを入力";
unsigned int channelId_01 = ここにAmbientのチャネルIDを入力;
const char*  writeKey_01  = "ここにAmbinentのライトキーを入力";

int scanTime = 120; // seconds
uint8_t mac[6];
float temp;
float humidity;
float temp_cache_01;
float temp_cache_02;
float temp_cache_03;
float temp_cache_04;
          
uint8_t mac_01[6] = {0xa4,0xc1,0x38,0xAA,0xAA,0xAA};  //A4:C1:38:AA:AA:AA
uint8_t mac_02[6] = {0xa4,0xc1,0x38,0xBB,0xBB,0xBB};  //A4:C1:38:BB:BB:BB
uint8_t mac_03[6] = {0xa4,0xc1,0x38,0xCC,0xCC,0xCC};  //A4:C1:38:CC:CC:CC
uint8_t mac_04[6] = {0xa4,0xc1,0x38,0xDD,0xDD,0xDD};  //A4:C1:38:DD:DD:DD


void printBuffer(uint8_t* buf, int len) {
  for (int i = 0; i < len; i++) {
    Serial.printf("%02x", buf[i]);
  }
  Serial.print("\n");
}

void parse_value(uint8_t* buf, int len) {
  int16_t x = buf[3];
  if (buf[2] > 1)
    x |=  buf[4] << 8;
  switch (buf[0]) {
    case 0x0D:
      if (buf[2] && len > 6) {
        temp = x / 10.0;
        x =  buf[5] | (buf[6] << 8);
        humidity = x / 10.0;
        Serial.printf("Temp: %.1f°, Humidity: %.1f %%\n", temp, humidity);
      }
      break;
    case 0x04: {
        temp = x / 10.0;
        Serial.printf("Temp: %.1f°\n", temp);
      }
      break;
    case 0x06: {
        humidity = x / 10.0;
        Serial.printf("Humidity: %.1f%%\n", humidity);
      }
      break;
    case 0x0A: {
        Serial.printf("Battery: %d%%", x);
        if (len > 5 && buf[4] == 2) {
          uint16_t battery_mv = buf[5] | (buf[6] << 8);
          Serial.printf(", %d mV", battery_mv);
        }
        Serial.printf("\n");
      }
      break;
    default:
      Serial.printf("Type: 0x%02x ", buf[0]);
      printBuffer(buf, len);
      break;
  }
}


// Sensor No.1 - No.4 //

void ambient_01_set(void){
  if(memcmp(mac, mac_01, 6) == 0){
    Serial.printf("Sensor No.1 ... Temp: %.2f°, Humidity: %.1f%%\n", temp, humidity);
    if (abs(temp - temp_cache_01) < 1){
      ambient_01.set(1, temp);                
      ambient_01.set(2, humidity);
      //Serial.println(" - ambient_01 set 1");                
    }
    temp_cache_01 = temp; 
    }
  else if(memcmp(mac, mac_02, 6 )== 0){
    Serial.printf("Sensor No.2 ... Temp: %.2f°, Humidity: %.1f%%\n", temp, humidity);
    if (abs(temp - temp_cache_02) < 1){
      ambient_01.set(3, temp);                
      ambient_01.set(4, humidity);
      //Serial.println(" - ambient_01 set 2");               
    }
    temp_cache_02 = temp;               
  }
  else if(memcmp(mac, mac_03, 6 )== 0){
    Serial.printf("Sensor No.3 ... Temp: %.2f°, Humidity: %.1f%%\n", temp, humidity);
    if (abs(temp - temp_cache_03) < 1){
      ambient_01.set(5, temp);                
      ambient_01.set(6, humidity);
      //Serial.println(" - ambient_01 set 2");               
    }
    temp_cache_03 = temp;               
  }
  else if(memcmp(mac, mac_04, 6 )== 0){
    Serial.printf("Sensor No.4 ... Temp: %.2f°, Humidity: %.1f%%\n", temp, humidity);
    if (abs(temp - temp_cache_04) < 1){
      ambient_01.set(7, temp);                
      ambient_01.set(8, humidity);
      //Serial.println(" - ambient_01 set 2");               
    }
    temp_cache_04 = temp;               
  }  
}

class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {

    uint8_t* findServiceData(uint8_t* data, size_t length, uint8_t* foundBlockLength) {
      uint8_t* rightBorder = data + length;
      while (data < rightBorder) {
        uint8_t blockLength = *data + 1;
        //Serial.printf("blockLength: 0x%02x\n",blockLength);
        if (blockLength < 5) {
          data += blockLength;
          continue;
        }
        uint8_t blockType = *(data + 1);
        uint16_t serviceType = *(uint16_t*)(data + 2);
        //Serial.printf("blockType: 0x%02x, 0x%04x\n", blockType, serviceType);
        if (blockType == 0x16) { // https://www.bluetooth.com/specifications/assigned-numbers/generic-access-profile/
          // Serial.printf("blockType: 0x%02x, 0x%04x\n", blockType, serviceType);
          /* 16-bit UUID for Members 0xFE95 Xiaomi Inc. https://btprodspecificationrefs.blob.core.windows.net/assigned-values/16-bit%20UUID%20Numbers%20Document.pdf */
          if (serviceType == 0xfe95 || serviceType == 0x181a) { // mi or custom service
            //Serial.printf("blockLength: 0x%02x\n",blockLength);
            //Serial.printf("blockType: 0x%02x, 0x%04x\n", blockType, serviceType);
            *foundBlockLength = blockLength;
            return data;
          }
        }
        data += blockLength;
      }
      return nullptr;
    }

    void onResult(BLEAdvertisedDevice advertisedDevice) {
      uint8_t* payload = advertisedDevice.getPayload();
      size_t payloadLength = advertisedDevice.getPayloadLength();
      uint8_t serviceDataLength = 0;
      uint8_t* serviceData = findServiceData(payload, payloadLength, &serviceDataLength);
      if (serviceData == nullptr || serviceDataLength < 15)
        return;
      uint16_t serviceType = *(uint16_t*)(serviceData + 2);
      //Serial.printf("Found service '%04x' data len: %d, ", serviceType, serviceDataLength);
      //printBuffer(serviceData, serviceDataLength);
      if (serviceType == 0xfe95) {
        if (serviceData[5] & 0x10) {
          mac[5] = serviceData[9];
          mac[4] = serviceData[10];
          mac[3] = serviceData[11];
          mac[2] = serviceData[12];
          mac[1] = serviceData[13];
          mac[0] = serviceData[14];
          Serial.printf("MAC: "); printBuffer(mac, 6);
        }
        if ((serviceData[5] & 0x08) == 0) { // not encrypted
          serviceDataLength -= 15;
          payload = &serviceData[15];
          while (serviceDataLength > 3) {
            parse_value(payload, serviceDataLength);
            serviceDataLength -= payload[2] + 3;
            payload += payload[2] + 3;
          }
          Serial.printf("count: %d\n", serviceData[8]);
        } else {
          if (serviceDataLength > 19) { // aes-ccm  bindkey
            // https://github.com/ahpohl/xiaomi_lywsd03mmc
            // https://github.com/Magalex2x14/LYWSD03MMC-info
            Serial.printf("Crypted data[%d]! ", serviceDataLength - 15);
          }
          Serial.printf("count: %d\n", serviceData[8]);
        }
      } else { // serviceType == 0x181a
        if(serviceDataLength > 18) { // custom format
          mac[5] = serviceData[4];
          mac[4] = serviceData[5];
          mac[3] = serviceData[6];
          mac[2] = serviceData[7];
          mac[1] = serviceData[8];
          mac[0] = serviceData[9];
          //Serial.printf("MAC: "); 
          //printBuffer(mac, 6);
          temp = *(int16_t*)(serviceData + 10) / 100.0;
          humidity = *(uint16_t*)(serviceData + 12) / 100.0;
          uint16_t vbat = *(uint16_t*)(serviceData + 14);
          //Serial.printf("Temp: %.2f°, Humidity: %.2f%%, Vbatt: %d, Battery: %d%%, flg: 0x%02x, cout: %d\n", temp, humidity, vbat, serviceData[16], serviceData[18], serviceData[17]);
        } else if(serviceDataLength == 17) { // format atc1441
          Serial.printf("MAC: ");printBuffer(serviceData + 4, 6);
          int16_t x = (serviceData[10]<<8) | serviceData[11];
          temp = x / 10.0;
          uint16_t vbat = x = (serviceData[14]<<8) | serviceData[15];
          Serial.printf("Temp: %.1f°, Humidity: %d%%, Vbatt: %d, Battery: %d%%, cout: %d\n", temp, serviceData[12], vbat, serviceData[13], serviceData[16]);
        }
      }
      ambient_01_set();
    }
};


void setup() {
  Serial.begin(115200);
  ambient_01.begin(channelId_01, writeKey_01, &client);
  Serial.println("Scanning...");
  BLEDevice::init("");
  pBLEScan = BLEDevice::getScan();
  pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks(), true);
  pBLEScan->setInterval(625); // default 100
  pBLEScan->setWindow(625);  // default 100, less or equal setInterval value
  pBLEScan->setActiveScan(true);
}

void loop() {
  BLEScanResults foundDevices = pBLEScan->start(scanTime, false);
  pBLEScan->stop();
  pBLEScan->clearResults();
  
  WiFi.begin(ssid, password);
  while (WiFi.status() != WL_CONNECTED) {
      delay(1000);
  }
  ambient_01.send();
  Serial.println(" - ambient send");
  WiFi.disconnect(true);
}

Ambientの設定


1. Ambeintにログインして、設定するチャネル名を選択する。
0011

2. 「+」マークをクリック。グラフが表示される。
00123. 「チャネル/データ設定」を選択する。
0013

4. 赤枠の部分を設定して「設定する」を選択する。
0014

5. 温湿度計とESP32がBLEで接続されており、さらにESP32がWifiに接続してAmbientにデータを送信していれば、約2分後からグラフ表示が始まる。
0015

6. ESP32モジュールはBLEとWifiの電波が届く位置を探して、コンセントに繋げたままにする。(基板むき出しが怖いので養生テープで保護した)
ESP32


memo

LYWSD03MMC
https://yasurok2.wordpress.com/2020/10/18/custom-firmware-for-lywsd03mmc/
https://maky-ba.hatenablog.com/entry/2021/06/18/215016
https://twitter.com/tomozh/status/1441657460893241345
https://www.youtube.com/watch?v=NXKzFG61lNs

ESP32
http://marchan.e5.valueserver.jp/cabin/comp/jbox/arc212/doc21201.html
https://forum.arduino.cc/t/solved-reading-advertising-data-via-arduinoble-hack-for-atc_mithermometer/677540
https://forum.arduino.cc/t/esp32-xiaomi-lywsd03mmc/685428
https://decode.red/net/archives/746
https://www.denshi.club/cookbook/wireless/ble/ble-13-esp321.html
https://github.com/karolkalinski/esp32-snippets/blob/master/Mijia-LYWSD03MMC-Client/Mijia-LYWSD03MMC-Client.ino
https://bitbucket.org/dstacer/workspace/snippets/jBoazB/esp32-arduino-ide-ble-scanner-for-xiaomi

Ambient
https://ambidata.io/samples/m5stack/ble_gw/

CSS
https://web.hazu.jp/css-describe-code/
https://webtools.dounokouno.com/htmlescape/index.html