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

Протокол платы замков UPUS-SKB27

Протокол снят эмпирически 2026-08-31 и перепроверен на живом 2026-09-01: все 27 каналов, контрольная сумма сошлась на 28 кадрах из 28. Документа от производителя не существует — в открытом доступе его нет, а вендорское приложение, которое им пользовалось, было стёрто до того, как проект начался.

Здесь — рабочая выжимка. Полная запись с историей поиска живёт в репозитории прошивки (docs/LOCK-PROTOCOL.md), готовая реализация — в packages/lock-protocol.

Перед первой командой прочитайте запреты

Четыре запрета — два из них про необратимую поломку железа. Кадры 9A и 9B сжигают катушку соленоида за десятки секунд, и плата об этом не сообщит.

Линия

Значение
Порт/dev/ttyS0 (узел fdd50000.serial, владелец system:system)
Скорость9600, 8N1, без управления потоком
ФизикаRS485, полудуплекс, задний клеммник корпуса
Адрес платы0x02 — ⚠️ не 1, вопреки DIP-переключателю. Найден развёрткой адресов
Каналов27, нумерация 1:1 с шелкографией CH01…CH27
Контрольная суммаXOR всех байтов, кроме последнего

Соседние порты, чтобы не перепутать: ttyS1 — это Bluetooth-UART, ttyS3 свободен, но сигнала платы замков там нет, /dev/ttyS2 в системе отсутствует вовсе.

Константы линии живут в packages/lock-protocol/src/protocols.ts: LOCK_PORT, LOCK_BAUD, LOCK_ADDRESS, LOCK_CHANNELS.

Открыть ячейку

8A <адрес> <канал> 11 <XOR> XOR = 0x8A ^ адрес ^ канал ^ 0x11
CH01 → 8A 02 01 11 98

Плата подтверждает приём эхом того же кадра. Эхо приходит только на кадр со сошедшейся суммой — с испорченной плата молчит вовсе. Значит это разбор, а не закольцовывание линии:

ПосланоОтвет
открытие канала 3, сумма вернаяэхо кадра
открытие канала 3, сумма невернаятишина
чтение всех, сумма вернаякадр состояния
чтение всех, сумма невернаятишина
произвольный мусортишина
Подтверждение не значит «дверь открылась»

Эхо говорит «команда принята и проверена». Физический результат знает только датчик: опросить состояние примерно через полсекунды после команды (импульс длится 357 мс). Код, который считает подтверждение доказательством открытия, будет ошибаться в поле — и именно там, где ошибку заметит клиент.

Импульс

Длительность задаёт сама плата, команда её не несёт: три измерения по звуку дали 357 / 358 / 357 мс. Это жёсткая константа прошивки платы, удлинить её нашей командой невозможно.

Отсюда правило, зашитое в LOCK_REOPEN_MIN_MS = 500: повторное открытие того же канала не слать чаще, чем раз в полсекунды, иначе команда придётся на ещё не завершившийся импульс. Соблюдает его LockBus, а не вызывающий код.

Команды «закрыть» не существует

И существовать не может. Защёлка пружинная, fail-secure: плата даёт импульс, язычок убирается, дальше дверцу закрывает человек рукой и язычок защёлкивается сам.

Следствие для кода: «закрыто» — это показание датчика, а не результат команды. Поля close в пресете протокола оставлены только на случай моторизованных замков другого вендора.

Прочитать состояние

80 <адрес> <канал> 33 <XOR> XOR = 0x80 ^ адрес ^ канал ^ 0x33

Ответ бывает двух видов:

поканальный (5 байт): 80 <адрес> <канал> <состояние> <XOR>
общий, канал 0 (7 байт): 80 <адрес> <кан.17-24> <кан.9-16> <кан.1-8> 33 <XOR>
Состояние в поканальном ответеСмысл
0x11язычок в покое → дверь закрыта
0x00язычок вдавлен → дверь открыта, либо на канале ничего нет

Общий кадр: три ловушки в одном месте

  1. Порядок байтов — от старших каналов к младшим. Контринтуитивно, поэтому сверено с поканальным опросом всех каналов.
  2. Полярность бита обратна полярности байта. В поканальном ответе 0x00 = открыто, а в общем кадре бит 1 = открыто, бит 0 = закрыто. Оба варианта встречались в старых документах и выглядели противоречием — противоречия нет, просто это разные кадры.
  3. Общий кадр покрывает только каналы 1–24. Три байта состояния — это 24 бита, а каналов 27. Каналы 25–27 читаются поканально, штатными пятибайтовыми кадрами (проверено на живом).

