javascript

Управление bluetooth из js или как я реверсинжинирил умный чайник

  • четверг, 6 марта 2025 г. в 00:00:10
https://habr.com/ru/articles/887976/

Иногда требуется изготавливать оборудование подключаемое по беспроводной связи. Это часто упрощает конструкцию, уменьшает количество кабелей. Для беспроводного канала как правило применяю радиосвязь на приемопередатчиках типа nRF24L01 или Wi-Fi. Первый способ требует дополнительного устройства для передачи информации на компьютер. Второй проще для связи с ПК, но сложнее при написании программ.

Внимание привлек Bluetooth. Передатчик Bluetooth как правило встроен в ноутбуки или подключается к компьютеру через USB. Сложно найти телефон без поддержки этого стандарта. Помимо этого, разработка и программирование таких устройств просты, что делает Bluetooth привлекательным.

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

Все это подтолкнуло меня написать собственное приложение для управления чайником. Опыта в этой области у меня не было, как и в работе с Bluetooth. Поэтому начал изучать как чайник подключается к телефону с самых простых методов.

Определение основных параметров соединения

Ранее у меня был опыт работы с nfc. Тогда для низкоуровневого редактирования меток использовал специальные приложения. Подобное приложение удалось найти для Bluetooth. LightBlue предоставляет исчерпывающую информацию о деталях связи между устройством и телефоном. после подключения к чайнику в приложении отобразились некоторые данные.

Информация о соединении в LightBlue
Информация о соединении в LightBlue

На скриншоте много информации, но интерес представляет Generic Attribute. Через них передаются данные между телефоном и чайником. UUID "6e400001-b5a3-f393-e0a9-e50e24dcca9e" – это UART Service. Два идентификатора ниже это TX Characteristic и RX Characteristic. Оказывается чайник передает информацию по UART.

UART через Bluetooth работает через две характеристики. Для отправки данных на чайник надо записать значение в TX Characteristic, для получения – подписаться на обновление RX Characteristic.

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

При подписке чайник может прислать "55 1 6 0 0 0 0 0 62 1e 0 0 0 0 0 0 80 0 0 aa". Пока непонятно что тут записано, но контакт установлен.

Подслушивание о чем говорит чайник

Узнать какие команды отправлять чайнику можно двумя способами. Копаться в дизассемблированном коде приложения или подслушать что передается по Bluetooth. Второй вариант кажется чуть проще.

Телефон умеет записывать передачу данных по Bluetooth. Для активации этой опции необходимо попасть в "Параметры разработчика". По умолчанию этот пункт скрыт в меню, но его можно включить следуя инструкциям для конкретной версии android. Затем нужно включить "журнал HIC Bluetooth" и "Отчет об ошибке". Теперь на экране выключения появился пункт "отчет об ошибке" который можно скачать и поделиться.

Активация журнала Bluetooth
Активация журнала Bluetooth

Алгоритм извлечения команд из чайника выглядит следующим образом:

  1. Включить журнал Bluetooth

  2. Зайти в приложение, подключиться к чайнику.

  3. Давать чайнику команды и записывать время отправки

  4. Скачать журнал Bluetooth

Отчет об ошибке – это архив с кучей папок. Журнал Bluetooth находиться в папке "bugreport...\FS\data\log\bt". Файл с логами открывается в Wireshark.

Wireshark
Wireshark

Cопоставляя время, название устройства и тип пакетов определяется что именно передано с чайника и на чайник. В итоге вырисовывается такая картина:

  1. Приложение подписывается на получения данных с чайника

  2. Приложение отправляет чайнику 55 00 ff ec bd 51 70 5c 2e 5d 5d aa

  3. Чайник отвечает 55 0 ff 2 aa

После этого чайник инициализирован, не разрывает соединение и при изменении своего стояния отправляет его приложению. Приложение может послать чайнику команды.

  • 55 01 03 aa – Включить

  • 55 01 04 aa – Выключить

  • 55 01 06 aa – Обновить информацию

На что чайник отвечает сообщением типа "55 0 6 0 0 0 0 0 3a 1e 0 0 0 0 0 0 80 0 0" Каждый байт в этом сообщении несет определённую информацию. Например 8 байт "3a" – температура. Узнать за что отвечает каждый байт можно изменять настройки чайника и отслеживать изменения в ответе. Например 11 байт содержит "0" если чайник выключен, и "2" если – включен.

Приложение

Я считаю опьимальным языком для создания приложений html+css+js. Это позволяет быстро разрабатывать приложения не требующие установки и работающие на большинстве устройств. Bluetooth в чистом js нет, но его можно получить через интерфейс Navigator. Поддержка Bluetooth осуществляется далеко не всеми браузерами, но для локального использования это не так важно.

Покопавшись в примерах сделал простенькое приложение для управления чайником.

Для начала запрашиваем у браузера Bluetooth устройства. В опциях прошу показать все устройства и обязательно сообщаю UUID сервиса UART

let device = await navigator.bluetooth.requestDevice({
    acceptAllDevices: true,
    optionalServices: ['6e400001-b5a3-f393-e0a9-e50e24dcca9e']//Сервис UART
});

