Стенд с интерактивной световой инсталляцией, тренажёрный зал с датчиками пульса, смарт-полка на складе: во всех этих сценах посетителю достаточно открыть страницу в браузере, нажать одну кнопку и увидеть живые показания физического устройства. Ещё недавно для этого требовалось нативное приложение с установкой и обновлениями, отдельной версией под каждую платформу. Web Bluetooth убирает посредника: страница сама находит устройства поблизости, подключается к ним по протоколу Bluetooth Low Energy, читает характеристики, подписывается на уведомления и управляет внешней электроникой. Разберём иерархию GATT, код подключения, разбор байтов, восстановление связи и границы, которые безопасность ставит на этом пути.

Протокол BLE и иерархия GATT внутри одного подключения

Web Bluetooth рассчитан только на Bluetooth Low Energy, экономичный вариант классического протокола, спроектированный под устройства, живущие годами от одной батарейки. Классические профили со звуком и последовательными каналами в браузер не попали, зато GATT, Generic Attribute Profile, покрывает весь мир датчиков и актуаторов. После подключения устройство открывает GATT-сервер, сервер содержит службы, службы содержат характеристики, у характеристик бывают дескрипторы с метаданными. Стандартные сущности получают короткие 16-битные идентификаторы из официального реестра консорциума Bluetooth: служба батареи 0x180F с характеристикой уровня заряда 0x2A19, служба пульса 0x180D с измерением 0x2A37. В API популярные идентификаторы получили человекочитаемые имена: battery_service, battery_level, heart_rate. Вендорские расширения живут с полными 128-битными UUID и перечисляются явно, без сокращений.

Поиск устройств начинается на этаж ниже, в GAP, Generic Access Profile. Неподключённое устройство раз в секунду отправляет advertising-пакеты, короткие радиопосылки с именем и списком служб, а браузер показывает пользователю список того, что отозвалось поблизости. На связь устройству и странице выделяются короткие окна обмена, между которыми радиомодуль спит, поэтому батарейка датчика живёт месяцами. Понимание этой этажности объясняет странности API: нельзя подключиться к устройству, которое не вещает о себе, и нельзя потребовать службы, о которых устройство не объявило.

Короткие идентификаторы не живут сами по себе: консорциум резервирует для них общий базовый префикс, и служба батареи 0x180F разворачивается в полный адрес 0000180F-0000-1000-8000-00805F9B34FB. Устройству всё равно, в какой записи с ним говорит страница, но разработчику человекочитаемые имена экономят часы: их можно прямо подставлять в фильтры без конвертации и опечаток в шестнадцатеричных кодах.

Первый контакт от requestDevice до подключения сервера

Точка входа одна, navigator.bluetooth. Метод requestDevice показывает системное окно выбора устройства, но только после жеста пользователя, клика или тапа, вызов при загрузке страницы будет отвергнут. Фильтры сообщают браузеру, кого показывать в списке, а optionalServices перечисляет службы для отложенного доступа. Полный цикл от выбора до чтения укладывается в несколько строк:

const device = await navigator.bluetooth.requestDevice({
  filters: [{ services: ["battery_service"] }],
  optionalServices: ["heart_rate", "device_information"]
});

const server = await device.gatt.connect();
const battery = await server.getPrimaryService("battery_service");
const levelChar = await battery.getCharacteristic("battery_level");
const value = await levelChar.readValue();
const percent = value.getUint8(0);

Каждый шаг асинхронный, поэтому цепочка читается сверху вниз как сценарий: выбрать устройство, подключиться к серверу, взять службу, взять характеристику, прочитать значение. В фильтрах указывается то, что обязательно должно присутствовать у устройства, иначе его просто не покажут в списке. В optionalServices попадает то, к чему страница обратится позже: без этой строки доступ к дополнительной службе останется закрытым даже после успешного подключения. Объект device несёт имя, которое пользователь видел в окне выбора, и идентификатор, по которому устройство можно найти повторно во время текущей сессии.

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

Чтение данных на примере уровня заряда батареи

Характеристика возвращает не число, а массив байтов в обёртке DataView, и это главный момент, в котором поверхностное чтение документации обманывает разработчика. Уровень заряда занимает один байт в смещении ноль, поэтому достаточно getUint8(0), но у составных характеристик поля идут подряд, и смещения нужно знать заранее. Измерение пульса начинается с байта флагов, за ним следует само значение, а за ним при определённых флагах идут поля расхода энергии и числа RR-интервалов. Официальный реестр характеристик описывает структуру каждой стандартной записи, без него парсинг превращается в гадание по случайно сходящимся числам.

const value = await levelChar.readValue();
const percent = value.getUint8(0);

if (percent < 20) {
  showLowBatteryWarning(percent);
}

Проверка прав доступа встроена в сами методы: чтение характеристики, которая не поддерживает операцию, завершится ошибкой, а не тихим нулём. Свойство properties у характеристики заранее сообщает, что она умеет: read, write, notify, indicate. Это позволяет собрать интерфейс под реальную начинку устройства ещё до первого подключения, рендеря кнопки по фактическому набору операций.

Уведомления превращают датчик в живой интерфейс

