Перейти к основному содержимому

Четыре запрета

Каждый из них уже стоил проекту времени, а первый стоил бы железа. Два верхних — про то, что ломает шкаф; два нижних — про то, что ломает разбор ответов так, что протокол выглядит неработающим.

🔴 1. Кадры 9A и 9B — никогда, ни в каком виде

Это команды удержания питания катушки. У нас импульсные соленоиды fail-secure: они рассчитаны на импульс в треть секунды, а не на постоянный ток. Постоянное питание сжигает катушку за десятки секунд, необратимо — замок после этого не открывается ничем и меняется только целиком.

Коварство в том, что отказа не будет: плата примет кадр, ответит эхом, а катушка будет греться. Ни лог, ни код возврата не предупредят.

Рядом, чтобы не искали:

  • команды 0x99 не существует — она была выдумана в раннем анализе и попала в старые документы; на живой плате не отвечает;
  • открытие с каналом 0 («открыть все») не проверялось и запрещено: на собранном шкафу оно выстрелит всеми катушками разом.

Как это защищено в коде

Две независимые линии, и обе обязательны.

Барьер assertFrameAllowed() в packages/lock-protocol/src/guard.ts. Он устроен как белый список: разрешены ровно две формы кадра — открытие канала 1…27 и чтение канала 0…27, длина ровно 5 байт, верный адрес, сошедшаяся контрольная сумма. Запрет по чёрному списку тут не годится: мы не знаем всех команд платы, а те, что знаем, соседствуют с управлением питанием.

Отправка в шину в приложении одна — в LockBus, и она зовёт барьер на каждой записи.

Тест-сторож репозитория no-forbidden-literals.spec.ts ищет такие литералы по всем исходникам проекта — в отладочной кнопке, в примере, в закомментированном коде, который кто-нибудь потом раскомментирует. Правило: если тест упал, надо убрать литерал, а не добавить файл в исключения.

Эмулятор платы — третья линия: такие кадры он не исполняет, молчит в ответ и громко записывает попытку. Лучше поймать это на ноутбуке, чем на шкафу.

🔴 2. Порт обязан открываться в настоящем raw-режиме

Ядро отдаёт ttyS0 с включёнными входными преобразованиями, и они калечат ответы платы:

ФлагЧто делаетПоследствие
istripсрезает 8-й бит0x800x00, заголовок ответа исчезает
ixon / ixoffсъедает байты 0x11 и 0x130x11 — это наше «закрыто», пропадало целиком
iuclc0x410x5A → строчныеконтрольная сумма 0x41 приходила как 0x61
igncr / inlcr / icrnlвыбрасывает и подменяет 0x0D/0x0Aкадр молча укорачивается или портится

Именно iuclc породил легенду о «неразгаданной контрольной сумме вендора». Её не существовало: это XOR, всегда был XOR, кадры портил драйвер порта.

Диагностический признак, который стоило заметить раньше: на однотипные запросы приходили ответы разной длины (3, 4, 5 байт), и в них подозрительно часто попадались 0x00 и 0x0D и никогда не встречался 0x11. Разная длина ответа на одинаковые по структуре запросы — почти всегда терминальные преобразования, а не протокол.

Чего делать нельзя:

  • звать stty из приложения. Штатный stty на устройстве — это toybox: он молча прекращает разбор аргументов на первом непонятом флаге и возвращает успех. Длинная строка настройки применяется частично — скорость встаёт, входные преобразования остаются;
  • открывать порт «просто файлом» из Java или Kotlin: термиос они не умеют вообще.

Единственный верный путь — нативно: open(O_RDWR|O_NOCTTY)cfmakerawtcsetattrобязательно перечитать tcgetattr и убедиться, что входные преобразования сняты, а размер символа — 8 бит. Проверка на выходе нужна потому, что tcsetattr рапортует успехом даже при частично принятой настройке.

Готовая реализация — apps/kiosk/modules/serial/android/src/main/cpp/tty.c. Обходить её нельзя ничем: ни FileInputStream, ни вызовом stty.

🔴 3. Один дескриптор на порт — и на чтение, и на запись

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

Из-за этого документация проекта полдня утверждала «плата не отвечает на команду открытия». Плата отвечала всегда — не отвечала методика.

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

🔴 4. Длину кадра нельзя определять по позиции байта

Существует законный семибайтный кадр, чьи первые пять байт проходят проверку как пятибайтный. Например, 80 02 00 00 82 33 33 — каналы 9–24 закрыты, из 1–8 открыты два: третий байт равен 0x00, что выглядит как «состояние = открыто», и XOR первых четырёх сходится с пятым. Наивный разбор съедал 5 байт вместо 7, хвост уходил в мусор, и картина двух открытых ячеек терялась целиком.

Правильный признак — сходимость контрольной суммы и маркер команды, а не позиция:

  1. сначала проверяем семибайтную трактовку: шестой байт — эхо команды 0x33, и XOR первых шести сходится с седьмым;
  2. и только потом пятибайтную.

Ошибку ловили трижды. На стенде она спит, потому что неподключённые каналы читаются как 0xFF; проснулась бы на собранном шкафу, где закрытые каналы дают ровно 0x00. Порядок проверок в takeReply() — не стилистика, менять его нельзя.

Ещё пять ловушек — в нативном модуле порта

Они не про протокол, а про то, как Android отдаёт последовательный порт. Каждая уже проявлялась и каждая выглядела как «плата отвечает мусором».

  1. read() на tty возвращает -1, и это НЕ конец файла. В термиос стоит VMIN=0, VTIME=1: ядро возвращает 0 байт, если за 100 мс ничего не пришло, а FileInputStream трактует это как EOF. Прежняя версия на -1 выходила из цикла — поток чтения умирал через 100 мс после открытия, раньше, чем плата успевала ответить. Для tty настоящего конца потока не бывает: и 0, и -1 значат «пока тишина».
  2. Ссылку на поток чтения брать локально, а не из поля. После переоткрытия порта старый поток, проснувшись, увидел бы в поле уже новый дескриптор и начал читать параллельно с новым потоком. Два читателя на одном порту рвут ответ пополам.
  3. Мёртвый порт обязан отказывать и на запись. Иначе после аварии чтения модуль продолжает считать порт открытым: запись рапортует успехом, в интерфейсе горит «подключено», а приём мёртв необратимо.
  4. Закрытие: сначала дождаться потока, потом закрывать дескриптор. Прерывание блокирующего чтения на Android не работает, а закрытие дескриптора поток не будит. Ждать недолго — чтение вернётся за ~100 мс. Дескриптор закрывать последним и ровно один раз.
  5. Порт в модуле один на всё приложение. Диагностический экран и бизнес-логика не могут держать его одновременно: второй открывший отберёт порт у первого молча.

Первоисточник по всем пяти — apps/kiosk/README.md и комментарии в SerialModule.kt.