Прежде чем перейти к статье, хочу вам представить, экономическую онлайн игру Brave Knights, в которой вы можете играть и зарабатывать. Регистируйтесь, играйте и зарабатывайте!
Не так давно, удалось мне обзавестись известными датчиками температуры и влажности от 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, крайне желательно все же понимать как оно работает. Всегда могут появиться не самые стандартные случаи, которые заставят код развалиться. Хорошо, когда это происходит в хобби-проектах, но, очевидно, никому не хотелось бы наталкиваться на подобные случаи в боевом коде. Давайте будем осторожны с использованием чужого кода, ну или по крайней мере попытаемся в нем разбираться.
Как-то длинновато получилось, но надеюсь, что кому-то это будет полезно. Со своей же стороны, надеюсь, что этому эксперименту будет продолжение, и данные температуры все же будут отправлены дальше.
Полный код примера можно скачать здесь.