exchanger/docs/hardware/PERIPHERY_CONTROLLER_2_3_5.md

33 KiB
Raw Permalink Blame History

Интеграция универсального контроллера 2.3.5

Источник

Реализация основана на локальной спецификации references/docs/periphery_controller.md, версия 2.3.5. Каталог references/ не входит в Git; этот документ фиксирует только принятые для приложения технические решения и не заменяет оригинальную документацию производителя.

Физический транспорт

  • USB-COM/UART, 115200, 8 data bits, parity None, 1 stop bit, flow control None.
  • 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-адаптера:

  1. ПК отправляет 07 с новым случайным 16-байтовым блоком.
  2. Плата возвращает корректно сформированный CHAL.
  3. ПК отправляет 08 = AES-256-ECB-Encrypt(CHAL) с ключом приложения и Padding=None.
  4. Плата возвращает корректно сформированный RAES.
  5. После восстановления ключа через XOR 0x37 RAES точно совпадает с AES-256-ECB-Encrypt(07).
  6. На тот момент отсутствие AUTH_CNT_OK трактовалось как допустимое, а адаптер переходил в Ready после RAES. Это поведение отменено: актуальная реализация требует стартовый баннер, паузу 2000 мс и точный завершающий кадр READY!.
  7. После привязки входов безопасная группа повторно прошла 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. Обход криптографической проверки не используется. Ни оплата, ни выдача в безопасном прогоне не запускались.

Вопросы для последующей проверки на целевом оборудовании

  1. После подключения платы физически проверить из приложения одну и несколько выдач первого хоппера через 309N, включая HOPPER1 APPEND=N, число кадров HOPPER1 OK и фактических жетонов.
  2. Подтвердить порядок HOPPER1 ERROR → HOPPER1 CREDIT=N, ответ на 3090 и возможность следующей явной выдачи без перезапуска платы.
  3. Подтвердить сервисный пересчёт 301 → HOPPER1 PULSE=1… → 300 и окончание по 10 секундам без импульса.
  4. Уточнить, MONEY:BILL/COINS:zzz — дельта одного события или накопительный итог сессии.
  5. Описать сообщения отказа/отмены/таймаута Вендиста, если они существуют.
  6. Расшифровать точные форматы ответов на status-команды 140, 150, 160, 3090, 99 и heartbeat.
  7. После исправления прошивки второго хоппера получить вход его датчика выдачи и отдельно специфицировать 40X; до этого приложение не отправляет такие команды.

Эти вопросы не оставляют незавершённого кода внутри документированного профиля Этапа 3: неподтверждённые возможности представлены безопасными состояниями Unsupported/Unknown. Они блокируют только включение production-профиля на физическом автомате.

Для проверки реальной реализации этих ответов подготовлен отдельный аппаратный набор: BOARD_ACCEPTANCE_TESTS.md. Он компилируется вместе с solution, но открывает COM-порт и активирует периферию только при явных флагах запуска.