Встраиваемые системы и робототехника IoT-связь: Wi-Fi, BLE, LoRa, MQTT, безопасность устройств
0%

IoT-связь: Wi-Fi, BLE, LoRa, MQTT, безопасность устройств

IoT-связь: Wi-Fi, BLE, LoRa, MQTT, безопасность устройств

Главный сдвиг: связь — это расход энергии, а не вызов функции

На сервере отправка запроса — это write() в сокет: микросекунды процессорного времени, канал всегда есть, потерянный пакет переспросит TCP, а «отвалилась сеть» — редкая аварийная ветка. Всё это перестаёт быть правдой, как только вместо витой пары появляется антенна. Переформулируем задачу так, как её видит встраиваемый инженер:

Радиосвязь — это преобразование заряда батареи в вероятность того, что байт окажется на другом конце. Не гарантию — вероятность. И расход заряда определяется не объёмом данных, а тем, сколько времени приёмопередатчик был включён.

Каждое следствие этого определения ломает конкретную серверную привычку.

  • Передатчик — самый прожорливый узел в устройстве. Ядро Cortex-M4 на 80 МГц потребляет 10–20 мА, Wi-Fi-передатчик в пике — 250–350 мА, в двадцать раз больше, чем весь остальной вычислитель. Отсюда правило, противоположное серверному: вычислять дешевле, чем передавать. Сжать, усреднить, отфильтровать и отправить один байт вместо ста — выигрыш, даже если на сжатие уйдёт десять миллисекунд процессора. В бэкенде оптимизация ровно обратная: там сеть внутри дата-центра почти бесплатна, а CPU — дефицит.
  • Время в эфире важнее объёма данных. Отправить 40 байт по Wi-Fi — это 0,4 мс радиообмена и 2 секунды подключения, DHCP и TLS-рукопожатия до него; полезная работа занимает доли процента от счёта за энергию. Оптимизировать надо не payload, а последовательность действий до первого байта данных, и это редко приходит в голову человеку, привыкшему к постоянно открытым соединениям.
  • Потери — норма, а не авария. Пакет теряется не из-за перегрузки буфера, а потому что кто-то включил микроволновку, устройство повернули на 90 градусов или между ним и шлюзом проехал грузовик. Проектировать надо не «как избежать потерь», а «что делать с дырами в данных»: как нумеровать измерения, что накапливать в буфере, как склеивать историю на сервере. Идемпотентность здесь не роскошь, а базовое требование — та же логика, что в распределённых системах, только с батарейкой в качестве бюджета на ретраи.
  • Пропускная способность бывает микроскопической. LoRaWAN даёт 300 бит/с на дальнем краю: не килобит — бит. JSON с полем "temperature": 21.5 (25 байт) летит по такому каналу почти секунду и съедает дневную квоту эфирного времени; текстовые форматы, к которым приучил веб, здесь неприменимы.
  • Радио — это ещё и вычислительная нагрузка. TLS-рукопожатие с проверкой цепочки X.509 на Cortex-M0+ без криптоускорителя занимает секунды и десятки килобайт ОЗУ — сопоставимо со всей остальной прошивкой. Криптография становится статьёй бюджета памяти наравне со стеками задач из статьи про RTOS.

Ось выбора: дальность, скорость, энергия — выбирайте два

Универсального радио не существует по физическим причинам: чем шире полоса, тем больше шума ловит приёмник и тем выше нужна мощность для того же качества связи. Технологии расползаются по плоскости «скорость × дальность» — от LoRaWAN и Sigfox в углу «сотни бит в секунду на километры» через Zigbee, Thread и BLE в углу «десятки метров за микроамперы» до Wi-Fi и LTE Cat-1 в углу «мегабиты за сотни миллиампер». Место в этой плоскости определяет всё остальное, от топологии сети до срока службы батареи, а выбирать надо не «что лучше», а «чем я готов заплатить». Практический алгоритм — четыре вопроса именно в таком порядке:

  1. Откуда питание? Розетка — берите Wi-Fi и не думайте. Батарейка на годы — сразу вычёркиваете Wi-Fi и смотрите на BLE или LPWAN.
  2. Кто на другом конце и как далеко? Телефон в руках человека — BLE. Шлюз в том же здании — Wi-Fi, Zigbee, Thread. Поле, подвал, лес за три километра — LoRaWAN или NB-IoT.
  3. Сколько данных и как часто? Поток аудио или картинок — только Wi-Fi. Пакет на 20 байт раз в 15 минут — LPWAN. Пара килобайт при подходе человека — BLE.
  4. Кто владеет инфраструктурой? NB-IoT — это SIM-карта и договор с оператором. LoRaWAN можно поднять полностью своим (шлюз за 200 USD и сетевой сервер ChirpStack). Разница проявится не в прототипе, а на пятой тысяче устройств.

Типичная ошибка на этом шаге — выбрать радио по знакомству («мы умеем Wi-Fi») и потом три месяца героически выжимать миллиамперы там, где физика изначально не позволяла. Смена технологии на раннем этапе стоит недели, на позднем — переразводки платы и нового сертификационного цикла.

Радиолиния до всякого кода: бюджет связи

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

import math