Получаю сервис

 let service = await server.getPrimaryService('6e400001-b5a3-f393-e0a9-e50e24dcca9e');

И настраиваю получение и отправку сообщений

let Notification = await TXcharacteristic.startNotifications();
Notification.addEventListener('characteristicvaluechanged', receive);
RXcharacteristic = await service.getCharacteristic('6e400002-b5a3-f393-e0a9-e50e24dcca9e');

Функция получения сообщений выглядит следующим образом.

function receive(event) {
    let value = event.target.value;
    value = value.buffer ? value : new DataView(value);

    let data = [];
    //Представление данных в виде массива
    for (let i = 0; i < value.byteLength; i++) {
        data[i] = value.getUint8(i);
    }

    let s = '';
    for (let i of data){
        s += i.toString(16) + ' '
    }
    log(`Получено: ${s}`);
  }

Функция отправки:

async function send(data) {
    try {
        await RXcharacteristic.writeValueWithoutResponse(
           new Uint8Array(data)
        );
        let s = '';
        for (let i of data){
            s += i.toString(16) + ' '
        }
        log(`Передано: ${s}`)
        } catch (error) {
            log(`Ошибка:   ${error}`)
        }
}//send

Добавив минимальный интерфейс было получено такое приложение.

Интерфейс приложения
Интерфейс приложения

Полный код приложения представлен ниже.

Полный код
<!DOCTYPE html>
<html lang="ru">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Web Blu</title>
    <style>
        body {
            display: grid;
            max-width: 500px;
            margin: auto;
        }

        button {
            min-height: 50px;
            font-size: 20px;
        }

        textarea {
            font-family: monospace;
            font-size: 15px;
        }
    </style>
    <script>
        'use strict'

        //Характеристика для передачи
        let RXcharacteristic;


        //Вывод сообщения на страницу
        function log(l) {
            loglist.value += l + '\n';
            loglist.scrollTop = loglist.scrollHeight;
        }


        //Передать информацию
        async function send(data) {
            try {
                await RXcharacteristic.writeValueWithoutResponse(
                    new Uint8Array(data)
                );
                let s = '';
                for (let i of data){
                    s += i.toString(16) + ' '
                }
                    log(`Передано: ${s}`)
            } catch (error) {
                log(`Ошибка:   ${error}`)
            }
        }//send


        //Получение информации
        function receive(event) {
            let value = event.target.value;
            value = value.buffer ? value : new DataView(value);

            let data = [];
            //Представление данных в виде массива
            for (let i = 0; i < value.byteLength; i++) {
                data[i] = value.getUint8(i);
            }

            let s = '';
                for (let i of data){
                    s += i.toString(16) + ' '
                }
            log(`Получено: ${s}`);

            //если получено 20 байт -- извлечь температуру
            if (data.length == 20)
                temp.innerHTML = `Температура: ${data[8]}°C`

        }//receive


        //подключение
        async function connect() {
            try {
                log('Запрос Bluetooth Device...');
                let device = await navigator.bluetooth.requestDevice({
                    acceptAllDevices: true,
                    optionalServices: ['6e400001-b5a3-f393-e0a9-e50e24dcca9e']//Сервис UART
                });

                log('Подключение GATT Server...');
                let server = await device.gatt.connect();

                log('Получение Nordic UART Service...');
                let service = await server.getPrimaryService('6e400001-b5a3-f393-e0a9-e50e24dcca9e');

                log('Получение TX Characteristic...');
                let TXcharacteristic = await service.getCharacteristic('6e400003-b5a3-f393-e0a9-e50e24dcca9e');

                log('Подписка...');
                let Notification = await TXcharacteristic.startNotifications();

                log('Подключение обработчика...');
                Notification.addEventListener('characteristicvaluechanged', receive);

                log('Получение RX Characteristic...');
                RXcharacteristic = await service.getCharacteristic('6e400002-b5a3-f393-e0a9-e50e24dcca9e');

                log('Инициализация...');
                send([0x55, 0x00, 0xff, 0xec, 0xbd, 0x51, 0x70, 0x5c, 0x2e, 0x5d, 0x5d, 0xaa]);
            } catch (error) {
                log(`Ошибка:   ${error}`);
            }
        }//connect
    </script>
</head>

<body>
    <button onclick="connect()">Подключиться</button>
    <button
        onclick="send([0x55, 0x00, 0xff, 0xec, 0xbd, 0x51, 0x70, 0x5c, 0x2e, 0x5d, 0x5d, 0xaa])">Инициализация</button>
    <button onclick="send([0x55, 0x01, 0x03, 0xaa])">Включить</button>
    <button onclick="send([0x55, 0x01, 0x04, 0xaa])">Выключить</button>
    <button onclick="send([0x55, 0x01, 0x06, 0xaa])">Обновить информацию</button>
    <button onclick="loglist.value = ``">Очистить лог</button>
    <textarea id="loglist" rows="15"></textarea>
    <label id="temp"></label>
</body>

</html>

Выводы

В результате получилось создать приложение, позволяющие управлять базовыми функциями чайника без официального приложения. Функционал приложения можно расширить, если проанализировать остальные команды. Но на данный момент этого не требуется.