Опять про BLE, температуру и датчики Xiaomi

    Не так давно, удалось мне обзавестись известными датчиками температуры и влажности от Xiaomi. Эти датчики заслуженно приобрели широкую известность, так как при своей достаточно низкой цене, достаточно удобны в использовании, а также умеют передавать свои показания по протоколу BLE в тот же Mi Home. К тому же весь Интернет завален вариантами подключения этих сенсоров к Home Assistant, MajorDoMo и другим системам.


    Но мне этого показалось мало и захотелось все сделать по-своему (не спрашивайте меня зачем и почему, просто захотелось). А именно, захотелось прочитать данные с датчиков, которые развешены по всему дому и как-нибудь интересно с ними поработать. Потому я покопался в своих электронных закромах и нашел там модуль ESP32.



    Быстрое гугление показало: ESP32 — это то, что мне нужно. Он умеет Bluetooth и WiFi, программируется из Arduino IDE и позволит мне получить показания с датчика и отправить их по WiFi куда нужно (хоть на домашний сервер, хоть в облако). К тому же, очень быстро нашелся простой и понятный туториал, который как раз решал мою задачу. Но как выяснилось, не все так просто...


    Первые проблемы


    Как часто бывает с примерами из Интернета, код не заработал. А ведь так хотелось… Очевидно, что нужно разбираться с этим дальше.


    Не смотря на то, что у меня в закромах лежат всякие ESP32, по основному роду деятельности я прикладной разработчик. Ковыряюсь с железками (как и многие, я полагаю) только в качестве хобби. Потому достаточно быстро пришло понимание того, что без закапывания в детали дальше продвинуться не получится. Потому пришлось изучить код, немного спецификацию BLE и понять как это устроено. По результатам разбирательств пришло некоторое понимание того, как оно работает, ну и сразу же захотелось этим с кем-нибудь поделиться.


    Как оно работает


    Обычно устройства BLE умеют работать в 2-х режимах. Назовем их широковещательный (discover mode) и подключенный (connection mode). В широковещательном режиме устройство может рассылать пакеты, позволяющие другим Bluetooth устройствам обнаружить его и установить соединение при необходимости. При дальнейшем установлении соединения устройства могут обмениваться данными и командами. Некоторые устройства упаковывают какие-то данные о себе прямо в широковещательные пакеты. Это некоторым образом упрощает взамодействие с устройством, а также в числе прочих средств позволяет экономить энергию.


    Сенсор Xiaomi умеет работать в двух режимах, и в Интернетах можно найти примеры работы как с широковещательными пакетами так и в режиме соединения. В найденном ранее руководстве используется вариант подслушивания широковещательных пакетов. Достаточно просто чтобы можно было быстро разобраться. Осталось только выяснить, что же не так.


    Так что все-таки сломалось?


    Код примера работает достаточно просто. При старте устройства инициализируется процесс сканирования устройств и устанавливается класс, функции которого будут вызываться при получении пакетов от устройств (advertising пакеты).


    void initBluetooth()
    {
        BLEDevice::init("");
        pBLEScan = BLEDevice::getScan(); //create new scan
        pBLEScan->setAdvertisedDeviceCallbacks(new MyAdvertisedDeviceCallbacks());
        pBLEScan->setActiveScan(true); //active scan uses more power, but get results faster
        pBLEScan->setInterval(0x50);
        pBLEScan->setWindow(0x30);
    }

    Пакеты от устройств обрабатываются в этой функции:


     void onResult(BLEAdvertisedDevice advertisedDevice)
        {
            if (advertisedDevice.haveName() && advertisedDevice.haveServiceData() && !advertisedDevice.getName().compare("MJ_HT_V1")) {
                std::string strServiceData = advertisedDevice.getServiceData();
                uint8_t cServiceData[100];
                char charServiceData[100];
    
                strServiceData.copy((char *)cServiceData, strServiceData.length(), 0);
    
                Serial.printf("\n\nAdvertised Device: %s\n", advertisedDevice.toString().c_str());
    
                for (int i=0;i<strServiceData.length();i++) {
                    sprintf(&charServiceData[i*2], "%02x", cServiceData[i]);
                }
    
                std::stringstream ss;
                ss << "fe95" << charServiceData;
    
                Serial.print("Payload:");
                Serial.println(ss.str().c_str());
    
                char eventLog[256];
                unsigned long value, value2;
                char charValue[5] = {0,};
                switch (cServiceData[11]) {
                    case 0x04:
                        sprintf(charValue, "%02X%02X", cServiceData[15], cServiceData[14]);
                        value = strtol(charValue, 0, 16);
                        if(METRIC)
                        {
                          current_temperature = (float)value/10;
                        }else
                        {
                          current_temperature = CelciusToFahrenheit((float)value/10);
                        }
                        displayTemperature();  
                        break;
                    case 0x06:
                        sprintf(charValue, "%02X%02X", cServiceData[15], cServiceData[14]);
                        value = strtol(charValue, 0, 16);  
                        current_humidity = (float)value/10;
                        displayHumidity();                      
                        Serial.printf("HUMIDITY_EVENT: %s, %d\n", charValue, value);
                        break;
                    case 0x0A:
                        sprintf(charValue, "%02X", cServiceData[14]);
                        value = strtol(charValue, 0, 16);                    
                        Serial.printf("BATTERY_EVENT: %s, %d\n", charValue, value);
                        break;
                    case 0x0D:
                        sprintf(charValue, "%02X%02X", cServiceData[15], cServiceData[14]);
                        value = strtol(charValue, 0, 16);      
                        if(METRIC)
                        {
                          current_temperature = (float)value/10;
                        }else
                        {
                          current_temperature = CelciusToFahrenheit((float)value/10);
                        }
                        displayTemperature();               
                        Serial.printf("TEMPERATURE_EVENT: %s, %d\n", charValue, value);                    
                        sprintf(charValue, "%02X%02X", cServiceData[17], cServiceData[16]);
                        value2 = strtol(charValue, 0, 16);
                        current_humidity = (float)value2/10;
                        displayHumidity();                                        
                        Serial.printf("HUMIDITY_EVENT: %s, %d\n", charValue, value2);
                        break;
                }
            }
        }

    Очевидно, проблема где-то здесь.


    Основное действие в этом коде происходит в конструкции switch, где проверяется значение 11го байта в service data массиве. Проблема только в том, что в моем случае массив данных был меньше 11 байт. Осталось выяснить почему.


    Каждый advertising пакет помимо информации о возможности соединения с устройством может содержать пакет данных (payload). Этот пакет содержит расширенные данные об устройстве, также данные о сервисах, которые поддерживает устройство. В одном пакете может быть информация о нескольких сервисах. Типичный payload моих устройств выглядит так (это отдельные байты в шестнадцатиричной системе счисления):


    020106121695fe5020aa01ab9f0231342d580a10014309094d4a5f48545f563105030f180a180916ffffc8b33f8a48db

    Информация здесь кодируется достаточно просто. Первый байт (в примере 0x02) задает размер блока в байтах. За ним следует байт, который указыает назначение блока (подробно о типах блоков здесь). Затем следуют данные в зависимости от типа блока. Ну и дальше все повторяется (опять появляется длина блока) пока не закончится пакет данных.


    Нас больше всего интересют блоки с типом 0x16, которые отвеают за service data, т.е. за данные, описывающие отдельные функции устройства. В нашем примере таких блоков 2:


    121695fe5020aa01ab9f0231342d580a100143
    0916ffffc8b33f8a48db

    Если присмотреться поближе, то можно заметить, что 11й байт в первом блоке очень похож, на тот, что ожидает наш switch (0x0A). А второй блок как раз похож на тот, слишком короткий блок, на который мы ссылались в начале. Похоже здесь и порылась собака. Похоже, что наш код ожидает видеть первый блок, а получает второй.


    Почему так вышло?


    Может у нас какие-то не такие устройства, а может у автора кода другие, но факт остается фактом, у нас оно так не работает. Самое время посмотреть в исходники библиотеки ESP32 для Arduino. Не будем вдаваться в подробности, но по этому коду видно, что getServiceData должен иметь параметр с индексом блока данных, который найден в пакете. Т.е. в библиотеке предусмотрена возможность того, что payload может содержать несколько блоков service data. Однако, не все так просто. Оказывается, что эта ветка изменений на момент написания этой заметки еще не опубликована (текущая версия релиза 1.0.4). И просто так скачав в Arduino IDE все необходимое для ESP32 через Boards Manager будет получена более старая версия библиотеки. И как раз в этой версии функция getServiceData() всегда возвращает последний блок service data. Это не очень приятно, но всегда можно использовать последнюю версию библиотеки. Главное, что мы смогли понять в чем была проблема.


    Финальный код


    С новой библиотекой решить проблему можно будет очень просто. Но не очень хочется создавать зависимость от новой версии библиотеки. Мы можем добавить простой код, который сделает то, что нужно нам нужно и так. Для этого нам нужен код, который в payload найдет нужный нам блок service data (в примере ниже функция findServiceData).


    class MyAdvertisedDeviceCallbacks: public BLEAdvertisedDeviceCallbacks {
    
        uint8_t* findServiceData(uint8_t* data, size_t length, uint8_t* foundBlockLength) {
            // пэйлоад у состоит из блоков [байт длины][тип блока][данные]
            // нам нужен блок с типом 0x16, следом за которым будет 0x95 0xfe
            // поэтому считываем длину, проверяем следующий байт и если что пропускаем
            // вернуть надо указатель на нужный блок и длину блока
            uint8_t* rightBorder = data + length;
            while (data < rightBorder) {
                uint8_t blockLength = *data;
                if (blockLength < 5) { // нам точно такие блоки не нужны
                    data += (blockLength+1);
                    continue;
                }
                uint8_t blockType = *(data+1);
                uint16_t serviceType = *(uint16_t*)(data + 2);
                if (blockType == 0x16 && serviceType == 0xfe95) { // мы нашли что искали
                    *foundBlockLength = blockLength-3; // вычитаем длину типа сервиса
                    return data+4; // пропускаем длину и тип сервиса
                }
                data += (blockLength+1);
            }   
            return nullptr;
        }
    
        void onResult(BLEAdvertisedDevice advertisedDevice) {
            if (!advertisedDevice.haveName() || advertisedDevice.getName().compare("MJ_HT_V1"))
                return; // нас интересуют только устройства, которые транслируют нужное нам имя
    
            uint8_t* payload = advertisedDevice.getPayload();
            size_t payloadLength = advertisedDevice.getPayloadLength();
            Serial.printf("\n\nAdvertised Device: %s\n", advertisedDevice.toString().c_str());
            printBuffer(payload, payloadLength);
            uint8_t serviceDataLength=0;
            uint8_t* serviceData = findServiceData(payload, payloadLength, &serviceDataLength);
    
            if (serviceData == nullptr) {
                return; // нам этот пакет больше не интересен
            }
    
            Serial.printf("Found service data len: %d\n", serviceDataLength);
            printBuffer(serviceData, serviceDataLength);
    
            // 11й байт в пакете означает тип события
            // 0x0D - температура и влажность
            // 0x0A - батарейка
            // 0x06 - влажность
            // 0x04 - температура
    
            switch (serviceData[11])
            {
                case 0x0D:
                {
                    float temp = *(uint16_t*)(serviceData + 11 + 3) / 10.0;
                    float humidity = *(uint16_t*)(serviceData + 11 + 5) / 10.0;
                    Serial.printf("Temp: %f Humidity: %f\n", temp, humidity);
                }
                break;
                case 0x04:
                {
                    float temp = *(uint16_t*)(serviceData + 11 + 3) / 10.0;
                    Serial.printf("Temp: %f\n", temp);
                }
                break;
                case 0x06:
                {
                    float humidity = *(uint16_t*)(serviceData + 11 + 3) / 10.0;
                    Serial.printf("Humidity: %f\n", humidity);
                }
                break;
                case 0x0A:
                {
                    int battery = *(serviceData + 11 + 3);
                    Serial.printf("Battery: %d\n", battery);
                }
                break;
            default:
                break;
            } 
        }
    };

    Вывод


    Вся проделанная работа в очередной раз показыает, что не всегда код из Интернета хорошо работает. Будь-то пример для ESP32 или кусок кода со StackOverflow, крайне желательно все же понимать как оно работает. Всегда могут появиться не самые стандартные случаи, которые заставят код развалиться. Хорошо, когда это происходит в хобби-проектах, но, очевидно, никому не хотелось бы наталкиваться на подобные случаи в боевом коде. Давайте будем осторожны с использованием чужого кода, ну или по крайней мере попытаемся в нем разбираться.


    Как-то длинновато получилось, но надеюсь, что кому-то это будет полезно. Со своей же стороны, надеюсь, что этому эксперименту будет продолжение, и данные температуры все же будут отправлены дальше.


    Полный код примера можно скачать здесь.


    UPD: Продолжение экспериментов тут

    AdBlock похитил этот баннер, но баннеры не зубы — отрастут

    Подробнее
    Реклама

    Комментарии 18

      +2
      Но мне этого показалось мало и захотелось все сделать по-своему

      А мне показалось мало только получать от него температуру, захотелось еще и отображать на нем температуру на улице. И поскольку все мои домашние датчики — zigbee, я по поступил кардинально: вырезал из него BLE процессор и вставил свой Zigbee. Вот что вышло:
      image
      Теперь стоит на полке — показывает температуру на улице, внутри квартиры, а уровень CO2 показывает, только если превышен.
        0
        Это очень круто! Далеко за пределами моего уровня на текущий момент.
          +1
          Не так сложно, как может показаться. Представьте, если бы вы разместили внутри этого датчика ESP32 и подключили бы её к драйверу LCD и датчику температуры/влажности. И залили бы в нее свою прошивку. Только ESP32 туда не влезет… Я использовал модуль сс2530.
            +1

            А не могли бы вы выложить информацю об этом проекте? Я бы очень хотел повторить

              +1
              Наверняка сложность была в работе с драйвером LCD. Вряд ли он там какой-то стандартный. Так что прсоединяюсь к соседнему комментарию. Детали бы были очень интересны.
                +3
                LCD драйвер там стоит BU9795AFV, даташит доступен. Датчик температуры SHT3x — для него есть ардуиновские библиотеки. Модуль cc2530 считывает показания с SHT3X и отправляет их мне на сервер по zigbee, а также получает от сервера значения температуры на улице и уровня СО2, ну и выводит на LCD. От cc2530 до LCD — 4е управляющих провода(припаивался прямо к микросхеме BU9795), до SHT3x — два провода(TP9-SCL,TP10-SDA). Питание cc2530 взял с платы TP15-3V.
                image
                Фотографии процесса не делал, и сейчас, к сожалению, тоже не могу сфотографировать, я в отъезде. А исходники завтра выложу на github.
                  0
                  Спасибо! По крайней мере теперь понятно с чего начать если что.
                    0
                    Вот ссылка на неоформленные исходники…
                    Кстати, там у меня тоже есть один проектик по BLE — управление термостатами Danfoss Eco 2, автоматизировал отопление в своей квартире.
                      0
                      Огромное спасибо!
                0

                Можно впихнуть, если взять не готовый модуль, а чип и сделать плату под него с печатной антенной. Больше возни будет с питанием / экономией энергии (батарейка всё-таки).

                  0
                  В моём случае с питанием не пришлось возится, встроенный step-up тянет BLE/Zigbee. Wifi думаю не потянет.
            0
            а никто не разбирался с коннектед режимом? Я сколько их ни снифферил там все время странные пакеты бегают
              0
              Я пробовал этот режим, однако понял, что лучге не надо (вот тут пример есть github.com/kenken64/flutter_iot/blob/master/firmware/Xiaomi_ESP32_Blynk/Xiaomi_ESP32_Blynk.ino). Во-первых, у меня почему-то сенсор отсоединялся после передачи 2-3 значений температуры. Во-вторых, в режиме соединения в любом случае будет расходоваться дополнительная энергия, что заставит чаще менять батарейки в сенсорах. В ситуации когда можно пассивно получать от сенсоров информацию, режим с соединениями мне показался избыточным.
                0
                Сенсор хочет какието ключи… я вот и пытаюсь понять что за ключи он хочет :( Понятно что адверт режим самый простой. Но хотелось бы понять логику их коннектед режима
                  0
                  Хотеть ключи в его случае это нормально. Это что-то типа авторизации.
                  Но по идее этот этап проходить научились, поскольку если использовать код из примера, на который я ссылался ранее, значения температуры и влажности сенсор отдает. А обрубать коннект он может специально, в целях экономии энергии. Но это не точно…
                    0
                    на родном приложении он не рвет коннект. Надо будет разобраться до конца
              0
              В esphome есть поддержка этих датчиков из коробки.
              esphome.io/components/sensor/xiaomi_lywsdcgq.html
              Не пробовали?
                0
                Видел, но не пробовал. Хотелось руками :)

              Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

              Самое читаемое