def fspl_db(distance_km: float, freq_mhz: float) -> float:
    """Потери в свободном пространстве, ITU-R P.525. O(1) по времени и памяти.
    Чем выше частота, тем быстрее слабеет сигнал: 868 МГц бьёт дальше 2,4 ГГц."""
    return 20 * math.log10(distance_km) + 20 * math.log10(freq_mhz) + 32.44

def link_margin_db(tx_dbm, g_tx, g_rx, sens_dbm, distance_km, freq_mhz, extra_db=0.0):
    """Запас линии, дБ. Меньше 10 — связь капризная, 20 — рабочий проект."""
    return tx_dbm + g_tx + g_rx - fspl_db(distance_km, freq_mhz) - extra_db - sens_dbm

# LoRa SF12: 14 дБм, штыревые антенны, чувствительность -137 дБм, 10 км
print(round(link_margin_db(14, 2, 2, -137, 10.0, 868), 1))       # 43.8 дБ запаса
print(round(link_margin_db(14, 2, 2, -137, 10.0, 868, 35), 1))   # 8.8 дБ в застройке
# BLE: 0 дБм, антенна на плате, чувствительность -95 дБм, 30 м
print(round(link_margin_db(0, 0, 0, -95, 0.03, 2440), 1))        # 25.3 дБ — уверенно

Три вывода, которые надо унести с собой. Во-первых, каждые 6 дБ запаса — это удвоение дальности; поэтому замена куска провода на нормальную антенну часто даёт больше, чем любые программные ухищрения. Во-вторых, чувствительность приёмника покупается скоростью: LoRa на SF12 слышит сигнал на 40 дБ слабее, чем Wi-Fi, ровно потому, что размазывает один бит на десятки миллисекунд. В-третьих, бетонная стена стоит 10–20 дБ, а человеческое тело на 2,4 ГГц — около 10 дБ: браслет на запястье с внутренней стороны руки теряет связь, с внешней — держит. Такие вещи не отлаживаются кодом; они находятся замерами RSSI в реальном помещении.

Wi-Fi: быстро, привычно, дорого

Wi-Fi на микроконтроллере — почти обычный сетевой стек: IP-адрес, DNS, сокеты, TLS. Эта привычность и делает его ловушкой: код выглядит как серверный, а ведёт себя как радио.

Во что обходится подключение

Полный цикл выхода в сеть — это активный скан 13 каналов, ассоциация с точкой доступа, обмен ключами WPA2/WPA3, DHCP (четыре пакета плюс таймауты), DNS-запрос и TLS-рукопожатие. На ESP32 это укладывается в 0,7–3 секунды при токе 100–350 мА. Никакие данные в это время не передаются.

Профиль тока цикла отправки: наивный вариант против оптимизированного

Разница между двумя графиками — не микрооптимизация, а пятикратная разница в сроке службы. Список приёмов, которые её дают:

  • Сохранять BSSID и канал между пробуждениями (на ESP32 — в RTC-памяти, которая переживает deep sleep). Точка доступа известна, полный скан не нужен: подключение сокращается с ~900 до ~200 мс.
  • Статический IP вместо DHCP. Минус четыре пакета и, что важнее, минус ожидание ответа с таймаутом.
  • TLS session resumption (session ticket в TLS 1.2, PSK-режим в TLS 1.3). Возобновление обходится в один RTT и без асимметричной криптографии — экономия и радио, и процессора. Детали механизма — в статье про TLS.
  • Не поднимать сеть ради каждого измерения. Накопить 20 замеров в ОЗУ и отправить пачкой — та же энергия на подключение, но в двадцать раз реже. А если устройство от розетки, соединение вообще не разрывается: keepalive стоит копейки, а латентность команд падает с секунд до миллисекунд.

Сон при постоянном соединении: DTIM

Если устройство должно быть онлайн, но питается от аккумулятора, работает механизм DTIM: точка доступа шлёт маяк (beacon, обычно каждые 102,4 мс), а в каждом N-м маяке — карту буферизованного трафика для спящих клиентов; клиент просыпается раз в DTIM-интервалов, слушает маяк пару миллисекунд и снова засыпает. Практика: DTIM = 3 даёт средний ток порядка 1–2 мА на ESP32 при сохранении отклика в пределах 300 мс. DTIM = 10 уронит ток до долей миллиампера, но отклик уедет к секунде. Ключевая деталь: DTIM задаёт точка доступа, а не устройство. На чужом Wi-Fi (у клиента, в офисе) вы не управляете этим параметром, и энергетический расчёт, сделанный на своём роутере, рассыпается. Это и есть главный аргумент против Wi-Fi для батарейных устройств: часть бюджета энергии находится вне вашего контроля.

BLE: разговор с телефоном

Bluetooth Low Energy решает принципиально другую задачу: короткая дистанция, крошечные порции данных, годы работы от таблетки CR2032 — и, главное, на другом конце обычно смартфон, а не сервер (от классического Bluetooth здесь не осталось ничего, кроме названия). Модель BLE стоит держать в голове целиком, потому что она непохожа ни на сокеты, ни на HTTP:

  • GAP — кто кого находит. Периферия (peripheral) шлёт рекламные пакеты, центральное устройство (central) их слушает и инициирует соединение.
  • GATT — что за данные. Устройство публикует дерево: сервисы, внутри них характеристики, у каждой UUID, значение и права доступа. Это, по сути, маленькая база данных с фиксированной схемой, доступная по чтению, записи и подписке.
  • Notify/Indicate — как приходят изменения. Подписка на характеристику превращает её в поток событий: notify без подтверждения, indicate с подтверждением.

