Четыре запрета
Каждый из них уже стоил проекту времени, а первый стоил бы железа. Два верхних — про то, что ломает шкаф; два нижних — про то, что ломает разбор ответов так, что протокол выглядит неработающим.
🔴 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-й бит | 0x80 → 0x00, заголовок ответа исчезает |
ixon / ixoff | съедает байты 0x11 и 0x13 | 0x11 — это наше «закрыто», пропадало целиком |
iuclc | 0x41…0x5A → строчные | контрольная сумма 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) → cfmakeraw → tcsetattr →
обязательно перечитать tcgetattr и убедиться, что входные преобразования сняты, а размер
символа — 8 бит. Проверка на выходе нужна потому, что tcsetattr рапортует успехом даже при
частично принят ой настройке.
Готовая реализация — apps/kiosk/modules/serial/android/src/main/cpp/tty.c. Обходить её нельзя
ничем: ни FileInputStream, ни вызовом stty.
🔴 3. Один дескриптор на порт — и на чтение, и на запись
Подтверждение открытия теряется, если писать и читать разными дескрипторами. Похоже на управление направлением RS485: с отдельным пишущим дескриптором линия дольше остаётся в передаче, и ответ приходит в заглушённый приёмник.
Из-за этого документация проекта полдня утверждала «плата не отвечает на команду открытия». Плата отвечала всегда — не отвечала методика.
Отсюда же требование «один владелец порта на приложение»: нативный модуль держит ровно одно соединение, и повторное открытие молча отбирает порт у предыдущего хозяина.