Я работаю в компании, которая разрабатывает и продаёт систему электронной очереди. Пришёл недавно, успел написать веб-клиент для их очереди — общение по SOAP, веб-сайт, талоны с QR-кодом, ничего особенного. Потом руководство поставило задачу: интегрировать физическое табло с вызовом клиентов через веб. Старое десктопное приложение как-то умеет зажигать на нём цифры — надо сделать то же самое, но из веба.

Окей, задача понятная. Первым делом пошёл к тем, кто делал это приложение.


Естественно я пошёл лично в отдел разработки и тестирования…

День 1: да-да, конечно, щас поищем…
День 3: да чет нету, давно было, ну ты потом подойди…
День 5: А тебе зачем?
День 7: Сеньор, не отрываясь от монитора: «Да там где-то в коде зашито глубоко, долго искать».

Итог — одна старая сервисная утилита вообще от монтажников под Windows, без исходников. Вместо того чтобы жаловаться руководству — взял да разобрался сам. С Клодом.


Ловим пакеты

Аппаратного анализатора нет, зато есть com0com — виртуальная пара COM-портов. Старая утилита пишет в один конец, RealTerm читает из другого и показывает HEX.

Нажимаю кнопку в утилите — в терминал вываливается поток байт. И первое, что бросается в глаза: каждый байт — строго либо 0x00, либо 0x7F. Никаких промежуточных значений вообще.

55 00 7F 00 00 00 7F 00 00 7F 00 00 00 00 00 00 00
7F 00 7F 00 00 00 00 00 7F 00 00 7F 00 7F 00 00

Сначала думал — битый поток или помехи на линии. Потом дошло.


BCD4: один бит — один байт

Причина оказалась простой: это аппаратный костыль для работы на длинных шинах с помехами. Один бит данных — один байт на линии. 0x7F — это семь единиц подряд — такой импульс никакая помеха не съест. 0x00 — ноль.

Схема кодирования: число от 0 до 15 раскладывается по битам, каждый бит превращается в байт.

Бит 0 → байт 0:  1 → 0x7F,  0 → 0x00
Бит 1 → байт 1:  1 → 0x7F,  0 → 0x00
Бит 2 → байт 2:  1 → 0x7F,  0 → 0x00
Бит 3 → байт 3:  1 → 0x7F,  0 → 0x00

Несколько примеров, чтобы была понятна логика:

Число

Биты

BCD4-байты

0

0000

00 00 00 00

1

0001

7F 00 00 00

2

0010

00 7F 00 00

5

0101

7F 00 7F 00

9

1001

7F 00 00 7F

12

1100

00 00 7F 7F

Цена вопроса — 4 байта на каждую цифру числа и черепашья скорость 1200 бод. Зато надёжно.

В Python это одна строчка:

def _bcd4(value: int) -> bytes:
    return bytes([0x7F if value & (1 << i) else 0x00 for i in range(4)])

Проверяем:

>>> _bcd4(5).hex(' ')
'7f 00 7f 00'   # 0b0101 — правильно
>>> _bcd4(9).hex(' ')
'7f 00 00 7f'   # 0b1001 — правильно

Структура пакета

Разобравшись с кодированием, стал смотреть на структуру целиком. Пакет для вывода числа на конкретное окно — 33 байта:

Байт  0     : 0x55              — стартовый маркер
Байты 1–4   : заголовок режима  — определяет тип команды
Байты 5–8   : BCD4(D1)          — единицы числа
Байты 9–12  : BCD4(D2)          — десятки числа
Байты 13–16 : BCD4(D3)          — сотни числа
Байты 17–20 : BCD4(W_lo)        — единицы номера окна
Байты 21–24 : BCD4(W_hi)        — десятки номера окна
Байты 25–28 : BCD4(c1)          — контрольное поле 1
Байты 29–32 : BCD4(c2)          — контрольное поле 2

Например, число 487 на окне 19: D1=7, D2=8, D3=4, W_lo=9, W_hi=1.

55 00 7F 00 00          ← заголовок
7F 7F 7F 00             ← BCD4(7) = D1 (единицы)
00 00 00 7F             ← BCD4(8) = D2 (десятки)
00 00 7F 00             ← BCD4(4) = D3 (сотни)
7F 00 00 7F             ← BCD4(9) = W_lo
7F 00 00 00             ← BCD4(1) = W_hi
[8 байт контрольных полей]

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


Контрольная сумма, которая не хотела считаться

Вот здесь и начался настоящий реверс.

Простое сложение — не подходит. XOR — не подходит. При числах вроде 80 или 90 контрольные байты скачкообразно менялись — явно был какой-то перенос между полями. Причём не всегда: на маленьких числах формула работала, а на больших — ломалась.

Вот здесь я и позвал Клода. Скинул ему ~30 перехваченных пакетов, объяснил структуру, попросил найти зависимость.

Ключевым оказалось правильно сформулировать задачу: не «найди формулу контрольной суммы», а «у меня есть два зависимых поля c1 и c2, которые, судя по поведению, образуют единое 8-битное значение с переносом между старшим и младшим nibble — найди зависимость от остальных полей пакета». После этого дело пошло.