Три параметра соединения определяют всю энергетику BLE, и путать их — самая частая ошибка новичков:

Параметр Что задаёт Влияние на ток Влияние на задержку
Connection Interval (7,5 мс … 4 с) как часто открывается окно связи линейно: реже окна — меньше ток задержка команды сверху ограничена интервалом
Slave Latency (0 … 499) сколько окон периферия имеет право пропустить, если нечего сказать сильное падение среднего тока на приём команды не влияет: центральный ждёт нужное окно
Supervision Timeout через сколько считать связь потерянной нулевое определяет, как быстро заметите обрыв

Комбинация «интервал 30 мс + latency 8» даёт быструю реакцию на команды при среднем токе как у интервала 240 мс: периферия молчит, когда ей нечего передать, но всегда готова услышать — способ получить сразу и низкую задержку, и низкое потребление, аналога которому в мире TCP нет. Ещё две практические ловушки. Первая: MTU по умолчанию — 23 байта, из них 3 уходят на заголовок ATT, остаётся 20. Пока не сделан MTU exchange, любое сообщение длиннее 20 байт будет резаться на несколько транзакций, каждая в своём окне соединения — то есть пересылка килобайта при интервале 200 мс займёт десять секунд. Вторая: шифрование BLE защищает только канал. Телефон-посредник видит данные открытыми, а значит, для реальной приватности нужен свой слой поверх GATT — например, полезная нагрузка, зашифрованная ключом, который знают только устройство и ваш сервер.

Прототип центрального устройства пишется на Python: bleak работает поверх BlueZ, CoreBluetooth и WinRT, и десяти строк (BleakScanner.find_device_by_filterBleakClientstart_notify) хватает, чтобы проверить, что периферия отдаёт правильные байты, задолго до мобильного приложения. Первое, что стоит там напечатать, — client.mtu_size: значение меньше 100 означает, что обмен MTU не состоялся. И ещё: предпочитайте стандартные характеристики Bluetooth SIG (например, Temperature 0x2A6E — знаковое 16-битное в сотых долях градуса) своим UUID — их поймёт любое стороннее приложение.

LoRa и LoRaWAN: километры вместо мегабит

LoRa — это модуляция (chirp spread spectrum), LoRaWAN — сетевой протокол поверх неё; различать обязательно, потому что LoRa можно использовать как «длинный радиомодем» вообще без сети. Физическая идея LoRa: вместо того чтобы передавать бит коротким импульсом, его передают линейно нарастающим по частоте «чирпом» длиной в миллисекунды. Приёмник знает форму чирпа и вытаскивает его из шума корреляцией — сигнал может быть на 20 дБ ниже уровня шума и всё равно быть принят. Плата за это — время: коэффициент расширения SF (spreading factor) от 7 до 12 удваивает длительность символа на каждый шаг.

import math

def lora_time_on_air(sf: int, payload_bytes: int, bw_hz: int = 125_000, coding_rate: int = 1,
                     crc: int = 1, implicit_header: int = 0, preamble: int = 8) -> float:
    """Время в эфире одного пакета LoRa, секунды. O(1). Формула из даташита
    Semtech SX1276, раздел 4.1.1.7 — самая важная функция в проекте на LoRa:
    она определяет и энергию, и разрешённую частоту отправки, и размер данных."""
    t_sym = (2 ** sf) / bw_hz
    # При символе длиннее 16 мс обязательна оптимизация низкой скорости
    low_data_rate = 1 if t_sym > 0.016 else 0
    t_preamble = (preamble + 4.25) * t_sym
    numerator = 8 * payload_bytes - 4 * sf + 28 + 16 * crc - 20 * implicit_header
    denominator = 4 * (sf - 2 * low_data_rate)
    n_payload = 8 + max(math.ceil(numerator / denominator) * (coding_rate + 4), 0)
    return t_preamble + n_payload * t_sym

for sf in range(7, 13):
    toa = lora_time_on_air(sf, payload_bytes=20)
    # Duty cycle 1 % в EU868: после передачи молчим в 99 раз дольше
    print(f"SF{sf}: {toa * 1000:6.1f} мс в эфире, пауза {toa * 99:6.1f} с")

Результаты для полезной нагрузки в 20 байт — таблица, которую стоит держать перед глазами при проектировании:

SF Время в эфире Скорость Пауза по duty cycle 1 % Чувствительность Практическая дальность
SF7 57 мс 5470 бит/с 5,6 с −123 дБм 2 км
SF8 103 мс 3125 бит/с 10,2 с −126 дБм 3 км
SF9 185 мс 1758 бит/с 18,3 с −129 дБм 4 км
SF10 371 мс 977 бит/с 36,7 с −132 дБм 6 км
SF11 741 мс 537 бит/с 73,4 с −134,5 дБм 9 км
SF12 1319 мс 293 бит/с 130,6 с −137 дБм 12+ км