Разовое чтение годится для настроек, но живой интерфейс строится на подписке. Характеристика с поддержкой notify сама присылает изменения, и страница перестаёт опрашивать устройство вслепую:

await levelChar.startNotifications();

levelChar.addEventListener("characteristicvaluechanged", (event) => {
  const percent = event.target.value.getUint8(0);
  renderBattery(percent);
});

Уведомления о заряде приходят редко, у пульса частота иная, и здесь всплывает байтовая структура. Разбор измерения пульса показывает, зачем нужны флаги:

function parseHeartRate(view) {
  const flags = view.getUint8(0);
  if (flags & 0x01) {
    return view.getUint16(1, true);
  }
  return view.getUint8(1);
}

Младший бит флагов задаёт ширину поля значения: ноль означает один байт, единица означает два байта в порядке little-endian, что и отражает второй аргумент getUint16. Правильный разбор отделяет рабочую интеграцию от графика с мусорными выбросами. Подписку принято снимать, когда элемент уходит из поля зрения, методом stopNotifications, иначе фоновая вкладка продолжит получать радиообновления и расходовать энергию обоих участников связи.

Рядом с notify в спецификации живёт indicate, подтверждаемое уведомление. Разница в одном ответном пакете: notify уходит без квитанции и потому быстрее, indicate требует от устройства подтверждения приёма и потому надёжнее для событий, потерю которых нельзя простить, например для кнопки аварийной остановки оборудования. Стандартный пульсометр обходится notify, а в вендорских 128-битных профилях выбор режима обычно зафиксирован в документации на железо.

Запись значений и управление внешней электроникой

Обратное направление, запись, превращает страницу в пульт. Характеристика с правом записи принимает Uint8Array:

const on = new Uint8Array([1]);
await ledCharacteristic.writeValueWithResponse(on);

Метод writeValueWithResponse ждёт подтверждения от устройства и гарантирует доставку, writeValueWithoutResponse отправляет команды потоком без подтверждений и подходит для плавных эффектов, где потеря одного пакета незаметна. Так страница включает подсветку, запускает мотор, меняет цвет лампы или переключает режим работы датчика. Для текстовых протоколов байты готовит TextEncoder:

const encoder = new TextEncoder();
const payload = encoder.encode("LED:ON\n");
await ledCharacteristic.writeValueWithResponse(payload);

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

Разрыв связи и восстановление сессии после отключения

Физическое соединение рвётся от стены между залами, от севшей батарейки, от сна самого устройства. Событие gattserverdisconnected сообщает об этом странице, и грамотная инсталляция не падает, а пережидает и воссоединяется:

device.addEventListener("gattserverdisconnected", reconnect);

async function reconnect() {
  renderStatus("нет связи");
  try {
    const server = await device.gatt.connect();
    await subscribeToAll(server);
    renderStatus("связь восстановлена");
  } catch (err) {
    renderStatus("устройство вне зоны доступа");
  }
}

Пауза между попытками стоит наращивать: первая повторная попытка через секунду, вторая через три, дальше экспоненциально, иначе страница устроит бесконечный штурм радиомодуля и посадит аккумулятор устройства быстрее самой инсталляции. Подписки после переподключения оформляются заново, сервер присылает новые экземпляры характеристик, и ссылки из прошлой сессии мертвы. Приложение в телефоне переживает сон экрана, веб-страница живёт, пока открыта вкладка, и это отличие стоит закладывать в сценарий стенда заранее.

Перед первой попыткой полезно спросить у API, есть ли радиомодуль вообще: метод getAvailability возвращает промис с логическим значением, а событие availabilitychanged сообщает об изменениях на лету, например когда пользователь выдернул адаптер или включил авиарежим. Инсталляция, которая слушает это событие, показывает честное сообщение о выключенном радио вместо загадочной тишины после безуспешных попыток подключения.

Ограничения безопасности и поддержка в браузерах

Безопасность выстроена вокруг одного принципа: физический мир вокруг человека, и доступ к нему страница получает только с явного согласия. Ограничения складываются в четыре барьера:

  1. страница работает только в защищённом контексте, по шифрованному протоколу или на localhost;
  2. окно выбора устройства открывается только после жеста пользователя в моменте;
  3. доступ выдаётся на конкретные службы из фильтров и optionalServices, остальное закрыто;
  4. вложенные iframe получают API только при явном разрешении в заголовке Permissions-Policy.

Для повторных визитов у API есть метод getDevices, он возвращает устройства, на которые пользователь уже давал согласие, без нового окна выбора. Состояние разрешения можно узнать через Permissions API. Поддержка сегодня сосредоточена в браузерах на движке Chromium: API появился в Chrome 56 в начале 2017 года и работает на настольных системах и Android. Firefox и Safari реализации не имеют, поэтому страница обязана уметь деградировать:

if ("bluetooth" in navigator) {
  enableLiveSensorMode();
} else {
  enableDemoModeWithSampledData();
}

Проверка занимает одну строку, а решает судьбу инсталляции на конкретном стенде. Демо-режим с заранее записанными данными спасает выставочный сценарий, когда посетитель пришёл с браузером без поддержки, а ставить нативное приложение ради двух минут взаимодействия никто не станет.

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