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

Состояние ячейки: свод трёх источников

Это главное архитектурное решение проекта. Оно не про красоту, а вынуждено физикой: плата отвечает байтом 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 — машина, которая решает, что делать дальше. Она не открывает порт и не считает контрольные суммы: просит хозяина сделать и присылает обратно факты. Это не украшение архитектуры — единственная плата отключена, и машина, не зависящая от железа, это то, что можно прогнать в тестах целиком.

Три инварианта, ради которых там именно машина, а не набор условий:

  1. В «выдано» нельзя попасть без показания датчика. Подтверждение платы не ведёт туда ни по одному пути: замок может заклинить, а эхо придёт как ни в чём не бывало.
  2. В ожидании закрытия машина не отправляет в шину ни одного кадра. Команды «закрыть» не существует, дверь закрывает человек, а опрос датчика включается один раз при входе в ожидание открытия.
  3. При мёртвой шине приём кодов запрещён — не «показываем предупреждение», а именно нет перехода. Принять код при неработающей шине значит взять обязательство, которое нечем выполнить, и погасить код за дверь, которая не откроется.

🔴 Исправленная ошибка: «дверь бросили открытой» была тупиком

Состояние «человек ушёл, не закрыв дверцу» — единственное исключение из второго инварианта, и раньше это было сделано неверно. Переход в него шёл из ветки выдачи, а выход из той ветки гасил опрос датчика — ровно в тот момент, когда только он и мог бы вывести из тупика. Событие «дверь закрыли» не могло прийти физически.

На практике это означало: один невнимательный человек вечером выводил постамат из строя до приезда оператора. Теперь опрос в этом состоянии возобновляется отдельно, и постамат возвращается в работу сам, как только дверцу закрывают.

Урок общего свойства: если состояние гасит источник события, которым из него выходят, это тупик — независимо от того, как он называется в коде.

Чего на этой модели строить нельзя

  • «Покажем клиенту свободные ячейки» по одному опросу. Пустой канал шкафа и распахнутая дверца неразличимы. Свободность — это журнал плюс конфигурация, а опрос лишь уточняет.
  • Автоопределение карты «ячейка ↔ канал». По той же причине. Карта приходит из панели, и заполняется она мастером привязки.
  • «Дверь закрылась» как результат команды. Команды закрытия не существует; закрытие видит только датчик.