# Протокол стенда «Хакатон Радиошторм» для участников (IQ v1) Документ описывает внешний интерфейс стенда: как модем команды подключается, в каком виде передаётся сигнал, как сдаётся результат и какие ошибки бывают. Внутреннее устройство модема (модуляция, кадры, коды, синхронизация, адаптация) команда выбирает сама. Числа из раздела 2 — константы протокола v1. После публикации участникам они не меняются. --- ## 1. Общая схема ``` GET /input (только A) PUT /result (только B) ┌──────────┐ ┌──────────┐ │ Узел A │ ──IQ──▶ [ канал A→B: задержка, затухание, ... ] ──IQ──▶ │ Узел B │ │ │ ◀──IQ── [ канал B→A: свои параметры ] ◀──IQ── │ │ └──────────┘ стенд, одно WebSocket-соединение на узел └──────────┘ ``` - Оба узла — программы **одной команды**. Удобно собрать их в один Docker-образ и запускать дважды: `SIGNAL_ROLE=A` и `SIGNAL_ROLE=B`. - Обмен информацией между A и B допускается **только через радиоканал стенда**. - Стенд не знает ваш протокол и ничего не расшифровывает. Он только искажает поток комплексных отсчётов. - Текущие параметры канала и момент смены условий стенд **не сообщает**. Модем обнаруживает их сам по сигналу. ## 2. Формат сигнала | Параметр | Значение | |---|---| | Частота дискретизации Fs | **200 000** комплексных отсчётов/с | | Отсчётов в блоке | **4096** | | Длительность блока | 4096 / 200 000 = **20,48 мс** | | Формат отсчёта | I и Q — `float32` little-endian | | Порядок | I0, Q0, I1, Q1, …, I4095, Q4095 | | Размер блока | **32 768 байт** (ровно) | | Диапазон I и Q | от −1,0 до +1,0 | | NaN / Inf | запрещены | | Рабочая полоса | −80…+80 кГц относительно нуля | | Рекомендуемая ширина сигнала | не более 80 кГц | Упаковка на Python/NumPy: ```python buf = np.empty(4096 * 2, dtype="/ws/v1/sessions/{session_id}/nodes/{A|B}?token=<токен узла>`. Вместо `?token=` можно передать заголовок `Authorization: Bearer <токен>`. - На узел — **одно** соединение, полнодуплексное. Всё, что узел шлёт бинарными сообщениями, — его передаваемый сигнал. Всё, что получает бинарными сообщениями, — сигнал с выхода встречного канала. - **Сжатие (permessage-deflate) стенд не использует**: IQ-данные не сжимаются, а процессор тратится впустую. - Максимальный размер сообщения — 1 МБ. Бинарные сообщения обязаны быть ровно 32 768 байт. ### Сообщения стенда (текст, JSON) ```json {"type":"hello","protocol":"v1","session_id":"7d1c…","node":"A","sample_rate":200000, "frame_samples":4096,"frame_bytes":32768,"sample_format":"float32_le_iq_interleaved","component_range":[-1.0,1.0]} {"type":"start","duration_s":90.0,"duration_samples":18000000,"total_frames":4395} {"type":"stop","reason":"session_finished"} {"type":"error","code":"bad_frame_size","message":"…","fatal":false} ``` - `total_frames` — сколько блоков стенд пришлёт и сколько примет. Если длительность не кратна блоку, в последнем блоке после конца сессии идут нули (90 с = 4394 полных блока + 2176 отсчётов). - `stop.reason`: `session_finished`, `all_nodes_disconnected`, `stopped_by_admin`, `stand_stalled` (сбой стенда, сессия будет перезапущена). ### Сообщения узла - `{"type":"ready"}` — один раз после `hello`, когда модем готов. - `{"type":"ping"}` — необязательно, ответ `{"type":"pong"}`. - Бинарные блоки по 32 768 байт — только между `start` и `stop`. ### Темп и очередь - Стенд выдаёт блоки **строго по часам**: один блок каждые 20,48 мс в каждую сторону. - Если к такту от узла нет блока, в канал идут **нули**. Шум и помеха на выходе всё равно есть. Для узла, который уже начал передавать, такой такт — это `underrun`. - Узел может присылать блоки **чуть заранее**: очередь стенда — **10 блоков** (≈205 мс). Лишние блоки **отбрасываются** (`overrun`), и данные теряются. - **Надёжная схема — «пришёл блок, отправил блок».** После `start` отправьте 2–4 блока запаса, затем на каждый принятый блок отправляйте один свой. Так темп задают часы стенда, и очередь не переполнится и не опустеет. - Принимать поток нужно в реальном времени. Если узел не читает блоки больше ~5 с (250 блоков), стенд отключит его (`receiver_too_slow`, код 4429). ### Коды закрытия соединения | Код | Причина | |---|---| | 1000 | нормальное завершение после `stop` | | 4400 | `too_many_violations` — больше 50 нарушений протокола | | 4401 | `unauthorized` — нет токена или токен не от этой сессии | | 4403 | `wrong_role` — токен B на адресе узла A или наоборот | | 4404 | `session_not_found` / `bad_role` | | 4408 | `ready_timeout` — нет `ready` за 60 с | | 4409 | `already_connected`, `session_not_accepting`, `reconnect_not_allowed` | | 4429 | `receiver_too_slow` | ### Предупреждения (не разрывают соединение) `{"type":"error","fatal":false,"code":…}`: | Код | Когда | |---|---| | `bad_frame_size` | бинарное сообщение не 32 768 байт — блок отброшен | | `nan_or_inf` | в блоке NaN/Inf — такие отсчёты заменены нулями | | `frame_outside_running` | блок прислан до `start` — отброшен | | `duplicate_ready`, `unexpected_ready` | лишний `ready` | | `unknown_message` | непонятное текстовое сообщение | ### Переподключение - **До `start`** можно переподключиться (в пределах 10 минут с создания сессии). - **Во время эфира — нельзя.** Отключившийся узел считается молчащим, а сессия продолжается. Узел B может сдать результат по HTTP, даже если его WebSocket оборвался. ## 7. REST API База: `http(s):///api/v1`. Ошибки приходят в одном формате: `{"code":"…","message":"…","session_id":"…"}`. | Метод и путь | Кто | Что | |---|---|---| | `GET /health` | все | состояние стенда | | `GET /protocol` | все | константы протокола | | `GET /public/me` | `X-Team-Key` | название команды | | `GET /public/scenarios` | `X-Team-Key` | доступные сценарии: название, описание, длительность, размер файла | | `POST /public/sessions` `{"scenario_id":"public_clean"}` | `X-Team-Key` | создать public-сессию | | `GET /public/sessions` | `X-Team-Key` | мои сессии | | `POST /public/sessions/{id}/stop` | `X-Team-Key` | остановить свою сессию | | `GET /sessions/{id}` | токен узла, ключ команды | статус и счётчики | | `GET /sessions/{id}/input` | **только токен A** | исходный файл (`application/octet-stream`) | | `PUT /sessions/{id}/result` | **только токен B** | сдать результат | | `GET /sessions/{id}/metrics` | токен узла / команда (public) | метрики завершённой сессии | | `GET /sessions/{id}/events` | команда (public) | журнал событий | | `GET /sessions/{id}/spectrum?after=N` | команда (public) | строки спектра для водопада | Токен узла передаётся заголовком `Authorization: Bearer <токен>`. **Ограничения public-сессий:** не больше **1** активной на команду и не чаще **1 создания в 5 с** (иначе HTTP 429). ### Сдача результата ``` PUT /api/v1/sessions/{id}/result Authorization: Bearer <токен B> Content-Type: application/octet-stream <ровно восстановленные байты файла, без заголовков> ``` - Принимается **только после `stop`** и только **один раз**. Повтор или слишком ранняя сдача дают 409. - Размер — не больше **входного файла + 10 %**, иначе 413. Неверный `Content-Type` → 415. - Длина файла стенду заранее известна, а узлу B — нет. Модем должен передать её сам. - **Если часть данных не дошла**, сдавайте файл полной длины, а непринятые места заполните чем угодно (например, нулями). Стенд сравнивает байты **по позициям**: при «сжатом» файле без пропусков всё после первого пропуска окажется на чужих местах и не совпадёт. ## 8. Что считает стенд | Поле | Смысл | |---|---| | `exact_match` | файл совпал полностью (длина и SHA-256) | | `correct_bytes` | байты, совпавшие на своих позициях | | `ber` | доля ошибочных бит; лишние и недостающие байты — 8 ошибочных бит каждый | | `completion_ratio` | `correct_bytes / input_bytes` | | `goodput_bps` | `correct_bytes × 8 / длительность сессии` | | `credited_blocks`, `goodput_credited_bps` | блоки по 1024 байта, совпавшие целиком, и полезная скорость по ним | | `nodes.A/B` | блоки отправлено/принято, `underruns`, `overruns`, `clipped_samples`, нарушения | ## 9. Частые ошибки | Симптом | Причина и что делать | |---|---| | `bad_frame_size` | блок не 32 768 байт: дополняйте последний блок нулями до 4096 отсчётов | | Много `overruns` | передача быстрее реального времени; перейдите на схему «пришёл блок — отправил блок» | | Много `underruns` | модем не успевает формировать сигнал; отправляйте запас 2–4 блока после `start` | | `clipped_samples` > 0 | сигнал громче ±1; уменьшите амплитуду, оставьте запас на пик-фактор | | `receiver_too_slow` | приёмник не читает поток; разделите приём и тяжёлую обработку (очередь, отдельный поток) | | B сдал результат, а `correct_bytes` почти 0 | байты сдвинуты: сохраняйте позиции пропусков, не «сжимайте» файл | | `ready_timeout` | узел не прислал `{"type":"ready"}` за 60 с после `hello` | | Эфир не начинается | `start` приходит, только когда `ready` прислали **оба** узла | ## 10. Пример клиента `examples/python_client/client.py` — минимальный клиент без модема. Узел A передаёт тон 5 кГц, узел B измеряет мощность и сдаёт пустой результат. Замените две функции-заглушки своим модемом. ```bash pip install -r examples/python_client/requirements.txt SIGNAL_ROLE=A SIGNAL_SESSION_ID=… SIGNAL_NODE_TOKEN=<токен A> \ SIGNAL_API_URL=http://localhost:8000/api/v1 SIGNAL_WS_URL=ws://localhost:8000/ws/v1 \ python examples/python_client/client.py # во втором терминале то же с SIGNAL_ROLE=B и токеном B ``` Все значения для копирования есть на странице сессии в веб-консоли команды (`/`).