Формула бита канала N (1…24), целочисленное деление:

байт = frame[4 - (N-1)/8]
бит = (N-1) % 8 в бите 0 = закрыто, 1 = открыто

Практическое следствие: полная картина 27 каналов — это один общий кадр плюс три поканальных запроса, а не 27 обменов. Общий кадр стоит одного обмена вместо двадцати четырёх.

0x00 — это два состояния сразу

Ответ 0x00 означает одновременно «дверь открыта» и «на этом канале ничего нет». Различить их опросом невозможно — ответ платы байт в байт одинаковый. Плата сообщает состояние входа, а не наличие железа на нём.

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

Что из этого следует для архитектуры — состояние ячейки. Коротко: карта «ячейка ↔ канал» приходит из конфигурации и не может быть получена автоопределением.

Кадры открытия по каналам

Считать их в уме не нужно — это делает safeOpenFrame(channel). Таблица приведена, чтобы можно было сверить руками то, что ушло в порт.

ЯчейкаКадрЯчейкаКадрЯчейкаКадр
CH018A 02 01 11 98CH108A 02 0A 11 93CH198A 02 13 11 8A
CH028A 02 02 11 9BCH118A 02 0B 11 92CH208A 02 14 11 8D
CH038A 02 03 11 9ACH128A 02 0C 11 95CH218A 02 15 11 8C
CH048A 02 04 11 9DCH138A 02 0D 11 94CH228A 02 16 11 8F
CH058A 02 05 11 9CCH148A 02 0E 11 97CH238A 02 17 11 8E
CH068A 02 06 11 9FCH158A 02 0F 11 96CH248A 02 18 11 81
CH078A 02 07 11 9ECH168A 02 10 11 89CH258A 02 19 11 80
CH088A 02 08 11 91CH178A 02 11 11 88CH268A 02 1A 11 83
CH098A 02 09 11 90CH188A 02 12 11 8BCH278A 02 1B 11 82

Канала 0 в команде открытия в этой таблице нет намеренно: он предположительно означает «открыть все» и запрещён барьером — на собранном шкафу он выстрелил бы всеми катушками разом.

Что уже написано в коде

Ничего из перечисленного не нужно писать заново:

ЗадачаЧем решается
собрать кадр открытия / чтенияsafeOpenFrame(), safeStatusFrame()
проверить, что кадр вообще можно отправитьassertFrameAllowed()
разобрать один кадрparseReply()
снять кадр с начала буфераtakeReply()
собрать кадры из потока байтов с мусоромFrameReader
держать порт, очередь и паузыLockBus в apps/kiosk/src/hardware/

FrameReader добавляет к разбору единственную вещь — время. takeReply() при нехватке байтов честно отвечает «жду ещё», и это правильно, но 5–6 байт мусора без продолжения пролежали бы в буфере навсегда и испортили первый же законный ответ. Полудуплексный RS485 роняет такие огрызки в приёмник при каждом переключении направления. Порог простоя — 80 мс: на 9600 бод семибайтный кадр укладывается в 7 мс, то есть порог заведомо больше любой законной паузы внутри кадра и меньше паузы между обменами.

Что осталось непроверенным

Отмечено честно: ниже — не факты, а дырки, которые закроются только на собранном шкафу.

  • Реальный датчик на каналах 25–27. Плата на них отвечает — это факт. Но на стенде железа на этих каналах не было, все три отдали 0x00, а 0x00 неразличим.
  • Реакция на обрыв шлейфа датчика в отличие от «датчика нет вовсе». Полярность обещает, что обрыв прочтётся как «открыто», то есть безопасно, но это рассуждение, а не измерение.
  • Канал 0 в команде открытия — действительно ли «открыть все». Не проверяли и проверять не будем нигде, кроме шкафа, где известно, что висит на каждом канале.
  • Почему адрес 2 при DIP «1» — возможно, кодировка DIP+1. При замене платы адрес искать развёрткой 0x000x1F.