Отсюда два жёстких ограничения, которых нет ни в одной другой технологии. Первое: duty cycle — это закон, а не настройка. В диапазоне EU868 на большинстве поддиапазонов разрешён 1 % эфирного времени в час. Один пакет на SF12 «съедает» два с лишним минуты тишины. Дневная квота на устройство — порядка 30 секунд эфира. Реальное следствие: при SF12 вы физически не можете отправлять чаще, чем раз в 2–3 минуты, и никакой ретрай-логикой это не обходится. Стек обязан сам блокировать передачу — в LoRaWAN-библиотеках это состояние DUTY_CYCLE_RESTRICTED, и обрабатывать его надо как штатное, а не как ошибку. Второе: полезная нагрузка измеряется десятками байт. В EU868 максимум — 51 байт на SF12 и 222 байта на SF7, и это уже после вычета 13 байт заголовка LoRaWAN. JSON тут не существует как вариант. Данные пакуются битовыми полями:

import struct

def pack_measurement(temp_c: float, humidity_pct: float, battery_mv: int, flags: int) -> bytes:
    """Упаковка телеметрии в 7 байт вместо 96 байт JSON. Каждое поле сжато до
    реально нужного разрешения — главный приём проектирования payload для
    LPWAN: хранится точность датчика, а не точность float."""
    t = int(round(temp_c * 10))            # -40.0..85.0 °C с шагом 0,1 → int16
    h = int(round(humidity_pct * 2))       # 0..100 % с шагом 0,5 → uint8
    v = max(0, min(65535, battery_mv))     # мВ → uint16
    return struct.pack("<hBHBB", t, h, v, flags, 0x01)   # последний байт — версия схемы

print(len(pack_measurement(21.5, 43.5, 3287, 0b0000_0010)))   # 7 байт вместо 96

Байт версии схемы в конце — не педантизм: устройства в поле живут годами, прошивки разных поколений сосуществуют, и декодер на сервере должен понимать, какой формат перед ним. Это тот же принцип эволюции схемы, что в Protobuf, только реализованный руками — на 7 байт библиотеки не напасёшься. Сам жизненный цикл устройства класса A — конечный автомат, который надо понимать буквально, потому что окна приёма открываются по таймеру с точностью в миллисекунды:

Ключевая деталь этого автомата: downlink возможен только сразу после uplink. Устройство класса A нельзя «разбудить» с сервера — команда будет ждать в очереди сетевого сервера до следующей передачи датчика. Класс C держит приёмник открытым постоянно (десятки мА, только от розетки), класс B открывает окна по расписанию от маяков шлюза (сложно, поддерживается редко). Для батарейного устройства это означает: управление всегда асинхронно и всегда с задержкой в один период опроса. Архитектура приложения должна закладываться на это с самого начала — например, OTA-обновление по LoRaWAN технически возможно, но занимает часы и обычно того не стоит.

Три технологии рядом

Стек протоколов IoT: Wi-Fi, BLE и LoRaWAN по слоям

Критерий Wi-Fi BLE LoRaWAN NB-IoT
Дальность 30–100 м 10–100 м 2–15 км покрытие оператора
Скорость 1–20 Мбит/с 0,1–0,7 Мбит/с 0,3–5 кбит/с 20–120 кбит/с
Пиковый ток 150–350 мА 5–15 мА 30–120 мА 100–250 мА
Годы от батарейки нет (месяцы) да (2–5 лет) да (5–10 лет) 1–3 года
Инфраструктура обычный роутер смартфон шлюз ваш или чужой SIM и оператор
IP-связность да нет нет да
Типичный сценарий камера, дисплей, шлюз браслет, метка, настройка счётчик, датчик в поле трекер, торговый автомат

MQTT: почему именно он стал языком IoT

Когда данные долетели до IP-сети, встаёт вопрос прикладного протокола. HTTP плох по трём причинам: заголовки в сотни байт на запрос, только модель «клиент спрашивает» (устройство за NAT никто не опросит) и новое соединение на каждое сообщение. MQTT решает ровно это: фиксированный заголовок в 2 байта, публикация-подписка и одно долгоживущее TCP-соединение, через которое команды идут в обе стороны. Устройства и сервисы подключаются к брокеру и обмениваются сообщениями через топики — иерархические строки вида plant/line3/press/42/telemetry. Издатель не знает подписчиков, подписчик не знает издателей. Подписка может содержать подстановки: + — один уровень, # — все оставшиеся. Матчинг топика в брокере — обход дерева подписок, то есть O(k) от числа уровней в топике, а не от числа подписчиков; именно поэтому брокеры держат десятки тысяч устройств на скромном железе.

Схема именования топиков — решение на годы

Топики почти невозможно поменять, когда в поле стоит десять тысяч устройств, — они вшиты в прошивку. Работающая структура:

tenant/{tenant}/site/{site}/dev/{id}/up/telemetry    # устройство → облако
tenant/{tenant}/site/{site}/dev/{id}/up/status       # retained + LWT
tenant/{tenant}/site/{site}/dev/{id}/down/cmd        # облако → устройство
tenant/{tenant}/site/{site}/dev/{id}/down/cfg        # retained-конфигурация

Правила, за которые платят кровью: идентификатор устройства в топике, а не в payload (иначе невозможны ни ACL, ни маршрутизация), направление up/down явно (иначе устройство подпишется само на себя и получит эхо-цикл), никаких пробелов и #/+ в идентификаторах, и права доступа выдаются префиксом — устройству разрешена публикация только в свою ветку up/ и подписка только на свою down/. Это ровно тот же принцип наименьших привилегий, что в авторизации API, просто выраженный строками топиков (в примерах кода ниже уровень site опущен для краткости).

