33 KiB
Интеграция универсального контроллера 2.3.5
Источник
Реализация основана на локальной спецификации references/docs/periphery_controller.md, версия 2.3.5. Каталог references/ не входит в Git; этот документ фиксирует только принятые для приложения технические решения и не заменяет оригинальную документацию производителя.
Физический транспорт
- USB-COM/UART,
115200, 8 data bits, parityNone, 1 stop bit, flow controlNone. - ASCII-кадры завершаются
CR,LFлибо паройCRLF. - Минимальный кадр — два символа.
- Незавершённый кадр отбрасывается через 100 мс.
- Максимальная длина входного кадра ограничена настройкой, по умолчанию 1024 байта. После переполнения хвост кадра игнорируется до следующего разделителя.
- Исходящие команды завершаются
CRLFи записываются последовательно одним worker-потоком.
SerialPortHardware не вызывает Godot API и пользовательские события из фонового потока. Worker складывает кадры и изменения соединения в потокобезопасную очередь; App._Process() доставляет их в главный поток через ProcessPendingEvents().
Авторизация
Поддержан строгий startup-handshake, совпадающий с актуальным plate_config_app:
open → RTS reset → periphery controller ver_<version> → wait 2000 ms
→ 07<host challenge> → CHAL<controller challenge> → 08<encrypted CHAL>
→ RAES<host-challenge proof> → [AUTH_PC_OK/AUTH_CNT_OK] → READY!
Сразу после открытия SerialPortHardware очищает буферы, устанавливает DTR в false, удерживает RTS в true 100 мс и возвращает RTS в false. До отправки 07 приложение требует регистрозависимый префикс periphery controller ver_ и непустую версию, затем выдерживает 2000 мс. Challenge и криптографические ответы содержат по 16 байт/32 HEX-символа в верхнем регистре. Разбор входящих CHAL и RAES, как в plate_config_app, принимает слитный payload, а также необязательный пробел или двоеточие после префикса. После CHAL приложение отправляет 08, затем в постоянном времени проверяет, что RAES равен AES-ответу для исходного host challenge. Даже корректный RAES ещё не разрешает рабочие команды: завершением авторизации является только точный, полностью очищенный кадр READY!. AUTH_PC_OK и AUTH_CNT_OK распознаются как промежуточные подтверждения; READY, ready! и любые варианты с другим регистром не принимаются.
Таймауты отдельных стадий не блокируют главный поток:
- стартовый баннер ожидается до 20 секунд;
- после баннера всегда выдерживается 2000 мс;
HandshakeTimeoutMillisecondsзадаёт отдельный таймаут ожиданияCHALиRAES(по умолчанию 2000 мс, как в актуальномplate_config_app; диапазон 1000–60000 мс);- точный
READY!послеRAESожидается до 5 секунд; - таймаут любой стадии переводит авторизацию в
Failed, закрывает текущий порт и оставляет reconnect безопасно начать весь процесс заново; - disconnect сбрасывает автомат и отменяет текущее ожидание.
Для диагностики сохраняется только тип последнего ответа (CHAL, RAES, AUTH_CNT_OK), но не секретный payload.
Владелец проекта предоставил 32-байтовое обфусцированное представление ключа:
6940C70EF2C7457072CD2858998F73A85F5FEDB4C162B424ACD7A3D6D80B5037
Оно зафиксировано в PeripheryControllerSecurity.AesEncryptedKey. Перед использованием каждый байт восстанавливается операцией storedByte XOR 0x37, как в рабочем Unity-проекте. В документации и логах эффективный ключ не раскрывается. Переменная окружения EXCHANGER_CONTROLLER_AES_KEY может переопределить встроенное значение для другой ревизии платы и должна содержать уже эффективный 32-байтовый ключ в HEX — XOR к override повторно не применяется.
Владелец проекта предоставил рабочую функцию шифрования и подтвердил профиль:
aes.Key— 32-байтовый ключ контроллера;CipherMode.ECB;PaddingMode.None;CreateEncryptor().TransformFinalBlock(data, 0, data.Length);- каждый handshake-блок содержит ровно 16 байт; IV для ECB не используется.
Aes256EcbBlockCipher реализует тот же алгоритм. ControllerAesMode: "ECB" теперь установлен по умолчанию, а пользовательская конфигурация версий до 6 автоматически мигрирует на подтверждённый профиль.
Контрольный пример спецификации теперь воспроизводится точно: challenge 8C704E9C4CBF4BA2255741C566FB0F7A после восстановления ключа шифруется в 99ED731648AB8C1709CC4C3C8C3BB0E1. Тот же алгоритм подтвердил реальный случайный handshake на COM3.
Платёжные команды
| Действие приложения | Команды/события протокола |
|---|---|
| Начать наличную сессию | 141, затем 151 |
| Остановить наличную сессию | 142, 152 |
| Принять сумму монет | MONEY:COINS:zzz, немедленно ASK:COINS:zzz |
| Принять купюру/сумму | MONEY:BILL:zzz, немедленно ASK:BILL:zzz |
| Начать безналичную оплату | 161, затем 163<amount> |
| Поддержать Вендист активным | 161 каждые 120 секунд в idle/card-состоянии; прошивка требует интервал меньше 180 секунд |
| Подтвердить безналичную сумму | MONEY:CASHLESS:zzz, немедленно ASK:CASHLESS:zzz |
| Отменить безналичную оплату | 162 |
Привязка пользовательских действий
Команды привязаны не к визуальному оформлению темы, а к типизированным действиям SessionFlow.
Поэтому внешний DLC может полностью заменять сцены, не меняя аппаратный сценарий.
| Действие покупателя или приложения | Последовательность на проводе |
|---|---|
| Нажать «Наличные» на Home | 162 (гарантированно выключить Вендист), 141 (smart-монетник), 151 (разрешить купюры) |
| Внести монеты | плата: MONEY:COINS:<сумма>; ПК немедленно: ASK:COINS:<сумма>; только затем сумма прибавляется к текущему балансу |
| Внести купюру | плата: MONEY:BILL:<сумма>; ПК немедленно: ASK:BILL:<сумма>; только затем сумма прибавляется к текущему балансу |
| Нажать «Выдать» после наличной оплаты | 142, 152, 162, затем последовательность 309N/409N по выбранному плану двух хопперов; N в каждом пакете 1–99, а каждый HOPPER1/2 OK подтверждает один фактический жетон |
| Нажать «Безналичные» на Home | Аппаратная команда пока не отправляется: покупатель сначала безопасно выбирает сумму |
| Подтвердить карточную сумму | 142, 152, 161, 163<сумма> |
| Успешная карточная оплата | плата: MONEY:CASHLESS:<сумма>; ПК: ASK:CASHLESS:<сумма>, затем 162; выдача начинается только при точном совпадении с суммой 163 |
| Отмена или таймаут на экране терминала | 162; команда выдачи не отправляется |
| Назад/таймаут во время наличной оплаты | 142, 152; команда выдачи не отправляется |
| Потеря связи | все активные платёжные состояния сбрасываются; после reconnect команды оплаты и выдачи автоматически не повторяются |
Платёжные каналы взаимоисключающие. Перед запуском наличной ветки приложение выключает
Вендист, а перед 161/163 выключает монетник и купюроприёмник. Повторная отмена идемпотентна и
не добавляет одинаковые команды в FIFO-очередь платы.
Повторное MONEY:CASHLESS после первого подтверждённого результата подтверждается контроллеру, но не применяется к уже завершённой card-сессии. Сумма должна точно совпасть с запросом 163; несовпадение не считается успешной оплатой.
Событие COIN без номинала всегда подтверждается командой 10, но не зачисляется: приложение после успешного RAES включает smart-режим и ожидает MONEY:COINS:zzz.
Команды status-запросов 140, 150, 160 и 99 представлены типизированными константами. Форматы ответов производителем не заданы, поэтому приложение не пытается интерпретировать произвольный developer-текст как рабочее состояние.
Выдача и запас жетонов
В протоколе есть две подтверждённые группы хопперов: 30X для первого и 40X для второго. Целевой production-профиль DualHopperAuto использует оба канала. Для подтверждения выдачи и пересчёта приложение читает строки HOPPER1/2 OK, CREDIT и PULSE, поэтому физический вход импульсного датчика второго хоппера не требуется.
Безопасное поведение универсальной конфигурации по умолчанию:
DispenseMode: "Unsupported";- приём наличных и безналичной оплаты блокируется до наличия безопасного способа выдачи;
- приложение не отправляет предполагаемую команду силового выхода;
- аппаратный контроллер не сообщает точное число жетонов; отдельный учётный остаток владельца хранится приложением и не подменяет бинарное показание датчика.
Для подключённого автомата первый хоппер использует датчик малого остатка IN1 и команды 30X, второй — датчик IN4 и команды 40X. Конфигурация версии 18 мигрирует ранее выбранный одноканальный профиль Hopper1Auto/Hopper2Auto в DualHopperAuto, сохраняет сервисную кнопку на IN8 и active-low полярность обоих датчиков уровня. Репозиторный default остаётся Unsupported: перенос приложения на другой автомат не должен самовольно запускать мотор.
Каждый хоппер имеет бинарный датчик уровня: Enough или Low. Точного числа жетонов датчики не сообщают. Двухканальная аппаратная граница передаёт Hopper1StockLevel и Hopper2StockLevel, а устаревшее агрегированное MachineStockLevel сохранено только для совместимости:
Enough— датчик сообщает «много»;Low— датчик сообщает «мало»;Unknown— состояние ещё не получено, вход не настроен или связь потеряна.
Точные учётные остатки хранятся отдельно для каждого хоппера в user://data/token_inventory.json, начинаются с нуля, меняются раздельным ручным пополнением или пересчётом и списываются по фактически подтверждённому каналу выдачи. Покупателю и владельцу показывается только приблизительный общий итог: <100 для 1–99, шаг 100 до 1000 включительно и шаг 500 выше 1000.
Адаптер разбирает фактический ответ INPUT<bitmask>; для совместимости принимается и INPUT <bitmask>. Подтверждённая разводка production-профиля:
| Вход | Электрическая семантика | Действие приложения |
|---|---|---|
IN8 |
pull-up, 0 при нажатии |
Один active-low фронт публикует ServiceRequested: из свободного Home настройки открываются напрямую при выключенной защите либо через PIN при включённой; следующий фронт в Settings завершает автосохранение и возвращает Home. Удержание не создаёт повторов. Кадр SERVICE используется как fallback только когда отдельный сервисный вход не настроен. |
IN1 |
active-low датчик запаса первого хоппера | активный сигнал (бит 0) → Low, отсутствие сигнала (бит 1) → Enough; повтор одинакового снимка не публикует лишнее изменение. |
IN24 |
физический датчик выдачи первого хоппера | Сохранён в конфигурации и доступен для диагностики. Runtime не считает его периодические INPUT-снимки: обычную выдачу подтверждает HOPPER1 OK, прямой пересчёт — HOPPER1 PULSE=1. |
IN4 |
active-low датчик запаса второго хоппера | активный сигнал (бит 0) → Low, отсутствие сигнала (бит 1) → Enough; участвует в общей четырёхуровневой индикации. |
??? |
физический датчик выдачи второго хоппера | Вход не требуется runtime: обычную выдачу подтверждает HOPPER2 OK, прямой пересчёт — HOPPER2 PULSE=1. |
Индексы и полярности остаются переопределяемыми во внешней технической конфигурации:
ServiceButtonInputIndex/ServiceButtonActiveHigh;StockSensorInputIndex/StockLowWhenInputHighдля первого хоппера;Hopper2StockSensorInputIndex/Hopper2StockLowWhenInputHighдля второго;Hopper1DispenseSensorInputIndex/Hopper1DispenseSensorActiveHigh;InputPollMilliseconds— резервный опрос быстрых входов, по умолчанию 100 мс;StockPollSeconds— старый медленный интервал, используемый только если кнопка и импульсный датчик отключены значением-1.
Плата также может присылать INPUT асинхронно. Команда 91 используется как резервный опрос, чтобы состояние не зависело только от доставки событий.
Общая индикация объединяет учётный итог и два датчика: нулевой остаток — Empty; оба Enough — Much; остаток меньше 100 или оба Low — Low; остаток от 100 либо хотя бы один Enough — Enough. Покупатель видит соответственно «нет жетонов», «мало», «достаточно» или «много» и заранее подготовленную темой полоску 0%, красную 25%, красную 50% или зелёную 100%.
В целевом профиле DualHopperAuto обычная покупка формирует безопасный план по точным учётным остаткам двух хопперов и отправляет 309N либо 409N. За один пакет передаётся 1–99 жетонов; большие выдачи разбиваются последовательно. 309100 и 409100 возвращают соответственно ERROR:30:invalid count (0-99) и ERROR:40:invalid count (0-99). Значение 0 зарезервировано для запросов 3090/4090 текущего CREDIT. Ответы обоих каналов симметричны:
HOPPER1/2 APPEND=N— N кредитов добавлено в очередь; это ещё не выдача;HOPPER1/2 OK— фактически выдан один жетон;HOPPER1/2 ERROR— выбранный хоппер не смог продолжить текущую очередь;HOPPER1/2 CREDIT=N— осталось выдать N кредитов;3090/4090— запрос текущегоCREDIT.
После ERROR адаптер ждёт CREDIT до 3000 мс, вычисляет частичный результат и завершает только текущую операцию. Новая прошивка больше не защёлкивает хоппер в ошибке до перезагрузки, поэтому следующая явная операция владельца или покупателя разрешена. Автоматического retry всё равно нет: повтор без решения вызывающего кода мог бы выдать жетоны дважды.
Сервисный автоматический пересчёт — отдельный взаимоисключающий режим:
- выбранный хоппер включается
301либо401и обязательно выключается парной командой300либо400; - каждый физически прошедший жетон плата сообщает строкой
HOPPER1 PULSE=1либоHOPPER2 PULSE=1; - приложение считает только строки активного хоппера и не опрашивает физические импульсные входы, поэтому события не теряются и не дублируются;
- каждый
PULSE=1заново запускает интервал ожидания; 10 секунд тишины означают конец пересчёта; HOPPER1/2 OKв режиме прямого мотора не увеличивает счётчик;- отмена, закрытие приложения и потеря связи завершают операцию безопасно; при доступной связи сначала отправляется команда выключения двигателя.
На границе протокольного адаптера действует at-most-once-политика:
- пока предыдущая выдача не завершена, повторный вызов
DispenseTokensне отправляет вторую команду; - после обрыва связи команда автоматически не повторяется, а результат остаётся неизвестным;
HOPPER1/2 ERROR/CREDITзавершает активную выдачу с уточнённым partial-result, но не блокирует следующую команду;- обратная связь неактивного в текущем пакете хоппера игнорируется и не может завершить чужую выдачу или пересчёт.
Wire-симулятор
PeripheryControllerSimulator реализует IHardwarePort и детерминированно воспроизводит документированную часть протокола без COM-порта:
- полный double handshake через тот же
ControllerAuthenticatorи cipher; INPUT<bitmask>(также принимается вариант с пробелом);MONEY:COINS/BILL/CASHLESSи обязательныеASK:*;- успешную и частично неуспешную выдачу
309N → APPEND/OKлибоERROR/CREDIT; - наличную и безналичную ветки целиком, включая взаимоисключение приёмников, отмену и пакетную выдачу количества больше 99;
SERVICE, malformed/unknown кадры и disconnect.
Симулятор не придумывает status/heartbeat-ответы и production-команду силового канала. Он используется интеграционными тестами совместно с настоящими PeripheryProtocolParser, PeripheryMachineController и SessionFlow.
Потеря связи и повторное подключение
- При исчезновении порта активная операция получает
ConnectionLost; во время выдачиSessionFlowпереводит её вDispenseStatusUnknown. DISPENSEавтоматически не повторяется.- Приложение пытается повторно открыть COM-порт с настраиваемым интервалом, по умолчанию 5 секунд.
- После каждого подключения аппаратный reset, startup-handshake и безопасная инициализация выполняются заново.
- Только после точного
READY!монетник переводится в smart-режим, а все платёжные приёмники сначала отключаются.
Сервисное состояние соединения
PeripheryMachineController публикует типизированное состояние Disconnected, Authenticating, Ready или Faulted. Оно не содержит имени COM-порта, сырых кадров, AES-данных и текста внутренних исключений.
- при физическом подключении состояние переходит в
Authenticating; - только после стартового баннера, криптографической проверки
RAESи точногоREADY!публикуетсяReady; - штатный обрыв переводит состояние в
Disconnected, а запас — вUnknown; - ошибка конфигурации, криптографической проверки или таймаут handshake переводят состояние в
Faulted; - в production индикатор скрыт при
Ready, но остаётся видимым при авторизации, обрыве и ошибке; в debug отображается и штатное состояние.
Автоматический тест выполняет 200 циклов подключения, авторизации и отключения во время незавершённой выдачи. Он подтверждает, что reconnect не повторяет команду выдачи, отложенный ответ хоппера не завершает оборванную операцию, а последовательность состояний остаётся ровно одной на цикл. Проверка реального worker-потока при многократном отключении USB-кабеля остаётся обязательной аппаратной приёмкой.
Фактическая проверка платы 2026-08-24
Подключённый контроллер определился как Silicon Labs CP210x USB to UART Bridge на COM3.
Безопасный прогон использовал 115200 8N1/None и не запускал оплату, RESET, силовые выходы или хоппер.
Ниже сохранён исторический результат до синхронизации startup-фазы с plate_config_app. Он подтвердил ключ и доказательство RAES, но больше не является достаточным условием готовности production-адаптера:
- ПК отправляет
07с новым случайным 16-байтовым блоком. - Плата возвращает корректно сформированный
CHAL. - ПК отправляет
08 = AES-256-ECB-Encrypt(CHAL)с ключом приложения иPadding=None. - Плата возвращает корректно сформированный
RAES. - После восстановления ключа через XOR
0x37RAESточно совпадает сAES-256-ECB-Encrypt(07). - На тот момент отсутствие
AUTH_CNT_OKтрактовалось как допустимое, а адаптер переходил вReadyпослеRAES. Это поведение отменено: актуальная реализация требует стартовый баннер, паузу 2000 мс и точный завершающий кадрREADY!. - После привязки входов безопасная группа повторно прошла 9/9 2026-08-27: цифровой вход
91, преобразованиеIN1вEnough/Low, status-запросы140,150,160,309?,99и три цикла повторной авторизации. Во время последнего прогона активныйIN1корректно далLow. Все status-команды получили непустые неошибочные кадры; их недокументированное содержимое намеренно не сохраняется. Один краткий отказ открытия COM3 при быстром переоткрытии был автоматически восстановлен предусмотренным retry.
Повторная проверка строгой авторизации 2026-08-31
После синхронизации startup-последовательности с актуальным plate_config_app плата на COM3
успешно прошла отдельный безопасный auth-probe 1/1. Физически подтверждены аппаратный RTS-reset,
точный startup-баннер, пауза 2000 мс, CHAL, криптографически верный RAES и завершающий
READY!. Production-адаптер перешёл в Ready только после последнего кадра.
На предыдущей версии прошивки группа Safe прошла 8/9: 91, active-low IN1, 140, 150, старый запрос состояния хоппера, 99 и три
цикла повторной авторизации были успешны. Команда 160 в двух независимых запусках не получила
ответа. Это отдельная несовместимость status-запроса/подключения Вендиста и не является ошибкой
startup-handshake. Моторы, выдача жетонов, приём наличных и реальная безналичная операция не
запускались.
Диагностический probe не сохраняет сырые challenge/proof: в TRX остаются только категории, булевы результаты сравнений и короткие SHA-256 fingerprints. Обход криптографической проверки не используется. Ни оплата, ни выдача в безопасном прогоне не запускались.
Вопросы для последующей проверки на целевом оборудовании
- После подключения платы физически проверить из приложения одну и несколько выдач первого хоппера через
309N, включаяHOPPER1 APPEND=N, число кадровHOPPER1 OKи фактических жетонов. - Подтвердить порядок
HOPPER1 ERROR → HOPPER1 CREDIT=N, ответ на3090и возможность следующей явной выдачи без перезапуска платы. - Подтвердить сервисный пересчёт
301 → HOPPER1 PULSE=1… → 300и окончание по 10 секундам без импульса. - Уточнить,
MONEY:BILL/COINS:zzz— дельта одного события или накопительный итог сессии. - Описать сообщения отказа/отмены/таймаута Вендиста, если они существуют.
- Расшифровать точные форматы ответов на status-команды
140,150,160,3090,99и heartbeat. - После исправления прошивки второго хоппера получить вход его датчика выдачи и отдельно специфицировать
40X; до этого приложение не отправляет такие команды.
Эти вопросы не оставляют незавершённого кода внутри документированного профиля Этапа 3: неподтверждённые возможности представлены безопасными состояниями Unsupported/Unknown. Они блокируют только включение production-профиля на физическом автомате.
Для проверки реальной реализации этих ответов подготовлен отдельный аппаратный набор: BOARD_ACCEPTANCE_TESTS.md. Он компилируется вместе с solution, но открывает COM-порт и активирует периферию только при явных флагах запуска.