Состояние ячейки: свод трёх источников
Это главное архитектурное ре шение проекта. Оно не про красоту, а вынуждено физикой: плата
отвечает байтом 0x00 одновременно на «дверь открыта» и на «замка на этом канале нет», и
вторым опросом это не лечится — у платы просто нет способа сказать «канал пустой».
Показание датчика само по себе не является состоянием ячейки. Состояние — это свод трёх источников, и каждый отвечает на свой вопрос:
| Источник | Вопрос | Откуда берётся |
|---|---|---|
| конфигурация | существует ли такая ячейка вообще и не выведена ли она из работы | панель оператора, руками |
| опрос платы | что на входе прямо сейчас | LockBus + packages/lock-protocol |
| журнал событий | что с ней было раньше | packages/domain/src/events.ts |
Реализация — packages/domain/src/cell-state.ts, функция deriveCellState().
Пакет пишется прямо сейчас; точные имена и числа сверяйте по файлу, а не по этой странице.
Единственное положительное доказательство
Если канал хоть раз в жизни отдал 0x11, значит датчик на нём физически есть:
несуществующий датчик такого ответа дать не может ни при каких условиях.
Обратного доказательства не существует. Сколько бы 0x00 мы ни увидели, это не значит
«датчика нет» — это значит «дверь может быть открыта».
Поэтому признак «датчик доказан» идёт только в одну сторону, unknown → confirmed, и
никогда обратно. Функция, которая умеет его сбрасывать, — это способ потерять единственное
надёжное знание о шкафе. И живёт он дольше загрузки: это факт про физику канала, а не про
сессию, значит хранить его надо рядом с конфигурацией шкафа, а не в оперативной памяти.
Это же свойство лежит в основе мастера привязки ячеек: переход «открыто → закрыто», который техник делает рукой, — и есть момент, когда появляется доказательство.
Состояния
| Состояние | Что значит | Что можно делать |
|---|---|---|
absent | ячейки нет в конфигурации | не показывать и не открывать |
out_of_service | выведена человеком | опрос и журнал не спрашиваем |
unknown | связи с платой нет | выдача запрещена: мы не увидим результата |
closed | датчик вернул 0x11 | исправна и заперта |
open_expected | открыта, и это мы её открыли меньше 90 секунд назад | штатный ход выдачи |
open_unexpected | открыта, а команды не было | тревога |
sensor_unproven | открыта, и 0x11 с этого канала не приходил ни разу за всю жизнь | скорее всего датчика нет; под возврат такую ячейку давать нельзя |
Два состояния из этого списка объясняют, зачем вообще нужен журнал. Отличить open_expected
от open_unexpected можно только по записи «мы отправляли команду открытия вот тогда-то».
Отличить sensor_unproven от честно открытой двери — только по записи «0x11 с этого канала
приходил хоть раз».
Три решения, которые выглядят мелочью и таковыми не являются
Окно «открытие ожидаемо» — 90 секунд, и оно общее
Пока киоск ждёт, что человек закроет дверцу, та же самая открытая дверь не должна выглядеть тревогой для фонового опроса. Поэтому окно в модели состояния и тайм-аут ожидания в машине киоска — одно и то же число. Разъедутся — получим ложные тревоги ровно на каждой нормальной выдаче.
Время только монотонное
Настенные часы прыгают при синхронизации, а на плате они и без того ненадёжны: RTC сидит на
шине рядом с EEPROM панели, время уезжает, после синхронизации с сервером прыгает назад.
Прыжок назад на минуту превратит только что открытую дверь в open_unexpected — то есть
в ложную тревогу на ровном месте.
Поэтому все интервалы в домене (окно 90 секунд, лестница антибрута, тайм-ауты киоска) считаются по монотонным часам — миллисекундам от старта приложения. Цена: они обнуляются при каждой перезагрузке.
offline — это не «одна потерянная посылка»
Один потерянный кадр надо перечитать, а не объявлять ячейку неизвестной: иначе весь шкаф
будет мигать unknown на каждой помехе. offline означает «шина молчит», а не «не разобрался
один ответ».
Журнал — третий источник, а не логи для отладки
Потеря журнала — это не потеря истории, а потеря способности понимать текущее состояние
шкафа: без него open_expected и open_unexpected неразличимы.
У каждого события три метки времени, и каждая отвечает на свой вопрос:
| Метка | Что это | Чему верить |
|---|---|---|
| настенная | часы устройства | единственная, понятная человеку; порядку событий по ней доверять нельзя |
| монотонная | миллисекунды от старта приложения | не прыгает, но обнуляется при перезагрузке; сравнивать имеет смысл только внутри одной загрузки |
| серверная | время приёма на сервере | проставляется один раз, служит для сведения журналов разных постаматов |
Отсюда обязательное поле bootId — идентификатор загрузки. Оно нужно не для удобства:
на плате живёт сторожевой таймер, который перезагружает её примерно каждые
295 секунд, если его не кормить, и в логах ядра при этом не остаётся ничего. Со стороны
это выглядит как «приложение иногда странно себя ведёт». Различить перезагрузку и продолжение
работы можно только по смене bootId.
Практическое следствие для того, кто разбирает инцидент: ряд событий с новым bootId
каждые ~295 секунд — это не загадка приложения, это несытый сторожевой таймер.
Идентификаторы событий — ULID в алфавите Crockford base32 (без букв I, L, O, U). Буквы выкинуты
не для красоты: идентификатор рано или поздно прочитают вслух по телефону или перепишут
с экрана, а I/1 и O/0 в этот момент неразличимы.
Машина состояний киоска: три инварианта и один исправленный тупик
Рядом со сводом состояний живёт packages/domain/src/kiosk-machine.ts — машина, которая
решает, что делать дальше. Она не открывает порт и не считает контрольные суммы: просит
хозяина сделать и присылает обратно факты. Это не украшение архитектуры — единственная плата
отключена, и машина, не зависящая от железа, это то, что можно прогнать в тестах целиком.
Три инварианта, ради которых там именно машина, а не набор условий:
- В «выдано» нельзя попасть без показания датчика. Подтверждение платы не ведёт туда ни по одному пути: замок может заклинить, а эхо придёт как ни в чём не бывало.
- В ожидании закрытия машина не отправляет в шину ни одного кадра. Команды «закрыть» не существует, дверь закрывает человек, а опрос датчика включается один раз при входе в ожидание открытия.
- При мёртвой шине приём кодов запрещён — не «показываем предупреждение», а именно нет перехода. Принять код при неработающей шине значит взять обязательство, которое нечем выполнить, и погасить код за дверь, которая не откроется.
🔴 Исправленная ошибка: «дверь бросили открытой» была тупиком
Состояние «человек ушёл, не закрыв дверцу» — единственное исключение из второго инварианта, и раньше это было сделано неверно. Переход в него шёл из ветки выдачи, а выход из той ветки гасил опрос датчика — ровно в тот момент, когда только он и мог бы вывести из тупика. Событие «дверь закрыли» не могло прийти физически.
На практике это означало: один невнимательный человек вечером выводил постамат из строя до приезда оператора. Теперь опрос в этом состоянии возобновляется отдельно, и постамат возвращается в работу сам, как только дверцу закрывают.
Урок общего свойства: если состояние гасит источн ик события, которым из него выходят, это тупик — независимо от того, как он называется в коде.
Чего на этой модели строить нельзя
- «Покажем клиенту свободные ячейки» по одному опросу. Пустой канал шкафа и распахнутая дверца неразличимы. Свободность — это журнал плюс конфигурация, а опрос лишь уточняет.
- Автоопределение карты «ячейка ↔ канал». По той же причине. Карта приходит из панели, и заполняется она мастером привязки.
- «Дверь закрылась» как результат команды. Команды закрытия не существует; закрытие видит только датчик.