Несколько ночей методичного тыкания: то константа не та, то перенос считался не туда, то формула работала на маленьких числах и ломалась на 80+. В какой-то момент я уже начал подозревать, что там вообще нет никакой логики и разработчики просто захардкодили таблицу.

Но в итоге добили. Механика оказалась следующей: два контрольных 4-битных поля на самом деле являются двумя половинками одного 8-битного значения. Когда младшая половина переполняется (выходит за 4 бита), единица переноса уходит в старшую — точно как в сложении столбиком:

  c1_raw = f(поля пакета)    # некая линейная комбинация
  ───────────────────────
  если c1_raw >= 16:
      carry = 1              # перенос в старший nibble
      c1 = c1_raw - 16
  иначе:
      carry = 0
      c1 = c1_raw

  c2 = g(другие поля, carry)  # carry участвует здесь

Перенос возникает не всегда — только при определённых комбинациях номера окна и числа. Именно поэтому без него формулы работали на простых тестах и ломались на реальных данных. Вот почему «число 80 на окне 5» давало неожиданный результат — а «число 12 на том же окне 5» — правильный.

Режим «все окна» (25 байт, без поля окна) оказался проще — там контрольная сумма устроена иначе и переноса нет.


Режим программирования окна

Режим установки номера окна оказался ещё интереснее: другой заголовок, другая длина пакета (тоже 33 байта, но с иным заголовком и без полей числа), и в формулах — умножение и несколько магических констант. Два независимых carry-вычисления для двух пар контрольных полей.

# Общая идея двух независимых carry-цепочек:

  цепочка A:
      raw_A = линейная_функция(W_lo)
      carry_A = raw_A >> 4
      field_A1 = raw_A & 0xF
      field_A2 = линейная_функция(W_hi, carry_A) & 0xF

  цепочка B:
      raw_B = другая_линейная_функция(W_lo)
      carry_B = raw_B >> 4
      field_B1 = raw_B & 0xF
      field_B2 = другая_линейная_функция(W_hi, carry_B) & 0xF

Когда Клод наконец вывел правильную формулу с доказательством на всех 30 перехваченных пакетах — это было маленькое торжество. Проверил на окне 1, на окне 19, на окне 30 — всё сошлось до байта.


Сборка пакета

Когда все поля известны, сборка — дело нескольких строк:

def build_window_packet(window: int, number: int) -> bytes:
    d1 = number % 10
    d2 = (number // 10) % 10
    d3 = (number // 100) % 10
    w_lo = window % 10
    w_hi = window // 10

    # Контрольные поля — результат реверса (подробности скрыты)
    c1, c2 = _checksum(d1, d2, d3, w_lo, w_hi)

    packet = bytearray(b'\x55\x00\x7F\x00\x00')   # заголовок режима
    packet += _bcd4(d1) + _bcd4(d2) + _bcd4(d3)   # цифры числа
    packet += _bcd4(w_lo) + _bcd4(w_hi)            # номер окна
    packet += _bcd4(c1) + _bcd4(c2)                # контроль

    assert len(packet) == 33
    return bytes(packet)

Структура прозрачная: раскладываем число и окно по цифрам, кодируем каждую через BCD4, добавляем контрольные поля — и 33 байта готовы к отправке в COM-порт.


Финальная архитектура

Всё это завернул в Python-сервер, который висит в трее и принимает простые HTTP-запросы. Веб-система вызывает клиента — на табло загорается номер. Ровно то, что делало старое десктопное приложение, только без него.

HTTP-интерфейс намеренно простой:

POST /
Content-Type: application/json

{"window": 5, "ticket": "487"}

Сервер разбирает запрос, строит пакет, отправляет в COM-порт. Всё. Веб-клиент даже не знает, что где-то есть RS-232 на 1200 бод.

Для отказоустойчивости добавил heartbeat-поток: сервер периодически регистрируется на центральном сервере очереди, и если связь пропадает — автоматически восстанавливает её, не требуя перезапуска:

_HEARTBEAT_OK   = 60   # секунд между проверками при наличии связи
_HEARTBEAT_FAIL = 10   # секунд между попытками при потере связи

def _heartbeat_loop():
    while True:
        ok = _try_register(...)
        was = _server_state['connected']
        _server_state['connected'] = ok
        if not was and ok:
            print("[OK] Соединение восстановлено")
        elif was and not ok:
            print("[WARN] Соединение потеряно, пытаюсь восстановить...")
        time.sleep(_HEARTBEAT_OK if ok else _HEARTBEAT_FAIL)

Итог

Задача решена за несколько бессонных ночей.

Из практического: ИИ хорошо справляется с поиском математических закономерностей, если дать ему достаточно примеров и правильно объяснить структуру. Важнее всего — правильно сформулировать гипотезу: не «найди формулу», а «вот механизм, который я подозреваю — проверь и уточни». Скармливаешь данные, описываешь что именно подозреваешь, — и получаешь формулу с доказательством на всех примерах.

Из жизненного: иногда проще потратить два вечера на реверс, чем неделю на переписку в корпоративном чате.


P.S. Если кто-то по описанию протокола — BCD4, 1200 бод, carry-арифметика в контрольной сумме — узнаёт производителя этих табло, напишите в комментарии. Так и не выяснил, чьё именно железо.