Гарантии доставки: QoS 0, 1 и 2

Практический выбор проще, чем кажется: QoS 0 для телеметрии, QoS 1 для событий и команд, QoS 2 почти никогда. Телеметрию не жалко: следующее измерение придёт через минуту, а ожидание PUBACK удерживает радио включённым и стоит энергии. События («сработала защита», «крышка открыта») терять нельзя — QoS 1 плюс идемпотентность на стороне сервера. QoS 2 требует хранить состояние транзакции на устройстве переживающим перезагрузку способом, то есть писать во flash — четыре пакета и износ памяти ради того, что дешевле решается номером сообщения и дедупликацией на сервере. Но три других механизма MQTT в IoT важнее самого QoS:

  • Last Will and Testament. Клиент при подключении сообщает брокеру сообщение, которое надо опубликовать, если соединение оборвётся некорректно. Это единственный способ отличить «устройство молчит, потому что нечего сказать» от «устройство умерло», и обходится он в ноль байт трафика.
  • Retained-сообщения. Брокер хранит последнее сообщение в топике и отдаёт новому подписчику немедленно. Идеально для статуса и конфигурации: дашборд, открытый в три часа ночи, сразу видит состояние, а устройство после перезагрузки сразу получает свои настройки, не спрашивая никого.
  • Persistent session (clean_session = false в 3.1.1, Session Expiry в 5.0). Брокер помнит подписки и копит сообщения QoS 1/2 для отключённого клиента. Для устройства, засыпающего на час, это способ получить команды, накопленные за время сна. Обратная сторона — очередь может переполниться и, что хуже, устройство после сна получает лавину устаревших команд, поэтому команды стоит снабжать временем жизни.

MQTT 5.0 добавил вещи, ради которых стоит на него переходить: коды причин в ответах (вместо «просто разорвал соединение»), Message Expiry Interval для протухания команд, Topic Alias (передавать длинный топик один раз, дальше — двухбайтовый номер, серьёзная экономия для узких каналов), Request/Response с полем Response Topic и общие подписки для горизонтального масштабирования потребителей.

Клиент на устройстве: ESP-IDF

/* ESP-IDF: MQTT поверх TLS со взаимной аутентификацией. Сертификаты вшиваются
 * в образ через EMBED_TXTFILES в CMakeLists.txt, а не читаются из NVS: иначе
 * их подменяют, не трогая подпись прошивки. */
#include "mqtt_client.h"
extern const uint8_t root_ca_pem_start[]    asm("_binary_root_ca_pem_start");
extern const uint8_t client_crt_pem_start[] asm("_binary_client_crt_pem_start");
extern const uint8_t client_key_pem_start[] asm("_binary_client_key_pem_start");

static esp_mqtt_client_handle_t s_client;
static char s_cmd_topic[96], s_status_topic[96];

static void mqtt_event_handler(void *arg, esp_event_base_t base,
                               int32_t id, void *event_data)
{
    esp_mqtt_event_handle_t e = (esp_mqtt_event_handle_t)event_data;
    switch ((esp_mqtt_event_id_t)id) {
    case MQTT_EVENT_CONNECTED:
        /* Подписки восстанавливаем сами: при clean_session брокер их не помнит.
         * QoS 1 — команду терять нельзя, в отличие от телеметрии.
         * Статус retained: дашборд увидит «online» сразу при открытии. */
        esp_mqtt_client_subscribe(s_client, s_cmd_topic, 1);
        esp_mqtt_client_publish(s_client, s_status_topic, "{\"online\":true}", 0, 1, 1);
        break;
    case MQTT_EVENT_DATA:
        /* Обработчик крутится в задаче MQTT: тяжёлую работу отсюда отдаём
         * в очередь, иначе поплывёт keepalive всего соединения. */
        handle_command(e->data, e->data_len);
        break;
    case MQTT_EVENT_ERROR:
        /* «Не подключается» — почти всегда просроченный сертификат или
         * разъехавшееся время на устройстве; молча глотать нельзя. */
        if (e->error_handle->error_type == MQTT_ERROR_TYPE_ESP_TLS)
            ESP_LOGE("mqtt", "TLS cert_flags=0x%x",
                     e->error_handle->esp_tls_cert_verify_flags);
        break;
    default: break;
    }
}

void mqtt_start(const char *dev)
{
    snprintf(s_cmd_topic, sizeof(s_cmd_topic), "tenant/acme/dev/%s/down/cmd", dev);
    snprintf(s_status_topic, sizeof(s_status_topic), "tenant/acme/dev/%s/up/status", dev);
    esp_mqtt_client_config_t cfg = {
        .broker.address.uri = "mqtts://mqtt.example.com:8883",
        .broker.verification.certificate = (const char *)root_ca_pem_start,
        .credentials.authentication.certificate = (const char *)client_crt_pem_start,
        .credentials.authentication.key = (const char *)client_key_pem_start,
        .credentials.client_id = dev,
        /* LWT: брокер объявит устройство мёртвым, если keepalive не придёт */
        .session.last_will = { .topic = s_status_topic, .msg = "{\"online\":false}",
                               .qos = 1, .retain = 1 },
        /* keepalive обязан быть МЕНЬШЕ таймаута NAT на роутере, иначе соединение
         * «есть» на устройстве и «нет» на брокере — источник пропавших команд */
        .session.keepalive = 60,
        .session.disable_clean_session = true,   /* команды доживут до пробуждения */
        .network.reconnect_timeout_ms = 10000,
    };
    s_client = esp_mqtt_client_init(&cfg);
    esp_mqtt_client_register_event(s_client, ESP_EVENT_ANY_ID, mqtt_event_handler, NULL);
    esp_mqtt_client_start(s_client);
}

На приёмной стороне и для отладки удобен paho-mqtt:

# paho-mqtt: приём телеметрии на сервисной стороне. Тот же скрипт работает
# и как инструмент отладки устройства, когда «данные не приходят».
import json, ssl, paho.mqtt.client as mqtt

def on_connect(client, userdata, flags, reason_code, properties=None):
    if reason_code != 0:
        raise SystemExit(f"брокер отказал: {reason_code}")
    client.subscribe("tenant/acme/dev/+/up/telemetry", qos=1)  # '+' — один уровень

def on_message(client, userdata, msg):
    device_id = msg.topic.split("/")[3]      # id берём из топика, не из payload
    data = json.loads(msg.payload)
    seq, seen = data.get("seq"), userdata["seen"]
    if seq is not None and seq <= seen.get(device_id, -1):
        return                               # дубль QoS 1 после потери PUBACK
    seen[device_id] = seq
    print(f"{device_id}: {data}")

client = mqtt.Client(mqtt.CallbackAPIVersion.VERSION2, userdata={"seen": {}})
client.tls_set(ca_certs="root_ca.pem", tls_version=ssl.PROTOCOL_TLSv1_2)
client.on_connect, client.on_message = on_connect, on_message
client.connect("mqtt.example.com", 8883, keepalive=60)
client.loop_forever()

Для быстрой проверки руками хватает утилит Mosquitto — это первое, что стоит запустить, когда «устройство не отправляет данные»: mosquitto_sub -h mqtt.example.com --cafile root_ca.pem -t 'tenant/acme/#' -v -q 1 -d печатает вообще всё, что приходит от устройств тенанта, вместе с топиками (-v) и пакетами протокола (-d), а mosquitto_pub с тем же набором ключей отправляет команду в конкретную ветку down/cmd.

Когда MQTT не нужен. CoAP (RFC 7252) — это REST поверх UDP с бинарными заголовками в 4 байта, DTLS для защиты и режимом observe вместо подписки. Он выигрывает там, где нет TCP-стека или где соединение слишком дорого держать: сообщение отправляется одним датаграммным пакетом без рукопожатия. MQTT-SN — вариант MQTT для сетей без IP вообще (Zigbee, проприетарное радио), где шлюз транслирует его в обычный MQTT. Ориентир простой: есть TCP и связь долгоживущая — MQTT; связь редкая и пакетная — CoAP; IP нет — MQTT-SN или свой протокол поверх шлюза. Более широкий разбор моделей обмена сообщениями — в статье про обмен сообщениями в распределённых системах.

Безопасность устройств: почему это не «включить TLS»

IoT-устройство отличается от сервера тремя свойствами, и каждое ломает привычную модель защиты. Оно физически доступно атакующему — прибор уносят домой и паяют сколько угодно. Оно живёт 5–10 лет — за это время алгоритмы стареют, а сертификаты истекают. Оно обновляется редко и удалённо — механизм обновления сам становится главной мишенью.

Идентичность: главный вопрос

Всё остальное — производное. Варианты различаются не удобством, а последствиями компрометации одного прибора.

Способ Что хранится на устройстве Компрометация одного = Когда применим
Общий ключ на партию один AppKey/PSK у всех все устройства скомпрометированы никогда в проде
Уникальный PSK на устройство 32 байта секрета одно устройство LoRaWAN, простые сценарии, ограниченная флеш
Клиентский сертификат X.509 приватный ключ + цепочка одно устройство, отзывается через CRL/OCSP MQTT/TLS, облачные платформы
Secure Element (ATECC608, SE050) ключ не покидает чип ничего: ключ не извлекается всё, где прибор физически доступен

Ключевая мысль про Secure Element: это не «ещё один чип за 60 центов», а единственный способ ответить на вопрос «что будет, если устройство унесут в лабораторию». Приватный ключ генерируется внутри чипа и физически не может быть считан, наружу отдаются только подписи: злоумышленник получает возможность подписывать от имени прибора, пока прибор у него, но не получает ключ, который можно размножить на тысячу клонов. Разница между «одно скомпрометированное устройство» и «скомпрометирована вся партия» — это разница между инцидентом и отзывом продукции. Отсюда же главный организационный принцип: уникальный секрет на каждое устройство и никогда — общий. Тысячи домашних роутеров и камер попадали в ботнеты именно из-за общего пароля, зашитого в прошивку. Ключи прошиваются на производстве (provisioning) в защищённом окружении, а не «прописываются потом руками» — иначе процесс неминуемо приведёт к общему ключу «для удобства монтажников». Хранение и ротация секретов на бэкенде — отдельная дисциплина, разобранная в статье про управление секретами.

Цепочка доверия от загрузчика до облака

Узлы, где ломается чаще всего:

  • Secure Boot проверяет подпись каждого следующего звена. На ESP32 это secure boot v2 с RSA-3072 и хешем открытого ключа в eFuse; на STM32 — опции RDP уровня 2 плюс проверка в загрузчике. Операция необратима: прошитый eFuse нельзя вернуть, поэтому включают её на партии после отладки, и тестовые платы держат отдельно.
  • Flash Encryption защищает от считывания микросхемы: ключ живёт в eFuse и недоступен процессору напрямую, дешифрование идёт на лету при чтении. Без него секрет из внешней SPI-flash снимается программатором за пять минут, а на многих платах — вообще логическим анализатором на шине SPI.
  • Anti-rollback. Подпись сама по себе не мешает залить старую подписанную прошивку с известной дырой. Монотонный счётчик версии в eFuse — обязательное дополнение, о котором забывают чаще всего.
  • Проверка времени. Сертификат нельзя проверить без часов. Устройство без RTC после перезагрузки думает, что сейчас 1970 год, и валидация X.509 падает. Классический баг «работает на столе, не работает после отключения питания на объекте»: на столе время подтянулось при прошлом запуске, в поле — нет.
  • Проверка перед фиксацией образа. OTA обязан иметь две банки flash и режим пробной загрузки: новая прошивка объявляется рабочей только после того, как сама подтвердила, что смогла подключиться к сети. Иначе первое же обновление с ошибкой в конфигурации Wi-Fi превращает парк устройств в кирпичи без возможности достучаться. Механика двух банок и практика выкатки подробно разбирается в статье про отладку и производство.

Ещё две вещи, которые обязаны быть в проде и почти всегда отсутствуют в прототипе. Отладочные интерфейсы закрыты: SWD/JTAG выключен фьюзами, отладочный UART не даёт шелл. Никаких секретов в исходниках и в git — они утекают через репозиторий чаще, чем через радио. Прикладная сторона криптографии — выбор режимов, работа с nonce, почему нельзя изобретать своё — в статье про прикладную криптографию.

Отладка связи без единого принта

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

  • Логический анализатор на шине к радиомодулю. SX1262, nRF24 и большинство внешних трансиверов подключены по SPI. Захват шины показывает точную последовательность команд и, главное, момент выставления DIO/IRQ-линии. Дешёвый 8-канальный анализатор с декодером SPI в PulseView отвечает на вопрос «модуль вообще получил команду на передачу?» за минуту вместо часа гаданий.
  • Осциллограф на линии питания через шунт. Резистор 1 Ом в разрыв питания даёт тот самый график из раздела про Wi-Fi — единственный способ увидеть, что устройство просыпается на 300 мс дольше, чем вы думали, или что радио не выключается после отправки. Специализированные измерители (Nordic Power Profiler Kit II, Otii Arc) делают то же удобнее и с логарифмической шкалой от наноампер до сотен миллиампер; подробности методики — в статье про питание и ограничения.
  • Радиоснифферы. Для BLE — nRF Sniffer на dongle nRF52 плюс Wireshark: видно рекламные пакеты, параметры соединения, MTU и момент, где именно телефон разрывает связь. Для Wi-Fi — режим monitor, для LoRa — второй модуль в режиме приёма. Сниффер отвечает на вопрос, который иначе не решается: «устройство не отправило или шлюз не принял?».
  • SWD с живым просмотром переменных. Отладчик Cortex-M читает память работающего процессора без остановки (у SEGGER это Real Time Transfer, RTT): счётчики отправленных пакетов, RSSI последнего приёма, состояние конечного автомата связи видны в реальном времени и не стоят ни миллиампера — в отличие от UART-лога.
  • Метрики вместо логов. Устройство в поле должно само сообщать диагностику: RSSI и SNR последнего пакета, счётчик неудачных подключений, счётчик перезагрузок с кодом причины сброса, напряжение батареи, использованный SF. Десять байт в каждом пакете телеметрии заменяют половину выездов на объект. Деталь: RSSI без SNR обманчив — LoRa принимает сигнал ниже уровня шума, и −120 дБм при SNR +8 дБ гораздо лучше, чем −100 дБм при SNR −15 дБ.

Какую плату взять

Плата Радио Когда брать Чего избегать
ESP32-S3 / C6 Wi-Fi + BLE в чипе всё, где есть питание: шлюзы, дисплеи, панели; лучший старт для прототипа батарейные проекты на годы: даже в deep sleep плата на модуле часто «течёт» из-за регулятора и светодиода
nRF52840 / nRF54 BLE, Thread, Zigbee носимые устройства, метки, датчики на CR2032; эталонная энергетика BLE Wi-Fi (его нет) и потоковые данные
STM32WL55 LoRa в одном корпусе с Cortex-M4 автономные датчики в поле, счётчики, промышленная телеметрия всё, где нужны мегабиты или IP-связность
STM32 + внешний модуль любое по SPI когда важнее периферия и детерминизм, а радио вторично усложняется разводка и сертификация
Arduino (AVR/UNO) только внешние модули обучение, макет на столе, проверка датчика за час прод: 2 КБ ОЗУ не вмещают ни TLS, ни нормальный стек
Raspberry Pi / CM4 Wi-Fi + BLE, Ethernet шлюз LoRaWAN, ROS-узел, обработка видео, всё с Linux питание от батареи и жёсткий реальный тайминг

Практическое правило, которое экономит месяцы: прототип на ESP32 или Raspberry Pi, прод — на том, что диктует энергетика. ESP32 позволяет за вечер довести идею до работающего MQTT-клиента с TLS, и это ровно то, что нужно для проверки гипотезы. Но если в требованиях «три года от батарейки», то финальное железо почти наверняка будет nRF52 или STM32WL, и закладывать переход надо заранее — держа прикладную логику отдельно от драйверов радио, чтобы порт не превратился в переписывание с нуля. Про разделение по слоям и переносимый код — в статье про C для встраиваемых систем, про выбор плат по периферии и питанию — в статье про железо для программиста.

В робототехнике связь распадается на два контура. Нижний — детерминированный, внутри робота, по CAN или UART (см. протоколы), и он не имеет права зависеть от Wi-Fi. Верхний — телеметрия и команды наружу, где как раз живут MQTT и Wi-Fi. Смешивать их нельзя: пакет управления двигателем, ушедший в очередь брокера, — это авария. Архитектурная сторона такого разделения — как раскладывать функции робота по узлам и какие контракты между ними держать — исследована в научных работах портала: Определение роботизированных систем и их классификация, Теоретические обоснования микросервисной архитектуры для роботизированных систем и Реализация сервисов. Они естественно продолжают эту статью: то, что здесь решается выбором радио и QoS, на уровне системы целиком решается границами сервисов и гарантиями между ними.

Типичные ошибки

  1. Выбрать радио по знакомству, а не по бюджету энергии. Wi-Fi на батарейке — самая дорогая ошибка проекта, потому что обнаруживается она после разводки платы.
  2. Оптимизировать payload вместо последовательности подключения. Сжали JSON вдвое и сэкономили 0,3 % энергии, пока DHCP и полный TLS-хендшейк съедают 90 %.
  3. Слать JSON по LoRaWAN. Один объект в 90 байт не помещается в кадр SF12 в принципе, а на SF7 съедает дневную квоту эфирного времени за десяток посылок.
  4. Игнорировать duty cycle. Стек молча блокирует передачу, приложение считает, что отправило, данные теряются — и воспроизводится это только через час работы.
  5. Считать, что MTU в BLE равен 247. По умолчанию он 23, а обмен MTU инициирует центральное устройство, и iOS с Android договариваются по-разному.
  6. QoS 2 «на всякий случай». Четыре пакета, состояние во flash и износ памяти вместо номера сообщения и дедупликации на сервере.
  7. Забыть Last Will. Мониторинг не отличает «датчик молчит, потому что тихо» от «датчик умер», и авария обнаруживается через сутки.
  8. Keepalive больше таймаута NAT. Устройство уверено, что соединение живо, брокер давно его закрыл, команды уходят в пустоту. Классика для домашних роутеров с таймаутом 300 с.
  9. Один ключ на всю партию, открытые SWD и UART в проде. Одно вскрытое устройство компрометирует всё, что вы продали: прошивка со всеми секретами считывается за минуты, а отозвать это невозможно.
  10. OTA без подписи, без anti-rollback и без пробной загрузки; проверка сертификата без корректного времени. Первые три способа превращают парк устройств в кирпичи, и каждый срабатывает ровно один раз; четвёртый даёт баг «работает на столе, ломается на объекте после отключения питания».
  11. Отлаживать связь принтами. UART-лог сам меняет тайминги и профиль тока; сниффер, логический анализатор и RTT отвечают на вопросы, на которые printf ответить не может.

Мини-итог

  • Связь — это энергия. Считать надо не байты, а миллиампер-секунды, и большая их часть уходит до передачи первого полезного байта.
  • Выбор радио определяется питанием, дальностью, объёмом данных и владельцем инфраструктуры — именно в этом порядке. Универсального варианта не существует по физическим причинам.
  • Wi-Fi даёт скорость и IP-связность ценой сотен миллиампер; BLE — годы от таблетки ценой десятков метров; LoRaWAN — километры ценой сотен бит в секунду и жёстких квот на эфир. Параметры соединения BLE (интервал, latency, MTU) влияют на потребление сильнее любой оптимизации кода; в LoRaWAN всё определяется временем в эфире, а его считает одна формула.
  • MQTT стал языком IoT из-за pub/sub, двухбайтового заголовка и долгоживущего соединения. Схема топиков, LWT, retained и persistent session важнее выбора QoS.
  • Безопасность начинается с уникальной идентичности устройства, а не с включённого TLS, и держится непрерывной цепочкой: secure boot, шифрование flash, anti-rollback, подписанный OTA с пробной загрузкой, взаимный TLS и корректное время на борту.
  • Отладка радио — это сниффер, логический анализатор на SPI, осциллограф на шунте и RTT по SWD. Принты в этой области мешают больше, чем помогают.

Источники

Что дальше

Датчики и приводы: считывание, фильтрация, калибровка, моторы — откуда берутся те самые байты, которые мы здесь так экономно отправляли: как читать аналоговый сигнал без шума, зачем нужны калибровка и фильтр Калмана, чем комплементарный фильтр отличается от скользящего среднего и как из ШИМ и драйвера получается управляемое движение.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов