Дорожная карта: надёжность сети

Документ охватывает четыре репозитория — soniks-client-new, soniks-flowgraphs, gr-soniks, soniks-network — плюс soniks-monitor, который по итогам разбора перестал быть отдельным продуктом.

Составлен по итогам совместной сессии 2026-08-22/24, когда впервые были доступны все репозитории сразу. Существующая дорожная карта закрытия техдолга остаётся на месте: она ретроспективна и в основном закрыта, этот документ перспективен. Принятые там решения здесь не переигрываются — см. §8.

Актуализирован 2026-09-10 по состоянию портала soniks-network@9ece8db4: за 28 августа — 10 сентября он ушёл на 115 коммитов, и часть утверждений ниже о нём устарела. Устаревшее зачёркнуто, а не удалено — по нему видно, откуда шли. Сводка того, что изменилось для клиента, — в Контекст экосистемы для ИИ-агента, раздел «Снимок».

Где мы на 2026-09-10

Фаза

Состояние

Что держит открытой

0 — остановить кровотечение

код закрыт целиком, все 26 пунктов. Реальный проход на стенде 2026-08-26 (наблюдение 1548706, сборка 2.3.0-1-ge1fe2fa-stand) дал payload.ogg, водопад .dat и .png, два data_*, метаданные с snr_db 12.1 дБ, пустой incomplete/. Код возврата диспетчера в той сборке ещё не логировался (exit_code появился 2026-08-27)

ничего. Три хвоста сняты решением мейнтейнера 2026-09-10: пересчёт истории оценок на паузе (п. 16), ветки soniks-update игнорируются (решение 19), scheduled pipeline под docker_build_check — потом. Переключение JOBS_ALLOW_ANONYMOUS — по счётчику (п. 18), фазу не держит

1 — надёжность обмена

сделано: durable spool и метаданные в нём, content-hash на портале, транспорт с Retry, /healthz, пиннинг сборки, версия из тега, расслоение образа, sat-data bundle, git убран из рантайма, тесты и порог покрытия. 2026-09-10: постоянный 400 в две ступени (решение 24), уход часов в /healthz (решение 25), пин gr-satellites (решение 32), buildx в build.sh (решение 26)

сделано в коде, ждёт человека: registry-прокси в портале (решения 34–35) — включить на серверах и переключить шаблоны compose (решение 37). Не сделано: ретеншен медиа (решение 28: сначала замер), станционный токен (отложен осознанно). ~~Свой образ hamlib~~ снят решением 36. Оба сценария готовности фазы на стенде не прогонялись — это прогон по команде (решение 23), фазу не держит

2 — агент и управление парком

начата 2026-09-10 с канала C: POST /api/v2/stations/<id>/status/, Station.reported_status, фильтр планирования по режимам; клиент публикует MODES (решение 31) и отказывает неизвестный режим, когда портал их знает (решение 45) — канал C закрыт. Контракт агента v1 записан (решения 38–44). Тем же днём, пакет 3 (решения 46–56): конфигурация станции с портала — форма stations/<id>/config/, GET state/, клиент применяет в процессе (remote_config, проверка приёмника sdr_check), MQTT через wss://sonik.space/mqtt, слияние reported_status, ReleaseChannel и update_mode — портальная половина агента v1 сделана. 2026-09-11 — soniks-agent v1 написан (решения 60–66): репозиторий soniks-agent, свой образ, pull state/, override с digest, L1/L2, откат, ключ agent в status/; портал его принимает и показывает, ReleaseChannel.client_image принимает только digest, build.sh печатает digest после --push

Код закрыт, ждёт человека: опубликовать образ агента (./build.sh 0.1.0 --push в его репозитории), раскомментировать блок soniks-agent в шаблонах compose, поставить digest на canary. Пересборка базы образа (paho-mqtt, python3-soapysdr), профиль mqtt и nginx на серверах — по-прежнему за человеком. telemetry-ingest уехал в Фазу 5 (решение 29)

3 — тракт приёма

3.1 сделана 2026-09-10 (решение 33): кадры gr-satellites потоком. 3.2 сделана 2026-09-12 (решение 67): один выбор декодера по наличию satyaml, radio.decoder в метаданных, gr_satellites без satyaml не запускается. 3.6 — клиентская часть 2026-09-12 (решение 68): MODES без is_baudrate/framing, --dc-removal как int — прежняя форма роняла диспетчер. Пакет 6, 2026-09-12 (решения 69–74): 3.3 — геометрия водопада и noise_dbhz в client_metadata.signal, нормирует клиент; 3.4 — общий фронтенд в generic/fm.grc + tools/sync_frontend.py, канал 1 и doppler_correction_per_sec вычищены, hierarchical/ удалён; 3.5 — bundle подтягивается между проходами без рестарта, спутник с satyaml принимается при чужом режиме, satellites в статусе; 3.6 закрыт — --baud для APT/SSTV диспетчер отбрасывал с 248e606 (2026-08-22), dev_args доезжает до soapy_source. Пакет 7, 2026-09-12: портальная половина ворот 3.5 сделанаsatellites принимается, is_transmitter_mode_supported и SQL-фильтр запусков пропускают спутник из списка при любом режиме (решение 74). Фаза закрыта целиком

vmcircbuf_default_factory (нужен запущенный GNU Radio). Фиксированная ветка водопада — одна правка золотого файла, когда понадобится (решение 69). CI grcc флоуграфов после пуша мейнтейнера — единственная проверка раскатки

4 — качество приёма

шаг 1 сделан 2026-09-11 (решения 57–59): автоопределение приёмника и калибровка усиления по команде с портала, график и кнопка «Применить рекомендованное» в форме настроек Пакет 7, 2026-09-12: валидация DSP сделана — tests/test_deviation.py на синтетических спектрограммах, оценщик 93 %, порог покрытия CI 80 %; signal.saturated в метаданных; шаг 2 спроектирован (решения 75–77) Шаг 2a сделан тем же днём: network/base/reception_quality.py, блок «Качество приёма» в форме настроек портала — на лету, только показ

шаг 2b — правило коррекции FLOWGRAPH__RF_GAIN, отдельным решением по накопленным данным; сходимость шкал калибровки и водопада (решение 77) — проверка на стенде по команде

5 — монитор

не начата

ui-kit фактически есть (вопрос 1); с 2026-09-10 сюда же входит telemetry-ingest (решение 29)

Пакет 1 закрыт 2026-09-10 — grill с мейнтейнером, решения 24–33. Три шага «Чем продолжать» прошлой редакции (судьба 400, вопросы 2/6/7, Фаза 3.1) сделаны или отвечены.

Пакет 4, 2026-09-11 — soniks-agent v1 написан, решения 60–66; см. Фазу 2, «Агент v1».

Пакет 5, 2026-09-12 — Фаза 3.2 и клиентский хвост 3.6, решения 67–68. Попутно найдено: --dc-removal=True от клиента роняет диспетчер 2.6.0 (type=int) с кодом 2 — FLOWGRAPH__DC_REMOVAL=true означало пустой проход.

Пакет 6, 2026-09-12 — Фаза 3 до конца: 3.3, 3.4, 3.5 и хвост 3.6, решения 69–74. Попутно найдено: docs/outputs.md флоуграфов называл полосу FSK 9600 «38.4 кГц при децимации 4», а find_decimation(9600, 2, 48000) даёт 6 — 57.6 кГц (76.8 у BPSK); doppler_correction_per_sec был мёртв с рождения — в 20 графах параметр, в диспетчере аргумент, ни одного читателя; фронтенд 20 графов оказался тождественным байт-в-байт, кроме двух комментариев и scale_factor в недостижимом example_flowgraph.

Пакет 7, 2026-09-12 — Фаза 3 закрыта портальной половиной решения 74; вход в Фазу 4: валидация DSP и контракт шага 2, решения 75–77. Попутно найдено: тесты водопада в оценщик не входили вовсе (nchan=4 упирался в guard nchan < 16, dsp.py исполнялся на 6 %), а «прогон plot() на синтетическом сигнале» из Фазы 4 был щедрой формулировкой; критерий насыщения bw99 > 0.85·samp_rate недостижим из-за среза ±0.45 (шапка обещает /2); interferer_rejected значит «оценён сигнал не по центру», а не «помеха отброшена».

Пакет 2 закрыт в тот же день — grill, решения 34–45. Registry стал прокси Docker Hub вместо своего push-канала, свой hamlib снят, контракт агента v1 записан, явный отказ неизвестного режима сделан без ожидания выкатки портала.

Чем продолжать (пакет 3). Каждый шаг закрывается тестами репозитория, стенда не ждёт (решение 23):

  1. Включить registry-прокси — руками, по разделу «Registry образов станций» в docs/dev/deploy.md портала: токен Docker Hub, COMPOSE_PROFILES, каталог кэша, сниппет nginx. Сначала стенд-сервер портала, потом прод; проверка — docker pull sonik.space/sonikspace/soniks-client:latest-addons снаружи.

  2. После подтверждённого pull — переключить шаблоны compose станции и docs/station/ на sonik.space/… (решение 37).

  3. ~~soniks-agent v1 (решения 38–44): отдельный репозиторий~~ Сделано 2026-09-11 (пакет 4, решения 60–66): репозиторий soniks-agent, портал принимает ключ agent. За человеком: git init и первый коммит репозитория агента, ./build.sh 0.1.0 --push там же, раскомментировать блок soniks-agent в шаблонах compose (решение 37 по аналогии: пока образа нет, активный блок уронил бы up новой станции), digest клиента на канал canary в админке. Перед выкаткой клиента с конфигурацией с портала — пересобрать базу (paho-mqtt, python3-soapysdr) и включить профиль mqtt портала с nginx-сниппетом («MQTT-брокер» в docs/dev/deploy.md портала).

  4. Пересобрать базу: в неё входят пин gr-satellites (решение 32) и второй этап платформ, PLATFORMS=linux/arm64,linux/amd64 (решение 26).

  5. Ретеншен медиа: замер S3 (команда — в открытом вопросе 4) выполняет мейнтейнер, lifecycle-правило — по его цифрам (решение 28).

Прогоны на стенде, готовые к команде. Ждут не очереди, а слова человека:

  • два сценария готовности Фазы 1 — час без сети поверх трёх проходов, и старт за firewall с одним sonik.space (после шагов 1–2 «Чем продолжать» образы идут через registry-прокси, решение 34; до них второй упрётся в Docker Hub — это и будет измеренным результатом);

  • реальный проход на текущей сборке: в прогоне 2026-08-26 ещё не было exit_code, mode_known и sat_data в метаданных;

  • Фаза 3.1: кадр gr-satellites появляется на портале через секунды после приёма, дублей нет, в /healthz есть clock_skew_seconds;

  • Фаза 2, агент: на стенде раскомментирован soniks-agent, станция в managed, на canary — digest сборки стенда; агент применяет его в окне между проходами, agent.last_apply.gate == "L2" на портале. Затем заведомо сломанный конфиг (OBSERVATION__SOAPY_RX_DEVICE=driver=nonexistent в форме портала) плюс новый digest — L2 проваливается, агент откатывает, last_apply.ok == false, клиент отвечает на /healthz старым образом.

Отложено решением мейнтейнера 2026-09-10 — к фазам не привязано и ничего не держит:

  • пересчёт истории оценок на проде. Завышенный success_rate за период до починки п. 16 допустим; важно, что после обновления оценка честная;

  • удаление веток soniks-update в клиенте и флоуграфах. Остаются как есть и игнорируются — материалом для чтения они по-прежнему служат;

  • scheduled pipeline под docker_build_check. Джоба есть и запускается вручную; расписание — когда понадобится.

Зачем

Сеть теряет данные, не зная об этом. Найдено несколько независимых каналов тихой потери: от разорванного контракта клиент↔диспетчер до сломанной оценки успешности наблюдений на портале. Общее у них одно — ни один не виден. Нет ошибки в логе, нет провалившегося наблюдения, нет сигнала оператору. Станция может месяцами писать шум и считаться исправной.

Причина в том, что ни один слой системы не наблюдаем: у станции нет health, у портала нет метрик, у оператора нет способа отличить работающую станцию от пишущей шум. И нет способа что-либо исправить удалённо — обратного канала от портала к станции не существует вовсе.

Цель — сделать сеть наблюдаемой, управляемой и обновляемой, и только потом поднимать качество и объём приёма. Порядок именно такой: переделка тракта приёма — единственное изменение здесь, способное одномоментно оглушить весь парк, и катить его следует поверх работающего механизма отката.

Состояние на входе

Репозиторий

Роль

Состояние

soniks-client-new

клиент станции, Python 3.11

2.2.2, активная разработка. Тег 2.3.0 поставлен, версию задаёт тег GitLab (см. Фазу 1)

soniks-flowgraphs

флоуграфы GNU Radio + диспетчер

форк satnogs-flowgraphs, ветка soniks. С 2026-08-24 пиннится тегом 2.6.0, новых коммитов с тех пор нет

gr-soniks

форк gr-satnogs (C++ OOT)

чистое зеркало, 0 своих коммитов, ~~к сборке не подключён~~ подключён 2026-08-24 по тегу v3.1.0.1

soniks-network

портал

~~Django 4.0.10 / Python 3.9~~ Python 3.12 / Django 5.2 LTS с 2026-09-02, форк satnogs-network

soniks-monitor

монитор станции

1357 строк, серверной части не существует

Прямые потери данных, происходящие сейчас

#

Что

Где

Эффект

A

Клиент шлёт --norad-cat-id, диспетчер ветки soniks такого аргумента не знает → parse_args() вышел бы с кодом 2 до запуска графа

src/soniks_client/flowgraph.py:64 против flowgraph_dispatcher.py:496

~~латентный, сейчас не стреляет~~ закрыт (п. 1/12): диспетчер разбирает parse_known_args() и знает оба аргумента. Снимается с парка выкаткой образа, не коммитом

A′

~~То же с --lo-transverter: клиент шлёт, диспетчер не знает~~ закрыт (п. 1/12)

src/soniks_client/flowgraph.py:41

латентный: по умолчанию None и не отправляется, но станция, задавшая FLOWGRAPH__LO_TRANSVERTER, глохнет так же и так же тихо

B

~~rate_observation сравнивает объект Mode со строками: not in ["CW","FM"] всегда истинно~~ закрыт (п. 16)

network/base/rating_tasks.py

любой пришедший кадр делал наблюдение Good. Вся статистика сети, success_rate и «needs attention» были построены на этом. Пересчёт истории на паузе (решение мейнтейнера 2026-09-10): старый период остаётся завышенным

C

Портал планирует режим, которого нет в таблице клиента; клиент подставляет FM

network/base/tasks.py:489 создаёт Mode из фида SatNOGS DB; src/soniks_client/observation_scripts.py

~~в логах ни строчки~~ виден с 2026-08-27: предупреждение в логе и client_metadata.radio.mode_known. Сама подмена остаётся до фильтрации планирования — Фаза 2

D

~~Вся PATHS__BASE, включая очередь incomplete/, лежит в tmpfs~~ закрыт (п. 9)

src/core/configs/path.py:15 плюс type: tmpfs в обоих compose

любой перезапуск стирает невыгруженные данные

E

~~Одно битое задание от портала обнуляет всё расписание~~ закрыт (п. 2)

src/soniks_client/api.py:74 — список собирается внутри одного try

проходы не планируются, пока портал отдаёт битую запись

F

~~Наивная метка времени от портала роняет цикл синхронизации~~ закрыт (п. 3)

src/soniks_client/models.py:57sync.py:140

TypeError при сравнении, задания не обновляются

G

~~Метаданные наблюдения при ошибке сети теряются безвозвратно~~ закрыт 2026-08-26 (Фаза 1, durable spool)

src/soniks_client/api.py:88

блок signal (SNR, девиация, ppm) исчезал, очереди для него не было

H

~~Одиночный 404 удаляет директорию наблюдения целиком~~ закрыт (п. 6)

src/soniks_client/jobs/sending_data.py:147

без второй попытки и без подтверждения

I

~~CLIENT_VERSION не прокидывается в buildx bake~~ закрыт (п. 11)

.gitlab-ci.yml:216

образ с тегом 2.3.0 рапортует порталу 2.2.2

J

~~Приложение продолжает работать с мёртвым планировщиком~~ закрыт (п. 5)

src/main.py:111

после трёх неудачных перезапусков просто пишет в лог; Docker не перезапустит

Уточнение по каналу A: проверено на стенде 2026-08-24

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

Звено

Факт

Диспетчер

flowgraph_dispatcher --help → код 0, --norad-cat-id в списке опций отсутствует

Клиент

--norad-cat-id шлёт, если norad_cat_id is not None

Портал /api/jobs/

поля norad_cat_id нет ни в одном из 59 заданий — его нет в сериализаторе вовсе

job_data.get("norad_cat_id") всегда None, аргумент не добавляется, parse_args() не падает. В логах станции за 48 часов ноль unrecognized arguments, проходы отрабатывают штатно.

Клиентская сторона — не дефект, а задел. --norad-cat-id добавлен в клиент намеренно: gr-satellites выбирает декодер по NORAD через satyaml, и без этого аргумента функционал не включить. То есть из трёх звеньев контракта клиентское уже готово, а недоделаны два других — диспетчер и JobSerializer.

Следствие для порядка работ. Приоритет Фазы 0 из-за канала A поднимать не нужно: сеть данные не теряет. Опасен именно порядок доделки. Если norad_cat_id появится в JobSerializer раньше, чем станция научится аргументу, parse_args() начнёт валиться на каждом задании со спутником и парк оглохнет разом, без единой ошибки в логе.

До 2026-08-27 отсюда следовал вывод «сначала выкатить исправленный образ на парк, потом править портал». Вывод снят решением 21: выкатка на весь парк невыполнима в принципе. Ждать нечего — июльские станции останутся июльскими навсегда, а именно они и опасны: --norad-cat-id появился в клиенте в 2.2.0 (cc54042, 2026-07-01), образ парк получил 19 июля, то есть комбинация «клиент шлёт, диспетчер не знает» уже стоит на станциях. Станции на 1.x аргумент не шлют и безопасны.

Правильный порядок — не выкатка, а опт-ин: портал отдаёт norad_cat_id только той станции, которая объявила, что понимает его. Необновлённая получает ответ байт-в-байт как раньше и сломаться не может, сколько бы её ни не обновляли.

Состояние на 2026-08-27. Обе половины опт-ина сделаны — клиентская (CAPABILITIES в src/soniks_client/api.py) и портальная (JobSerializer.norad_cat_id), подробности ниже.

Предупреждение

docker manifest inspect sonikspace/soniks-client:<тег> больше не гейт правки портала — он ничего не решает, потому что решением 21 гейта не стало. Проверка осталась и отвечает на другой вопрос: доехало ли до парка хоть что-нибудь. Ответ на 2026-08-27 — нет. Парк последний раз получал образ 19 июля; ни 2.1.0, ни 2.2.0, ни 2.3.0 образов не оставили.

Причина не в поломке, и это стоит записать прямо: поставки через CI никогда не было. Образы всегда собирались вручную и заливались docker push — в registry лежат однопланформенные arm64-образы под плавающим тегом latest-addons, ни одного manifest list. Джоба docker с мультиархом под QEMU — незаконченная работа: на раннере проекта она падает на старте контейнера сборки, runc не может прикрыть /proc/acpi монтированием tmpfs. Чинить надо не её, а тот канал, который работает, — см. «Выкатка образа» в Участие в разработке.

Тем же диффом контракта найден канал A′ (--lo-transverter), которого в исходном разборе не было. --framing — обратный случай: диспетчер принимает, клиент не шлёт никогда.

Структурные проблемы

«Версия станции» не определена. Плавающий тег latest-addons; gr-satnogs клонируется из master Libre Space Foundation мимо собственного форка; flowgraphs — из ветки без пиннинга коммита; ~~satyaml клонируется из git при каждом старте; sat.cfg копируется один раз и не обновляется никогда~~ (оба закрыты 2026-08-28 sat-data bundle’ом — см. Фазу 1). Две станции с одинаковым тегом образа могут содержать разный код.

Обратного канала нет. Портал не умеет сказать станции ничего. Вкладка «Конфигурация» на странице станции — бутафория: satnogs_rf_gain и соседние поля заполняются только через django-admin и не читаются никем. Рядом лежат заготовки get_default_station_configuration* (network/base/models.py:528 на 2026-09-10), ссылающиеся на модели, которых в репозитории нет. Чистка портала 2026-09-01 («Delete the code and settings nothing reads») их не тронула — они по-прежнему ждут Фазы 2.

Токен пользовательский, а не станционный (network/users/models.py). Один токен на все станции владельца; ротация из UI молча убивает heartbeat всего парка (с 2026-08-29 ротация хотя бы только по POST — раньше её вызывал GET, то есть чужая страница с <img src=…>). При этом GET /api/jobs/ открыт анонимно — ~~у JobView нет permission_classes~~ с 2026-08-24 это выключатель JOBS_ALLOW_ANONYMOUS (п. 18): токен нужен только чтобы обновился last_seen. Готовый класс ClientIDAuthentication написан и никуда не подключён — на 2026-09-10 по-прежнему.

~~Два независимых декодера на один сигнал.~~ Первый — satnogs_*.py, реалтайм, выбор по mode от портала. Второй — gr_satellites, выбор по NORAD через satyaml, питается UDP-потоком IQ от того же графа, ~~но кадры разбираются только на stop (scripts/grsat.py:281) — то есть уже после остановки watchdog~~ (потоком с 2026-09-10, Фаза 3.1). Из двадцати флоуграфов блоки gr-satellites используют два. С 2026-09-12 выбор один (Фаза 3.2, решение 67): клиент проверяет satyaml для NORAD, без него gr_satellites не запускается — раньше он стартовал на каждом проходе и умирал молча в DEVNULL.

Двадцатикратная копипаста. Блок soapy_source с ~45 полями (включая мёртвый канал 1) и доплер-сниппет продублированы построчно во всех графах. Каталог hierarchical/ с шестью hier-блоками задуман как лекарство и не используется ни одним графом.

Усиление статическое. Аппаратная AGC жёстко выключена во всех графах — и это осознанно: для узкополосных пакетных сигналов она подстраивается под сильнейший сигнал в полосе, обычно помеху. Но при GAIN_MODE=Overall и незаданном RF_GAIN аргумент --gain не передаётся вовсе, и работает драйверный дефолт. Обратная связь существует — водопад считает snr_db, noise_db, peak_db — и никуда не заводится.

Водопады несопоставимы. Полоса равна out_samp_rate, а он зависит от бодрейта режима: 38.4 кГц для 9600, 48 кГц для FM, 66.56 кГц для APT. Значит noise_db между режимами сравнивать нельзя.

Станция требует открытого интернета, а его нет. Часть парка стоит в закрытых сетях, где доступен только sonik.space. На входе станция тянула извне четыре вещи: docker.io/librespace/hamlib:4.5.4 (rigctld, rotctld), docker.io/sonikspace/soniks-client, и — при каждом старте контейнера — git-клоны github.com/daniestevez/gr-satellites и gitlab.com/.../soniks-satyaml (scripts/liveupdate-satyaml.sh). В закрытой сети клон падал, скрипт печатал предупреждение, станция стартовала с вкомпилированным satyaml — то есть тихо устаревшим.

Два последних канала закрыты 2026-08-28: определения приезжают bundle’ом с портала. Осталось два обращения, оба за образами; ~~оба ждут registry на sonik.space — то есть ответа на открытые вопросы 6 и 7~~ registry-прокси сделан (решение 34), они закрываются его включением на сервере и переключением шаблонов compose (решение 37).

~~Портал на пределе. Один sync-воркер gunicorn без --timeout, Django 4.0.10 (вне поддержки с апреля 2023), Python 3.9 (EOL), Sentry импортирован но не инициализирован, CORS_ALLOW_ALL_ORIGINS = True, .env копируется в образ и уезжает в registry.~~ Закрыто целиком к 2026-09-04. Воркеры по формуле gunicorn и таймаут 600 (п. 23), Sentry снят вместе с SDK (п. 21), .env в образ не попадает (п. 22), CORS_ALLOW_ALL_ORIGINS оставлен сознательно (решение 20). Апгрейд, который здесь считался «нужен, но не блокирует», сделан 2026-09-02: Python 3.12, Django 5.2 LTS, DRF 3.18, Redis 7.4, Debian bookworm; портал по-прежнему WSGI, push к станции невозможен как и раньше. Сверх плана: Bootstrap 5.3 вместо 4.6 и admin-lte, jQuery снят целиком, OpenAPI-схема генерируется под пиннутой локалью и сверяется в CI.

~~Проверять по-настоящему нечем. Нет ни одного теста на execute_observation, main.Application, api.py, communication_session.py (360 строк конкурентного кода), waterfall/deviation.py (519 строк DSP). Файл tests/e2e_check.py в flowgraphs написан и к CI не подключён.~~ Закрыто на клиенте и во флоуграфах 2026-08-24/27 (Фаза 0.14, Фаза 1 «Тесты»); открытой осталась только валидация DSP — Фаза 4. На портале за тот же срок число тестовых функций выросло с ~180 до 920 с лишним, и среди них — сторожа контрактов, на которые опирается клиент (JobCapabilitiesTest, AlreadyUploadedResponseTest, DeprecatedVettedFieldsTest).

Принятые решения

#

Вопрос

Решение

1

Владение парком

Смешанный: ядро плюс волонтёры. Флаг managed / self-managed на станции

2

Свобода менять портал

Полная, обе стороны контракта — наша работа

3

Единица обновления

Три слоя: soniks-base (GNU Radio, gr-soniks, flowgraphs — гигабайты, меняется редко) → soniks-client (FROM base, десятки МБ, меняется часто) → sat-data bundle (satyaml, sat.cfg, таблица режимов; версионируется отдельно, раздаётся порталом). Версия станции = digest клиентского образа плюс версия bundle

4

Кто обновляет

Отдельный контейнер-агент с доступом к docker.sock

5

Транспорт управления

Pull REST; desired-state как версионированный документ с generation — задел под MQTT

6

Граница конфига

Портал читает весь фактический конфиг; меняет тракт приёма и железо

7

Критерий применения

Трёхуровневый health-gate плюс автооткат. В агенте v1 — L1 и L2 (решение 42), L3 позже

8

Архитектура тракта

Единый граф приёма, gr-satellites как основной декодер

9

Гарантия доставки

Durable spool на диске плюс идемпотентность по content-hash

10

gr-satnogs

Подключить существующий форк gr-soniks, пиннить тег, свои правки туда

11

Подбор усиления

Гибрид: калибровка на станции плюс статистическая подстройка порталом

12

soniks-monitor

Сливается с агентом в единый soniks-agent. Смягчено решением 30: агент пишется с нуля, монитор — только материал

13

Приём телеметрии

Отдельный ASGI-сервис рядом с порталом. Одно из оснований отпало 2026-09-02: довод «тащить ASGI в Django 4.0.10 значит связать мониторинг с большим апгрейдом» снят самим апгрейдом. Остались довод о нагрузке и о том, что логи и стрим водопада в ORM не ложатся. Решение не переигрывается здесь — ~~перепроверить на входе в Фазу 2~~ сервис уехал в Фазу 5 (решение 29), там и перепроверить

14

Согласование возможностей

Станция публикует capabilities → портал фильтрует планирование → клиент отказывает явно. Все три звена сделаны 2026-09-10 (решения 31 и 45)

15

Порядок фаз

Строго 0 → 1 → 2 → 3 → 4 → 5

16

Реальный стенд

Dev-агент с SSH-доступом, инструмент разработки

17

Доставка образов

Свой registry на sonik.space/v2/. ~~Ни одного чужого образа на станции, включая пересобранный hamlib~~ Ни одного чужого хоста — переписано решением 36: registry — прокси Docker Hub (решение 34), hamlib — образ LSF через него

18

Внешние зависимости рантайма

Убрать полностью: liveupdate-satyaml.sh перестаёт клонировать из git. Сделано 2026-08-28 — см. решение 22 и Фазу 1

19

origin/soniks-update

Не мёржить ни целиком, ни по коммитам. Экспериментальная ветка мейнтейнера: попытка обновить flowgraphs, качество кода и результат не проверялись. Служит материалом для чтения, не источником правок. Разбор — отдельной сессией: перенести годное, отменить негодное, ~~по итогам ветку удалить~~. Разбор выполнен 2026-08-24, переносить оказалось нечего — см. ниже. Удалять ветку не будем: 2026-09-10 решено её игнорировать

20

CORS_ALLOW_ALL_ORIGINS на портале

Оставить True. Сеть открытая, её API предназначен для запросов откуда угодно — это назначение сайта, а не упущение. Предлагалось свести к DEBUG со списком источников и отклонено 2026-08-24. В settings.py рядом стоит комментарий

21

Совместимость с парком

Парк не обновится целиком никогда. Часть станций в закрытых сетях, часть у волонтёров, часть просто не тронут. Отсюда правило: изменение портала не имеет права менять то, что отдаётся станции, которая об этом не просила. Новое — либо по явному опт-ину клиента, либо под /api/v2/. Станция на 1.2.8 работает как раньше бессрочно, обновлённая получает больше функционала. Принято 2026-08-27; отменяет предусловие «сначала выкатить образ на парк» везде, где оно стояло — см. ниже

22

Формат и сборка sat-data bundle

Архив tar.gz с satyaml/ и sat.cfg; собирает и публикует CI soniks-satyaml, версия — короткий SHA его коммита; целостность — sha256 поверх HTTPS, подписи нет; опубликованная версия неизменяема. Отсюда же: sat.cfg переехал в soniks-satyaml — это данные спутников, а не конфиг станции, и версионироваться они обязаны одним коммитом. Принято 2026-08-28, снимает открытый вопрос 5

23

Роль стенда в разработке

Прогон на стенде — только по команде человека, и разработка его не ждёт. Без команды проверка — тесты и линтеры репозитория (раздел «Верификация»), и работа идёт дальше, до конца плана. Прогон может быть назначен в любой момент, по любой уже сделанной правке. Разделы «Готовность фазы» описывают, что стенд проверяет, когда прогон назначен; переход к следующей фазе они не блокируют — порядок фаз (решение 15) держится на коде и зелёных тестах. Решение про разработку, а не про выкатку на парк: выкатка по-прежнему ручная и за человеком. Принято 2026-09-10

24

Постоянный 400 на файл выгрузки

Любой 400 окончателен, код ответа не разбирается. Две ступени, как у 404: первый отказ — файл в incomplete/, повторный при повторной отправке — файл закрывается как выгруженный, по REMOVE_OBSERVATION_DATA (удалить или в complete/). Одно правило для кадров и метаданных. Принято 2026-09-10

25

Уход часов станции

Виден уже сейчас, до health-gate: клиент сверяет часы с заголовком Date ответа /api/jobs/, расхождение — clock_skew_seconds в /healthz, больше 60 с — предупреждение в лог. Принято 2026-09-10

26

Платформы

Все: linux/arm64 (RPi, большинство из ~50 станций) и linux/amd64 (ноутбуки на Ubuntu и Windows/Docker Desktop). Выкатка в два этапа — сначала arm64. Сборка — docker buildx в build.sh, CI-джоба docker не чинится. Принято 2026-09-10

27

Registry на sonik.space

На него переходят все станции, а не только закрытые. Чтение анонимно — образы и так публичны, как и bundle; ~~push — только CI. Хранится текущая и предыдущая версия каждого образа — ради отката~~. Принято 2026-09-10, снимает вопросы 6 и 7. Пересмотрено решением 34 в тот же день: «push только CI» противоречило решению 26 — CI-джоба сознательно не чинится. Registry — прокси, push нет вовсе, версии хранит Docker Hub

28

Ретеншен медиа

S3 на Yandex Object Storage, больше 10 ТБ, всё хранится вечно. Сначала замер объёма по типам, потом lifecycle-правило бакета — старое в холодное хранилище. Удаление решается по цифрам замера. Принято 2026-09-10

29

telemetry-ingest

Из Фазы 2 в Фазу 5: готовности Фазы 2 (раскат, откат, версии) телеметрия, логи и стрим водопада не нужны. Форма сервиса (решение 13) решается там. Принято 2026-09-10

30

soniks-agent

Пишется с нуля как продукт для массового публичного парка. soniks-monitor — студенческий, шлёт данные на сторонний сервер, токен — в URL и в логе; его куски (логи через docker.sock, разбор водопада) — материал Фазы 5. Смягчает решение 12. Принято 2026-09-10

31

Как станция объявляет режимы

POST /api/v2/stations/<id>/status/ с modes; портал хранит документ целиком в JSON-поле Station.reported_status — агент потом допишет ключи без миграций. Станция без modes умеет всё (правило 21). Принято 2026-09-10. Уточнено решением 41: портал пишет документ целиком, и агент стирал бы modes — запись станет слиянием по ключам верхнего уровня

32

gr-satellites в образе

Пиннится коммитом main (GRSATELLITES_REF), хеш уезжает в client_metadata.radio.gr_satellites — как gr-soniks и флоуграфы. Принято 2026-09-10

33

Фаза 3.1

--kiss_server gr_satellites плюс потоковый читатель grsat.py stream; --kiss_out остаётся страховкой, дубли отсеивает content-hash портала. ZMQ не используется: в сообщении нет метки времени и нужен pmt. Принято 2026-09-10

34

Форма registry

Pull-through cache Docker Hub (registry:3.0.0, proxy mode). Push нет, build.sh публикует на Docker Hub как раньше. Имена на станции: sonik.space/sonikspace/soniks-client:<тег>, sonik.space/librespace/hamlib:4.5.4. nginx пропускает только эти репозитории — иначе открытое зеркало всего Docker Hub; прокси ходит с read-only токеном Docker Hub. Пересматривает 27. Принято 2026-09-10

35

Кэш registry

Локальный диск сервера, /opt/storage/network_volumes/registry, неиспользуемое вытесняется через 168 ч. Сервис под compose-профилем registry: локально не стартует; сначала стенд-сервер портала, потом прод. Принято 2026-09-10

36

Hamlib

Образ LSF через прокси: он — debian:bookworm плюс apt-пакет libhamlib-utils=4.5.4-1+b1, наша база на том же bookworm с тем же пакетом. Пересборка дала бы байт-в-байт то же ещё одним образом на поддержке. Переписывает 17. Принято 2026-09-10

37

Шаблоны compose станции

Переключаются на sonik.space/… только после того, как человек подтвердит docker pull через прокси: раньше новая установка не поднимется. Уже стоящие станции не затрагиваются — compose лежит у них. Принято 2026-09-10

38

Единица раската

Каналы canary и stable, цель — digest, а не тег. Продвижение canary → stable вручную, staff в админке: на 2–3 станциях канарейки «N часов без провала» ничего не доказывает. Принято 2026-09-10

39

Что меняет агент v1

Только образ клиента. Bundle остаётся latest, как сейчас; конфиг тракта (решение 6) — агент v2. Принято 2026-09-10

40

Как агент применяет образ

Compose override, приём tools/stand.sh: docker-compose.override.yml с image: …@sha256:…, docker compose up -d soniks-client. Откат — тот же файл с предыдущим digest; предыдущий образ агент на станции не удаляет. Принято 2026-09-10

41

Два писателя в reported_status

Слияние по ключам верхнего уровня: клиент владеет modes и client_version, агент — agent. Правка view портала — вместе с агентом. Уточняет 31. Принято 2026-09-10

42

Health-gate агента v1

L1 и L2. L2 — timeout 60 test-flowgraph.sh только когда следующий проход дальше 5 минут; успех — код 124 и непустой test.dat. L3 — позже, провал прохода виден на портале по exit_code и snr_db. Уточняет 7. Принято 2026-09-10

43

Режим по умолчанию

notify; managed включает владелец в настройках станции. Волонтёрская станция не катится без согласия владельца. Принято 2026-09-10

44

Репозиторий агента

Отдельный soniks-agent, свой образ. Самообновления в v1 нет — агента обновляет оператор. Аутентификация — токен владельца из .env станции, станционный токен отложен. Принято 2026-09-10

45

Явный отказ неизвестного режима

Только если publish_station_status() в этом процессе прошёл успешно: принять режимы может лишь портал с фильтром, значит станции такой режим планироваться не должен был. Отказ до создания путей, как у NoCompatibleRxDeviceError; иначе — подмена на DEFAULT_MODE, как раньше. Выкатки портала не ждёт. Закрывает третье звено решения 14. Принято 2026-09-10

46

Кто применяет конфигурацию станции

Сам soniks-client, не агент. Всё управляемое клиент читает из settings в момент использования: аргументы графа строятся на каждый проход, контроллеры ротатора и рига — под lru_cache, который сбрасывается, уровни логов — setLevel. Перезапуск контейнера не нужен, «агент v2 для конфига» из решения 39 снимается; агент — только образ. Уточняет 6. Принято 2026-09-10

47

Транспорт «сразу и в обе стороны»

MQTT через WebSocket на 443 (wss://sonik.space/mqtt): Mosquitto + mosquitto-go-auth с HTTP-auth в портал (/api/v2/mqtt/auth/, /acl/, закрыты nginx снаружи), профиль compose mqtt. Портал публикует stations/<id>/state (retained) при сохранении формы, мост mqtt_bridge пишет status/online (Last Will) в БД. REST GET state/ с ETag — источник истины и фолбэк на старте и каждой сверке. Переписывает 5, снимает строку «MQTT вместо pull REST» из «Сознательно отложено». Браузер к брокеру не подключается — страница опрашивает owner-only GET. Принято 2026-09-10

48

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

Тракт + ротатор/риг + уровни логов: FLOWGRAPH__* (кроме UDP_DUMP_*, FLOWGRAPH_DISPATCHER, DEFAULT_MODE), OBSERVATION__SOAPY_RX_DEVICE, REMOVE_*, RUN_*_SCRIPT, ANTENNA__ROTATOR__*, ANTENNA__RIG__*, LOG__LEVEL/SCRIPT_LEVEL/FLOWGRAPH_LEVEL — 34 имени, MANAGED_FIELDS клиента = STATION_CONFIG_FIELDS портала. Не управляются STATION__*, URL__*, PATHS__*, HEALTH__*, SCHEDULER__*, API__* и модель ротатора в compose rotctld. Принято 2026-09-10

49

Форма документа конфигурации

Плоский словарь с именами переменных окружения ({"FLOWGRAPH__RF_GAIN": 25}) — та же лексика, что docs/station/environment_variables.md; один словарь имён на портал, клиент и документацию, сторож — test_managed_fields_are_documented. Клиент собирает разделы model_validate({**baseline_из_.env, **overrides}): снятый с портала ключ возвращается к .env, а не остаётся от прошлого поколения. Принято 2026-09-10

50

Проверка перед применением

Открыть приёмник python-биндингом SoapySDR (sdr_check) в подпроцессе с таймаутом 20 с, только когда менялись параметры приёмника и только вне прохода; идущий проход откладывает применение до следующей сверки. Отказ по железу повторяется на следующей сверке (приёмник могли ещё не подключить), отказ по документу — нет. Без биндинга (старая база) проверка пропускается с предупреждением. L2 из решения 42 для конфига не нужен. Принято 2026-09-10

51

Переезд с .env на портал

Станция публикует фактическую конфигурацию (config.actual в status/); форма портала предзаполняется ею, пока владелец ничего не сохранил, и кнопкой «Заполнить с станции» после. generation 0 — станция на .env; после первого сохранения портал главный. Принято 2026-09-10

52

Минимальный .env

STATION__ID и STATION__TOKEN. Координаты берутся из state.location; заданные в .env имеют приоритет и уходят в /api/jobs/ как раньше, незаданные — не уходят (портал пишет только last_seen). Принято 2026-09-10

53

Два писателя в reported_status

Слияние по ключам верхнего уровня сделано (уточнение 41): клиент владеет modes, client_version, config; мост MQTT — connection; агент потом — agent. ~~modes пока обязателен — клиент всегда шлёт полный документ; ослабить вместе с агентом~~ — с 2026-09-11 modes необязателен (отсутствует — прежний список остаётся, пустой — 400), а client_version из статуса обновляет колонку Station.client_version, которую раньше писала только выгрузка наблюдения. Принято 2026-09-10

54

Портальная половина агента v1

Сделана: ReleaseChannel (canary/stable, client_image, правит staff), Station.release_channel, Station.update_mode (владелец, в форме конфигурации), state.release. Сам агент не пишется. Принято 2026-09-10

55

Поля satnogs_* на портале

Четыре поля и бутафорская вкладка удалены, заменены desired_config/config_generation. get_default_station_configuration* остаются — на них ссылается миграция 0005. Принято 2026-09-10

56

Фронтенд формы конфигурации

Django-шаблоны, Bootstrap 5.3, vanilla JS — как весь портал (снимает открытый вопрос 1 и для этой формы). Принято 2026-09-10

57

Автоопределение приёмника

Станция сама перечисляет приёмники SoapySDR и их возможности — входы, диапазоны усиления (общий и покаскадные), частоты дискретизации, диапазон частот (src/soniks_client/sdr_survey.py, на фундаменте sdr_check): на старте, раз в SDR__SCAN_INTERVAL_IN_MINUTES в простое и по команде с портала. Итог — ключ sdr статуса; в форме портала у каждого найденного приёмника кнопка «Использовать» подставляет строку устройства, вход, границы усиления и частоту дискретизации. Принято 2026-09-11

58

Команды портала станции

Не отдельный канал, а блок commands документа state/: {"rescan_sdr": {"at": iso}, "calibrate_gain": {"at": iso, "frequencies": [Гц]}}, на портале — Station.commands, кнопки в форме настроек публикуют документ в MQTT. Команда выполняется раз на отметку at, только вне прохода и когда до ближайшего дальше SDR__IDLE_WINDOW_IN_MINUTES; выполненные отметки — в sdr-survey.json рядом с файлом состояния. ETag state/ считается по телу, поэтому команда доезжает и REST’ом. Принято 2026-09-11

59

Калибровка усиления — Фаза 4, шаг 1

По команде: свип по общему усилению шагом SDR__GAIN_STEP_DB на частотах антенн станции (портал берёт середины диапазонов), средняя мощность на шаге. Рекомендация — первое усиление, на котором шумовая полка поднялась на SDR__NOISE_LIFT_DB (10 дБ) над минимумом; одно значение на все диапазоны — наибольшее из рекомендованных. Итог — ключ calibration статуса; портал рисует график (Chart.js) и кнопкой ставит рекомендованное в FLOWGRAPH__RF_GAIN. Шаг 2 (статистическая подстройка) по-прежнему ждёт Фазу 3.3. Принято 2026-09-11

60

Где живёт агент

Сервис того же compose-проекта, что и клиент. /healthz клиента не выведен на хост, а docker compose up -d soniks-client из агента обязан попасть в проект хоста: имя проекта, рабочий каталог и файлы конфигурации агент берёт из лейблов своего контейнера (com.docker.compose.project*), каталог станции монтируется в него по тому же пути (${PWD}:${PWD}). Каталога нет — ошибка в лог, агент только наблюдает. Принято 2026-09-11

61

Override

Сливается, не перезаписывается: PyYAML читает docker-compose.override.yml, меняется только services.soniks-client.image. У файла есть второй писатель — tools/stand.sh deploy и оператор. Принято 2026-09-11

62

Когда качать, когда применять

docker pull — сразу, применение (up -d) — только в окне: проход не идёт и до ближайшего дальше AGENT__WINDOW_IN_MINUTES (5). Один критерий на применение и на L2 (решение 42). Принято 2026-09-11

63

Повтор после провала

Провалившийся gate digest не применяется повторно AGENT__RETRY_AFTER_HOURS (24): иначе агент раз в минуту рестартовал бы клиента об тот же образ. Новый digest в канале — сразу. Ошибка pull (сеть) повтора не задерживает. Принято 2026-09-11

64

Окружение L2

docker compose exec -e со всеми парами state.config: это фактические настройки клиента, а test-flowgraph.sh падает на ${FLOWGRAPH__RX_SAMP_RATE?} у станции с минимальным .env (решение 52). Принято 2026-09-11

65

notify

Только поле available в agent и строка в лог раз на digest. Принято 2026-09-11

66

Транспорт агента

Только REST: state/ раз в минуту с ETag, status/ при изменении документа. MQTT не нужен — и та же личность station-<id> в двух paho-клиентах давала бы взаимные дисконнекты. Принято 2026-09-11

67

Фаза 3.2 — один выбор декодера

Правило одно, в sat_data.decoder_for(): есть norad: в satyaml пакета satellitesgr-satellites, иначе satnogs; нет пакета — satnogs, честно. Без кэша: сотни файлов в миллисекунды, а кэш пришлось бы сбрасывать после apply(). До скриптов едет переменной SONIKS_DECODER — они пакет не импортируют, а позиционный контракт хуков жёсткий; дефолт в grsat.py сохраняет ручной запуск. В метаданные — ключом radio.decoder, не в parameters: те целиком уходят в argv диспетчера. transmitter_parameters не подключается — выбор по NORAD, потребителя параметрам нет. Принято 2026-09-12

68

Фаза 3.6 — клиентская часть

is_baudrate/framing из MODES сняты (переигрывает «оставить» из roadmap.md: за год потребитель не появился). --dc-removalint, после сверки с argparse диспетчера 2.6.0. vmcircbuf_default_factory не трогается: адрес проверяется только запущенным GNU Radio. Принято 2026-09-12

69

Фаза 3.3 — сопоставимый водопад

Нормирует клиент, графы не трогаются: фиксированная полоса из точки съёма после доплер-компенсации недостижима без второго тракта децимации (CPU на Pi), а по правилу 21 разнородные водопады от старых станций будут всегда. В signal — геометрия из заголовка .dat и noise_dbhz = noise_db 10·lg(bin_hz). Режим sink остаётся Max Hold (смещение 10·lg(H_n) 3.2–5.5 дБ известно, nfft_per_row в метаданных); Mean тускнит короткие всплески — отказ мейнтейнера. Фиксированная ветка водопада, если понадобится, — одна правка золотого фронтенда. Принято 2026-09-12

70

Фаза 3.6 — хвост во флоуграфах

--baud для APT/SSTV уже отбрасывался диспетчером с 248e606; dev_args: dev_args в 20 графах через золотой фронтенд, example_flowgraph.grc получает параметр. Безопасно: FLOWGRAPH__DEV_ARGS по умолчанию не задан. Принято 2026-09-12

71

Фаза 3.4 — форма единого графа

Золотой фронтенд, не hier-блок. generic/fm.grc — источник истины для soapy_source, доплер-компенсации, водопада, IQ ZMQ и UDP; tools/sync_frontend.py раскладывает parameters: текстовым сплайсом, test_frontend_matches_golden сторожит. Свои у графа только out_samp_rate/samp_rate водопада и compensate. hierarchical/ удалён: шесть блоков отражали до-ZMQ архитектуру, не содержали soapy_source, а настоящий hier-блок требовал CMake-цепочки с проверкой только в CI и рантайм-импорта на станции без стенда. Принято 2026-09-12

72

Фаза 3.4 — что вычищено

16 полей канала 1 из soapy_source (кодоген гейтит их по nchan > 1); параметр doppler_correction_per_sec из 20 графов, диспетчер обнуляет его как norad-cat-id — старые клиенты шлют. Оставлены: поля усиления под чужие devname (мертвы при custom, но без железа не трогаем), рудименты enable_iq_dump/iq_file_path/file_path/decoded_data_file_path (их читает диспетчер), клиентская FLOWGRAPH__DOPPLER_CORR_PER_SEC (в форме портала и remote_config, помечена исторической). Принято 2026-09-12

73

Фаза 3.5 — bundle без рестарта

resync_sat_data() заданием планировщика раз в SCHEDULER__SAT_DATA_SYNC_INTERVAL_IN_HOURS (6): сверка версии с применённой, скачивание и раскладка только между проходами (health.seconds_to_next_observation() == 0.0 — отложить), после новой версии — перепубликация статуса. sync_sat_data() на старте не меняется: раскладка на каждом старте остаётся. Принято 2026-09-12

74

Фаза 3.5 — ворота по satyaml

Клиент: режим ∉ MODES при опубликованных режимах больше не отказ, если satyaml для NORAD есть — satnogs-граф берётся по модуляции первого передатчика (AFSK/BPSK/FSK, regex), baud при пустом задании — из satyaml, исходный режим — в radio.mode_requested; status/ получает satellites (NORAD из satyaml). Портал — сделано 2026-09-12 (пакет 7): satellitesListField(IntegerField(min_value=1)), без allow_empty=False (станция без bundle шлёт [] в каждом heartbeat, 400 ослепил бы её); reported_satellites() рядом с reported_modes(), is_transmitter_mode_supported пропускает передатчик, чей satellite.norad_cat_id в списке, — режим проверяется первым, .satellite трогается только при непустом списке (никто не делает select_related); тот же обход у SQL-фильтра get_available_transmitter запусков, который валидатор минует. transmitter_parameters по-прежнему не подключается. Принято 2026-09-12

75

Фаза 4, шаг 2 — где считается статистика

Портал, на лету, во view формы настроек станции: последние 50 наблюдений станции с непустым client_metadata и signal.noise_dbhz (старые клиенты не входят). Значения signal — строки с единицами («−69.4 dB»), число берётся первым числом строки, как parse_value в tools/portal_stats.py. Без миграции, Celery и колонки: 50 json.loads на показ дёшево, история переживает рестарт станции и не зависит от версии клиента. Отклонено: скользящее окно на станции (теряется с томом, дублирует то, что портал уже хранит). Принято 2026-09-12

76

Фаза 4, шаг 2 — только показ

Блок «Качество приёма» под графиком калибровки: число проходов в окне, медиана и MAD noise_dbhz, медиана snr_db по проходам с demoddata, доля проходов с signal.saturated (клиент шлёт с пакета 7; флаг ловит касание опорой цели края полосы). Правило коррекции FLOWGRAPH__RF_GAIN и кнопка — отдельным решением по накопленным данным: ни одна станция ещё не калибровалась, править нечего. Автоприменение для managed не проектируется. Принято 2026-09-12, показ сделан тем же днём

77

Фаза 4, шаг 2 — сопоставление с кривой калибровки

Ожидаемая связь: noise_dbhz P(gain) 10·lg(RX_SAMP_RATE) + 10·lg(H_n), где P — точка кривой шага 1 на текущем усилении (широкополосная мощность IQ), H_n — смещение Max Hold по nfft_per_row. Сходимость шкал не доказана — до проверки на стенде (по команде) величины показываются рядом числами, на график калибровки не накладываются. Принято 2026-09-12

78

Фаза 2 — вход в настройки станции на портале

Задача (портал, отдельная сессия). На странице станции одна кнопка «Настройки станции» вместо «Изменить», всегда видимая владельцу; форма за ней объединяет station_edit (имя, координаты, антенны) со всеми полями station_config_edit. Дефект, найденный на стенде 2026-09-13: после первого сохранения конфигурации вход в неё со страницы станции пропадает. Вкладка «Конфигурация» и карточка «Настроить станцию» в station_view.html показываются только при station.client_id, а client_id заполняет лишь старый поток регистрации через ClientIDAuthentication, который никуда не подключён, — у станции 46 на dev он пуст, как и у всего парка. Единственный видимый вход — баннер «Настроить в один клик», а он спрятан условием not station.config_generation и исчезает ровно после первого сохранения. Данные при этом целы: desired_config 23 ключа, reported_status.config.actual 34. Результат: владелец с любой страницы станции попадает в полную форму настроек независимо от client_id и поколения; условие на client_id из шаблона убрать, баннер первого запуска оставить как подсказку, а не как единственный вход. Принято 2026-09-13

Правило 21 на практике

Решение 21 — не лозунг о совместимости, а проверяемое ограничение на каждую правку портала. Формулировка, по которой правку можно принять или отклонить:

Возьмите станцию, которая никогда не обновится. Если после вашей правки она ведёт себя иначе, чем до неё, — правка неверна независимо от пользы.

Механизм — опт-ин, а не версия. Клиент объявляет, что понимает, параметром capabilities в GET /api/jobs/ (CAPABILITIES в src/soniks_client/api.py). Портал отдаёт новое поле только попросившему. Три следствия, ради которых выбрана именно эта форма:

  • станция, которая параметр не прислала, получает ответ байт-в-байт как раньше — сломать её нельзя физически, а не «мы постарались»;

  • заявление честно по построению: клиент и flowgraph_dispatcher едут в одном образе, значит объявленное клиентом умеет и диспетчер. Гейт по client_version этого не даёт — версия бывает пустой и протухшей, а ошибка сравнения строк-версий стоит глухой станции;

  • порядок выкатки становится безопасным в любую сторону. Клиент шлёт параметр, портал его пока игнорирует — не происходит ничего. Портал научится позже — поле поедет само.

Сделано 2026-08-27, обе половины. Клиентская: capabilities=norad_cat_id, тест test_request_declares_client_capabilities. Портальная: JobSerializer.norad_cat_idSerializerMethodField, который to_representation() убирает из ответа, если клиент возможность не объявил (_declared_capabilities() рядом разбирает параметр). Поле стоит последним в Meta.fields, поэтому после удаления порядок ключей совпадает с прежним байт-в-байт.

Три вещи, найденные при этом и стоящие внимания:

  • JobView.list строил сериализатор без контекстаJobSerializer(...) напрямую вместо self.get_serializer(...). Без request в контексте опт-ин не сработал бы никогда, причём молча: поле просто не появлялось бы ни у кого. Заменено на get_serializer; действие retrieve ходило через него всегда;

  • filterset не строгий — предусловие снято. ObservationViewFilter — обычный FilterSet, django-filter неизвестные query-параметры игнорирует. Это и есть причина, по которой клиент уже месяц шлёт capabilities в портал, который о нём не знал, и ничего не происходило. Закреплено тестом test_unknown_query_parameter_is_not_rejected, чтобы строгость нельзя было ввести незаметно;

  • Observation.satellitenull=True, поэтому obj.satellite.norad_cat_id без защиты отдал бы 500 на весь запрос расписания, а не на одно задание.

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

  • test_old_station_polling_its_own_schedule_is_unaffected — запрос той формы, которой ходит парк: токен в заголовке, ground_station, lat, lon, alt, параметра capabilities нет. Это другая ветка list(), чем анонимный GET: та, что пишет last_seen. Ответ — те же одиннадцать полей, heartbeat по-прежнему регистрируется;

  • test_the_opt_in_adds_a_field_and_changes_nothing_else — ответы с параметром и без сравниваются друг с другом целиком, со значениями. Это ловит вторую половину правила 21: поле не пропало и не появилось, но стало сериализоваться иначе. Сравнение двух ответов, а не с выписанным эталоном: повторять в тесте, как сериализатор считает frequency, значило бы проверять тест, а не код;

  • список из одиннадцати имён выписан в теле теста, а не прочитан у сериализатора: тест, спрашивающий у кода, что тот производит, не может поймать код, производящий новое.

Прогон на коде до правки — та самая сигнатура, которая и требуется: четыре проверки опт-ина падают, четыре проверки «ответ прежний» проходят. То есть неизменность ответа для необновлённой станции верна и до, и после правки, а не создана ею.

Второй, независимый слой — сделать отказ видимым. Опт-ин защищает от известного расхождения; от следующего защищает то, что клиент перестал считать упавший граф успешным наблюдением: Flowgraph.exit_code заполняется, когда граф кончился сам, пишется в лог ошибкой и уезжает на портал в client_metadata.radio.exit_code (None — проход штатный). Код 2 там означает argparse диспетчера, то есть ровно этот класс отказов, — и теперь он виден в tools/portal_stats.py, а не только на станции.

Тем же приёмом 2026-08-27 сделана видимой подмена режима — см. Фазу 2.

Честная граница обоих слоёв: станцию, которая уже не обновится, не защищает ни один из них. Её защищает только неизменность ответа портала. Клиентские правки закрывают следующее расхождение, не текущее.

Правило прижилось на портале и расширено им (состояние на 2026-09-10):

  • оно записано инвариантом 7 в docs/ai/context.md портала, а весь /api/v2/ вынесен в network/api/urls_v2.py с той же формулировкой в докстринге. Аналитика (needs-attention и суточные метрики) переехала под /api/v2/ 2026-09-02, старые пути /api/analytics/… остались алиасами;

  • опт-ин стал списком: JobSerializer.OPT_IN_FIELDS = norad_cat_id, transmitter_parameters, max_altitude (два последних — с 2026-09-07). Механизм тот же, добавление имени в кортеж — вся проводка. Клиент объявляет только norad_cat_id (CAPABILITIES в src/soniks_client/api.py). transmitter_parameters — снимок Transmitter.params на момент планирования, то есть параметры передатчика, по которым Фаза 3.2 собиралась выбирать декодер; max_altitude — максимальная высота прохода. Оба ждут потребителя в клиенте, объявлять их «на всякий случай» незачем;

  • удалённое из модели поле vetted_status продолжает отдаваться в /api/observations/ вычисленным — тест DeprecatedVettedFieldsTest сторожит, что ответ парку не изменился, хотя колонки больше нет.

Обратная сторона того же правила — портал стал строже к тому, что присылает станция, и это парка не касается по построению только пока станция присылает то же, что раньше. Два новых постоянных 400 на пути PUT /api/observations/<id>/ с demoddata: malformed_filename (п. 19) и frame_outside_window (2026-08-28: метка кадра дальше DEMODDATA_TIME_TOLERANCE_MINUTES, по умолчанию 5 минут, от окна наблюдения). Что с ними делает клиент — см. Фазу 1, durable spool.

Целевая архитектура

Всё, что станция видит из сети, — один хост sonik.space. Других обращений нет.

СТАНЦИЯ                                 sonik.space — единственный доступный хост
┌──────────────────────────────┐
│ soniks-agent  (docker.sock)  │◄──── GET  /api/v2/stations/<id>/state/   ─┐
│  • desired-state pull        │────► POST /api/v2/stations/<id>/status/   │
│  • OTA + health-gate + откат │◄──── GET  /v2/    (registry)              │
│  • применение конфига        │◄──── GET  /api/v2/bundles/<ver>/          │
│  • калибровка gain           │                              ┌───────────▼────────────┐
│  • телеметрия / логи / WF    │────► WS ──────────┐          │  soniks-network        │
└───────────┬──────────────────┘                   │          │  (Django, WSGI)        │
            │                                      │          │  • jobs, observations  │
            │ управляет                            │          │  • ReleaseChannel      │
┌───────────▼──────────────────┐                   │          │  • reported_status     │
│ soniks-client                │                   │          │  • state/ (desired)    │
│  • планировщик проходов      │────► PUT/POST ────┼─────────►│  • UI монитора         │
│  • durable spool (persistent)│      артефакты    │          ├────────────────────────┤
│  • /healthz                  │                   │          │  registry-прокси /v2/  │
└───────────┬──────────────────┘                   │          │  (кэш Docker Hub)      │
            │ subprocess                           │          │  sonikspace/soniks-*   │
┌───────────▼──────────────────┐         ┌─────────▼────────┐ │  librespace/hamlib     │
│ flowgraph_dispatcher         │         │ telemetry-ingest │ └────────────────────────┘
│  ┌─ единый граф приёма ────┐ │         │ (FastAPI/uvicorn)│
│  │ SDR→доплер→WF/IQ/аудио  │ │         │ Redis + Postgres │
│  └──────────┬──────────────┘ │         └──────────────────┘
│             │ поток          │
│  ┌──────────▼──────────────┐ │
│  │ gr-satellites (satyaml) │ │  ← основной декодер, потоковая выдача кадров
│  │ satnogs-графы (APT/SSTV/│ │  ← только там, где gr-satellites не умеет
│  │ CW/FM-аудио)            │ │
│  └─────────────────────────┘ │
└──────────────────────────────┘

Фаза 0 — остановить кровотечение

Только баги. Никакой архитектуры. Каждый пункт независим и проверяем.

soniks-client-new

  1. Сделано на стороне диспетчера (см. п. 12), клиент правки не требовал. parse_known_args() плюс явные --norad-cat-id и --lo-transverter, которые принимаются, но никуда не передаются: ни один .grc их не объявляет. origin/soniks-update не использовалась — см. решение 19: ветка экспериментальная и мёржу не подлежит. Поведение написано заново, а не перенесено оттуда.

    Осталось: правка живёт в исходниках soniks-flowgraphs, а на станции диспетчер лежит внутри образа. Пока образ не пересобран, stand.sh contract продолжает показывать отказ.

    Предусловие для портала снято решением 21. Оно требовало выкатки на весь парк, а её не будет никогда. Вместо него — опт-ин: портал отдаёт norad_cat_id тому, кто объявил capabilities. Обе половины сделаны 2026-08-27. Подробности — ниже.

  2. Сделано. api.py — разбор по одному заданию: битая запись пропускается, остальные планируются. Заодно проверка isinstance(jobs_data, list): без неё ответ изменившейся схемы отсеивал все задания и уходил в синхронизацию пустым списком, то есть «портал снял все проходы». Тесты — tests/test_api_jobs.py, комментарий в tests/test_models.py:57 стал правдой.

  3. Сделано. models.pyparse_datetime приводит к UTC: строка без смещения считается UTC, со смещением — переводится. Тесты tests/test_models.py перевыставлены под новое поведение.

  4. Сделано. jobs/observation.pyrun_pre_script() под собственным try (проход продолжается, как и при падении post-скрипта), весь блок после file_observer.start() — в try/finally, где finally гасит наблюдателя и ставит send_data_after_observation.

  5. Сделано. main.pysys.exit(1) после исчерпания попыток. SystemExit не ловится except Exception в run() и доходит до Docker. Тест — tests/test_application.py.

  6. Сделано. 404 откладывает данные в incomplete/, а удаляет их повторная отправка, получившая 404 второй раз — не раньше чем через RESENDING_INTERVAL_IN_MINUTES. Счётчик не понадобился: состояние — сам факт нахождения директории в incomplete/.

  7. Сделано. all_files_sent = False в ветке OSError.

  8. Сделано. station.py — пустая и пробельная строка не проходят валидацию. Проверка именно на пустоту, а не на ложность значения, как предлагалось здесь изначально: ELEVATION=0 (уровень моря) и LATITUDE=0.0 (экватор) — валидные величины, и if not value забраковал бы станцию на экваторе. Тест — tests/test_station_settings.py.

  9. Сделано. PATHS__BASE: /var/lib/soniks-client/data через environment: в обоих compose — на уже смонтированный постоянный том, новых томов не потребовалось. /tmp остаётся tmpfs.

  10. Сделано. stop_grace_period: 15s в обоих compose, комментарий в main.py приведён в соответствие.

  11. Сделано. --set "*.args.CLIENT_VERSION=${CI_COMMIT_TAG}" в bake, плюс проверка совпадения тега с pyproject.toml перед сборкой. Сверка трёх объявлений версии — не отдельная джоба, а tests/test_version_consistency.py: джоба pytest уже есть и идёт на каждый пуш, а тест ловит расхождение ещё до пуша.

soniks-flowgraphs

  1. Сделано. flowgraph_dispatcher.pyparse_known_args() с предупреждением ignored unknown arguments, плюс приём и обнуление --norad-cat-id и --lo-transverter — тем же приёмом, что и --framing. Тесты — tests/test_dispatcher.py, две новые проверки.

    Правка нужна по-прежнему, но предусловием правки портала больше не является: гейтом стал опт-ин клиента, а не состояние парка (решение 21).

  2. Сделано, но не тем, чем записано здесь изначально. Пункт стоял как «опечатка в одну строку, проходы PD120 молча идут обычным FM». Проверка перед правкой это опровергла: граф выбирает диспетчер по --mode, и его таблица (flowgraph_dispatcher.py:162) всегда указывала на satnogs_sstv_pd120_demod.py — демодуляция была верной.

    Ломалось соседнее. script_filename уходит шестым аргументом в satnogs-pre/satnogs-post, оттуда в scripts/find_samp_rate.py, а тот ищет в имени подстроку _sstv. С satnogs_fm.py функция возвращала 48000 вместо реальной out_samp_rate графа 4·4160·4 = 66560, и следствий было два, оба тихие: gr-satellites питался UDP-потоком IQ с неверной разметкой частоты, а IQ-дамп получал неверную частоту в имени файла.

    Правка та же самая — SCRIPTS["SSTV_PD120"] вместо SCRIPTS["SSTV"], — но тест поставлен на следствие, а не на букву таблицы: find_samp_rate(9600, MODES[mode]["script_filename"]) == 66560 для APT и SSTV_PD120. Комментарий в flowgraph.py, называвший всю таблицу справочной, исправлен: именно он усыпил пункт на два обхода.

  3. Сделано. tests/e2e_check.py подключён к джобе flowgraphs — там образ librespace/gnuradio, то есть pmt, ради которого тест и держали снаружи. В apt-get install дописаны python3-zmq, python3-numpy, python3-soundfile. Флоуграф тест подменяет своим stub’ом, поэтому SDR и собранные рядом satnogs_*.py ему не нужны; прогон ~25 секунд, сеть только loopback. Локально не проверено — на хосте нет pmt; подтверждается первым пайплайном после пуша.

  4. Сделано. Все четыре расхождения жили в docs/troubleshooting.md (код возврата, грейс, суффиксы) и в паре docs/dev/grc-conventions.md + docs/outputs.md (частота). docs/dispatcher.md был прав всё это время, поэтому правился расходящийся с ним текст, а не наоборот:

    • out_samp_rate APT и SSTV_PD120 — 66560 (4*4160*4), 48000 остаётся только у fm.grc. В обоих графах есть audio_samp_rate = 48000, но он уходит лишь в последний ресемплер аудио — отсюда и взялась ошибка;

    • код возврата флоуграфа пробрасывается, сигнал даёт 128+N. Оговорка сохранена: клиент этот код не проверяет, поэтому для него упавший граф по-прежнему выглядит успешным наблюдением;

    • грейс — 2 секунды на фазу, 4 в худшем случае;

    • абзац про суффиксы _0/_1 удалён целиком: имена кадров имеют микросекундное разрешение, а ссылка вела на запись в known-issues.md, которой не существует.

soniks-network

  1. Сделано. rating_tasks.py сравнивает ~~downlink_mode.name~~ строку Observation.transmitter_mode — с 2026-08-28 наблюдение хранит снимок передатчика на момент планирования, и оценка читает его, а не сегодняшнее значение справочника. Отсутствие режима автоматического «good» по-прежнему не даёт: неизвестный режим — ровно тот случай, который эта проверка не должна пропускать. Тест RateObservationDataUploadTest в network/base/tests.py; проверено, что на старом коде три его проверки из четырёх падают.

    Пересчёт истории — команда recalculate_observation_ratings, по умолчанию --dry-run, пишет только с --apply. Правило щадящее: сбрасывает в 0 наблюдение, у которого одновременно status == 100, есть demoddata, режим ∈ {CW, FM} и waterfall_status пуст. Последнее условие обязательно: в поле status вердикт человека неотличим от «good», выставленного багом, и различает их только факт веттинга. На проде команда не запускалась. На паузе решением мейнтейнера 2026-09-10: завышенная статистика за старый период допустима, важно, чтобы после обновления оценка работала честно — а это делает сама правка, не пересчёт. Команда остаётся в портале на случай, если к истории захочется вернуться.

    Ожидаемое следствие правки: success_rate по сети упадёт. Это и есть правда, которую баг скрывал.

  2. Сделано. Порог вынесен в OBS_AUDIO_DURATION_TOLERANCE, умолчание 120 секунд вместо зашитых 60. Там же scheduled_duration.seconds заменено на .total_seconds(): .seconds заворачивается на сутках.

  3. Сделано выключателем, а не разрывом. JobView.get_permissions() отдаёт AllowAny при JOBS_ALLOW_ANONYMOUS (умолчание True — поведение не изменилось) и IsAuthenticated при False. Счётчик анонимных запросов — предупреждение в лог из list() на каждый.

    Пересмотрено под решением 21 (2026-08-27). Формулировка «щёлкать нельзя, парк должен перейти целиком» неверна вдвойне: перехода целиком не будет, и он здесь не нужен. Парк аутентифицируется уже сегодня, а станция с протухшим токеном получает 401 до проверки прав и ослепла независимо от флага. То есть False затрагивает третьих лиц, а не парк. Что действительно решает счётчик — есть ли в парке станция, ходящая вовсе без токена; вот её флаг убьёт. Щёлкать, когда счётчик покажет ноль за период, покрывающий все станции.

    Счётчик засоряет soniks-monitor (найдено 2026-09-10): монитор опрашивает /api/jobs/?ground_station=… раз в 30 секунд без токена. Каждая станция с монитором даёт анонимные запросы со своим ground_station, неотличимые по счётчику от станции без токена, и после False монитор перестанет видеть расписание. Прежде чем щёлкать — вычесть или выключить мониторы.

    Попутно снято неверное утверждение из docs/ai/context.md: станция с протухшим токеном не продолжает получать задания. Клиент шлёт Authorization: Token , и DRF отвечает 401 до проверки прав. Анонимный доступ — про третьих лиц, а не про парк.

  4. Сделано. Разбор метки времени вынесен в _frame_datetime(): оба формата перебираются циклом, IndexError/ValueError дают None, а вызывающий отвечает 400 с кодом malformed_filename вместо 500. Контракт имени сведён в одну функцию demoddata_path() в network/base/models.py — комментарий «на change of the string bellow, change it also at api/views.py» удалён вместе со второй копией.

  5. Сделано. «Watefall» → «Waterfall» (и в докстринге serializers.py). Тела трёх ответов 403 переведены в {"detail": …, "code": "already_uploaded"}. Подстрока has already been uploaded сохранена дословно: на ней держится api.py:229 во всём парке, и тест AlreadyUploadedResponseTest сторожит именно её, а не формулировку.

  6. Отменено 2026-08-27. Обвязка Sentry снята целиком вместе с sentry-sdk: слать некуда и не планируется, а выключенный SDK — это зависимость в образе и выключатель, который никто не щёлкнет. Вместо него вернулся штатный mail_admins на логгере django.request: он был потерян при переопределении логгера, ADMINS заполнен и EMAIL_* настроены, но ни одного письма об ошибке не уходило годами, и каждый 500 жил только в docker compose logs web. ADMINS теперь смотрит на EMAIL_ADMIN, а не на DEFAULT_FROM_EMAIL — второй это адрес отправителя, обычно no-reply.

  7. Сделано частично, и одно отклонено. COPY .env /workdir/ из Dockerfile убран — секреты больше не уезжают слоем в registry; файл приезжает bind-mount’ом в /workdir/.env, путь AutoConfig не менялся. Порт Postgres прибит к 127.0.0.1:5432.

    CORS_ALLOW_ALL_ORIGINS = True оставлено намеренно — решение человека 2026-08-24: сеть открытая, её API предназначен для запросов откуда угодно, это назначение сайта. В settings.py рядом стоит комментарий, чтобы следующий заход не «починил» это снова.

  8. Переделано 2026-08-27. Оговорка «реальный скрипт подменяется в CI из $FILES_FOLDER» оказалась не примечанием, а сутью: копия побеждала репозиторий всегда — образ собирается на сервере и запекает то, что лежит в bin/, — поэтому правка п. 23 не исполнялась нигде. Копии двух серверов успели разойтись: стенд на восьми воркерах, прод на шестнадцати, и оба с run_celery_prod, где exec стоял без аргументов, worker и beat уходили в фон, а PID 1 держал tail -f /dev/null. Смерть любого из них оставляла контейнер Up, restart: on-failure не срабатывал.

    Копирование убрано из обеих джоб, репозиторий снова единственный источник. Worker и beat разведены по сервисам celery и celery-beat — каждый свой PID 1. Воркеры считаются по формуле самого gunicorn (nproc * 2 + 1), таймаут 600; GUNICORN_WORKERS и GUNICORN_TIMEOUT переопределяют, но попадают в контейнер только через environment: в compose — .env рядом с docker-compose.yml читается Compose для подстановки в сам YAML и внутрь контейнера не передаётся.

    С 2026-09-02 образ портала больше не собирается на сервере. Джоба publish собирает его в CI и кладёт в registry GitLab, выкатка тянет готовый образ по SONIKS_IMAGE (docker-compose pull). Оговорка «образ собирается на сервере и запекает то, что лежит в bin/» отсюда снята; docs/dev/deploy.md портала на 2026-09-10 всё ещё описывает сборку на сервере — это его долг, не наш.

Найдено попутно, вынесено в Фазу 1

Три вещи, обнаруженные при закрытии пп. 13–23 2026-08-24. Ни одна не входила в Фазу 0, все три стоят недорого и ни одна не была сделана.

  1. Имя .deb флоуграфов — межрепозиторный контракт без единой проверки. debian/control во флоуграфах переименовал пакет в soniks-flowgraphs (248e606, 2026-08-22), а Dockerfile клиента ставил ../satnogs-flowgraphs_*.deb. Glob перестал раскрываться, apt падал, образ не собирался вовсе — и это оставалось невидимым, потому что джоба docker идёт только по тегу, а тесты и линтеры сборку не трогают. Имя исправлено; сам класс отказа закрывается пиннингом FLOWGRAPHS_BRANCH на тег и сборкой образа не только по тегу — обе сделаны, см. «Воспроизводимость сборки» ниже.

  2. Сделано. setup.cfg:99python_files = tests.py test_*.py. Модулей оказалось десять, а не одиннадцать, и прогон вышел куда спокойнее ожидаемого: 71 проверка из 73 зелена сразу. Два падения — оба в test_utils.py, и оба не про режимы:

    • ожидаемый URL расходился с кодом на один пробел () ... против )...). Пробел стоит в network/base/utils.py с досоникового времени и уезжает пользователям в ссылку «обсудить наблюдение» — правился тест, а не код;

    • тест ходил в живой интернет: requests.get на community.libre.space не был замокан, а ID наблюдений в фикстурах вообще satnogs’овские. Он «проходил» лишь потому, что сеть была и форум отвечал как ожидалось; без сети или при правке чужого форума результат переворачивался. requests.get замокан, прогон стал 0.07 с вместо 1.08 с.

    Итог по сети: 26 падений и 132 прохода против 26 и 48 до правки — ни одного нового падения, все 26 — известный шум django-compressor (sass: not found).

  3. Сделано, и пункт оказался крупнее записанного. bin/djangoctl.sh:75 отдавал -max-tasks-per-child=200 обоим подкомандам. Проверка celery 5.2.7 показала, что celery beat по этому пути не стартует вовсе: с одним дефисом click читает его как -m и выходит с No such option, а с двумя — с No such option: --max-tasks-per-child, потому что у beat такой опции нет в принципе. То есть исправление дефиса в одну строку сломало бы beat ровно так же. Опция оставлена только worker, ветки case разведены.

    ~~Как и после п. 23: реальный скрипт в проде подменяется из $FILES_FOLDER — синхронизировать на сервере руками.~~ Отпало вместе с п. 23: копирование djangoctl.sh из обеих джоб убрано, репозиторный скрипт исполняется сам.

Оговорка по закрытым сетям

Станции, у которых открыт только sonik.space, физически не могут получить исправления этой фазы — им нечем скачать новый образ. Для них фактический порядок registry Фаза 0, и до появления registry их чинят руками. Порядок фаз это не меняет, но означает, что registry — самый ранний пункт Фазы 1, а не последний.

Готовность фазы

На стенде проходит реальное наблюдение, по итогам которого есть непустой payload.ogg, непустой водопад (.dat и .png), хотя бы один файл data_*, client_metadata с ненулевым snr_db, код возврата диспетчера 0, и в логе нет unrecognized arguments. Проверяется dev-агентом по команде человека — см. «Стенд» и решение 23. Здесь и в готовности остальных фаз стенд — проверка, которую можно назначить, а не гейт перехода к следующей фазе.

Фаза 1 — надёжность обмена

Durable spool

Механизм директорий остаётся: он рабочий, и подменять его очередью в SQLite значило бы переписать весь file_manager ради проблемы, которая в другом. Меняются четыре вещи:

  • PATHS__BASE на постоянном томе (сделано в Фазе 0) — incomplete/ переживает перезапуск;

  • ~~каждый артефакт получает content-hash~~ сделано 2026-08-27, портальная половина закрыта (soniks-network@b2f64840). Повторная заливка того же хеша даёт 200 без записи в хранилище, чужого — перезапись. Оборвавшуюся загрузку снова можно перезалить.

    Клиент не участвует, и это оказалось важнее всего остального. Хеш считает портал по принятому телу — _artifact_sha256 в network/api/views.py. Раньше предполагалось обратное: что хеш присылает клиент, а значит парк нужно обновить раньше портала. Из этого следовало, что определение дубликата по имени файла остаётся навсегда. Предположение снято: портал видит байты, парку знать о хеше незачем, порядок «станции раньше портала» соблюдается сам собой.

    Поля — Observation.payload_sha256, Observation.waterfall_sha256, DemodData.sha256, миграция 0035_artifact_content_hash. Пустой хеш означает строку, записанную до миграции: там разбор по имени файла и ответ 403 с подстрокой has already been uploaded сохраняются дословно — на ней стоит api.py:293 во всём парке 2.2.2.

    Проверено на dev 2026-08-27, наблюдение 1117: 200 / 200 / 200 вместо прежних 200 / 403 / 403, при повторе mtime файла не сдвинулся;

  • ~~select_for_update не держит строку Observation всё время записи артефакта~~ сделано 2026-08-27: блокировка снята с пути update целиком. Дубликаты разбираются по хешу, а каждая запись идёт через save(update_fields=...), поэтому две выгрузки разных артефактов одного наблюдения не затирают друг друга. Потолок отмечен в коде: две одновременные выгрузки одного артефакта обе пишут в хранилище, один файл осиротеет, строка остаётся согласованной;

  • ~~метаданные наблюдения кладутся в spool наравне с файлами~~ сделано 2026-08-26: post_processing() пишет metadata_<id>_<время>.json в директорию наблюдения, выгружает его общая очередь по новому префиксу. Канал G закрыт. Отправитель выбирается по префиксу: метаданные уходят полями формы, остальное multipart. Повтор безопасен и без content-hash — портал перезаписывает поля наблюдения, а не отвергает дубль;

  • ~~файл удаляется только по подтверждённому приёму~~ проверено 2026-08-26: на пути метаданных дыр нет, остальные места разобраны и оставлены как есть (удаление источника при коллизии имени в complete/ и rmtree по второму 404 происходят уже после подтверждённой выгрузки).

~~Открыто с 2026-08-28: постоянный 400 повторяется вечно.~~ Решено 2026-09-10 (решение 24). Портал отвечает на кадр 400 в двух случаях, и оба — навсегда: malformed_filename (имя не по контракту) и frame_outside_window (метка кадра дальше DEMODDATA_TIME_TOLERANCE_MINUTES, по умолчанию 5 минут, от окна наблюдения; допуск — на уход часов станции, а не на кадр из другого прохода). Раньше клиент такой ответ от временного не отличал, файл уходил на повтор каждые RESENDING_INTERVAL_IN_MINUTES без предела и держал в incomplete/ всё наблюдение.

Теперь 400 — это FileRejectedError, подкласс FileNotUploadedError, и устроен он как 404: первый отказ откладывает файл в incomplete/ (кратковременный 400 от ошибки портала не должен стоить кадра), повторный при повторной отправке закрывает файл как выгруженный — удаляет либо переносит в complete/ по REMOVE_OBSERVATION_DATA. Код ответа не разбирается: любой 400 на те же байты повторится, а новый код портала сработает без обновления станции. Правило одно для кадров и метаданных.

Корень frame_outside_window — часы станции: метку кадра ставит диспетчер по ним. До health-gate Фазы 2 уход часов виден так (решение 25): на каждой сверке расписания клиент сравнивает часы с заголовком Date ответа портала, расхождение — поле clock_skew_seconds в /healthz, больше 60 секунд — предупреждение в лог. Лишних обращений это не стоит.

Транспорт

Сделано 2026-08-26. Одна requests.Session на процесс плюс HTTPAdapter(max_retries=Retry(...)): до четырёх попыток, паузы 0/2/4 с, только PUT и только временные отказы (429, 500, 502, 503, 504). 404 и 403 «has already been uploaded» не повторяются — осмысленные ответы. GET расписания не повторяется тоже: он идёт раз в минуту, а четыре попытки по 45 с съели бы MISFIRE_GRACE_TIME и раздули бы last_sync_age в /healthz. respect_retry_after_header=False — urllib3 спит по Retry-After без верхней границы, а троттлинг портала отдаёт там всё окно лимита. Настоящий долгий backoff — очередь incomplete/, а не urllib3.

Батч-эндпоинт POST /api/v2/observations/<id>/frames/ из объёма выведен: на портале его нет, а заводить его до content-hash смысла нет — идемпотентность нужна раньше батча.

Портальная сторона — сделано 2026-08-27, см. Durable spool выше: идемпотентность по хешу вместо имени файла, select_for_update с пути update снят. Батч остаётся выведенным из объёма: теперь уже не потому, что нет идемпотентности, а потому, что на портале эндпоинта нет и парк его не просит.

Флаг активного прохода

Не лок. Взаимоисключающий лок на execute_observation уже рассматривался и был отклонён (roadmap.md, раздел 2.3) с обоснованием: портал наложений не выдаёт, а лок означал бы осознанный отказ от второго наблюдения. Решение остаётся в силе.

Фазе 2 нужно другое: агент должен знать, идёт ли сейчас проход, чтобы выбрать окно для обновления. Это наблюдаемость, а не взаимоисключение — флаг в /healthz плюс время следующего прохода. Отказов от наблюдений не добавляет.

Сделано 2026-08-26 вместе с /healthz: поля observation_running и next_observation. Внутри — множество идентификаторов активных проходов, чтобы завершение одного не гасило признак у наложившегося второго.

Здоровье и идентичность

Здоровье сделано 2026-08-26. GET /healthz (src/soniks_client/health.py, порт HEALTH__PORT, по умолчанию 8080) отдаёт состояние планировщика, время последней успешной сверки расписания, глубину incomplete/, признак идущего прохода и время следующего. Сервер — stdlib ThreadingHTTPServer в потоке-демоне: заводить веб-фреймворк ради одного GET не потребовалось. HEALTHCHECK добавлен в Dockerfile, healthcheck: — в оба compose.

Три решения, которые стоит держать в памяти:

  • 503 — только про станцию. Мёртвый планировщик или потерянное задание register_observation_jobs. Неудачный поход на портал кодом ответа не считается: лежащий портал перезапуском станции не чинится, возраст last_sync отдан потребителю как данные. unhealthy при этом ничего не лечит — Docker больной контейнер не перезапускает, это сигнал оператору;

  • признак прохода — множество идентификаторов, а не мьютекс. Наблюдаемость для агента Фазы 2; лок остаётся отклонённым (см. выше);

  • бинд на 0.0.0.0, а не 127.0.0.1. Наружу хоста не торчит, пока в compose нет ports: и network_mode: host, зато читается из соседнего контейнера — L1 health-gate Фазы 2 живёт именно там.

Станционный токен отложен (решение 2026-08-26). ClientIDAuthentication (network/api/authentication.py) по-прежнему написан и не подключён. Разбор показал, что правка двусторонняя и не сводится к одному подключению класса:

  • поле Station.client_id на портале есть (network/base/models.py:565 на 2026-09-10, с индексом) и заполняется через UI регистрации, но объявлено blank=True без unique: Station.objects.get(client_id="") даст MultipleObjectsReturned, то есть 500 вместо 401. Переделка модели станции 2026-09-06 (состояние стало вычисляемым из last_seen, is_available и testing, колонка status удалена) этого не коснулась;

  • класс ждёт Authorization: <client_id> без схемы, а клиент шлёт Token <пользовательский токен> — включение «как есть» оглушает парк. Совместимый путь — своя схема (Authorization: ClientID <id>) рядом с TokenAuthentication. Под решением 21 это «рядом» — навсегда, а не переходный период: TokenAuthentication снять нельзя никогда, пока в парке есть хоть одна станция, которая умеет только его;

  • у клиента понятия client_id нет вовсе: нужна переменная STATION__CLIENT_ID, релизный тег и действие оператора на каждой станции.

Воспроизводимость сборки

Сделано 2026-08-24, подтверждено сборкой на стенде. GRSATNOGS_URL переведён на собственный форк gr-soniks, GRSATNOGS_BRANCH — на тег v3.1.0.1, FLOWGRAPHS_BRANCH — на тег 2.6.0. Кода это не меняет: тег форка указывает ровно на тот коммит, что был у master LSF, а 2.6.0 поставлен на soniks-flowgraphs@14a8d60, то есть на HEAD ветки. Меняется другое — источник зафиксирован и записан.

Тег 2.6.0 пришлось создать: на origin флоуграфов не было ни одного тега 2.x, локальные 2.02.5.2 унаследованы от апстрима и никогда не пушились. А ближайший из них, 2.5.2, отстаёт на 18 коммитов и не содержит ни правки диспетчера (п. 12), ни переименования .deb (п. 24) — пиннинг на него откатил бы Фазу 0 внутри образа.

Попутно из цепочки 2 удалён кэшбастер ADD …/repository/branches/${FLOWGRAPHS_BRANCH}: он существовал ради плавающей ветки, на неизменяемом теге кэш слоя переиспользуется законно, а сам endpoint на тег отвечает 404 и уронил бы сборку.

Коммит флоуграфов записывается в образ — тем же приёмом, что уже был для gr-satnogs: flowgraphs-git-hash.txtflowgraphs_git_hash в core/_version.pyclient_metadata.radio.flowgraphs. Теперь по наблюдению видно, каким кодом оно принято: тег можно переставить, коммит — нет. Проверено на собранном образе: gr_satnogs_git_hash = 4defcfc4…, flowgraphs_git_hash = 14a8d605….

Дыра, найденная 2026-09-10: gr-satellites не пиннился. Dockerfile клонировал его с HEAD main без тега и коммит никуда не писал — ровно класс «два одинаковых тега — разный код», который закрывался здесь для gr-soniks и флоуграфов. Причём gr-satellites — второй декодер на тот же сигнал. Теперь GRSATELLITES_REF — коммит main (cfa6aac8, HEAD на 2026-09-10; тег v5.9.0 стоит на ветке maint и старше того, что база уже брала), клон — fetch --depth 1 этого коммита, хеш — gr-satellites-git-hash.txtgr_satellites_git_hashclient_metadata.radio.gr_satellites (решение 32). Действует со следующей пересборки базы; до неё в soniks-base:2026-08-27 лежит неизвестный коммит.

Платформы — решение 26. Парк не однороден: ~50 станций, в основном RPi (linux/arm64), и есть станции на ноутбуках с Ubuntu и Windows (linux/amd64). Опубликованные образы — только arm64, build.sh собирал одну платформу машины сборки. Теперь build.sh собирает docker buildx build --platform "$PLATFORMS": умолчание linux/arm64 (первый этап выкатки), второй этап — PLATFORMS=linux/arm64,linux/amd64, один manifest list. Несколько платформ — только с --push: в локальный docker manifest list не загружается. Предусловия (binfmt, builder docker-container) — «Выкатка образа» в Участие в разработке.

Версия клиента больше не объявляется в репозитории. Раньше число лежало в трёх местах (pyproject.toml, ARG CLIENT_VERSION, _version.py) и джоба CI их сверяла. Теперь версию задаёт тег GitLab и только он: CI подставляет $CI_COMMIT_TAG в CLIENT_VERSION, а в репозитории все три места — заглушки 0.0.0. Сверка тега с pyproject.toml из CI удалена, сверять стало нечего; tests/test_version_consistency.py вместо неё сторожит, чтобы настоящее число туда не вернулось, и чтобы набор имён в _version.py совпадал с набором, который записывает Dockerfile (забытое имя даёт AttributeError только в контейнере на станции).

Публикация в два registry. По тегу образ уезжает и на Docker Hub, и в registry самого GitLab ($CI_REGISTRY_IMAGE, токен джобы, своих секретов не требует). Docker Hub остаётся, пока оба docker-compose.yml тянут latest-addons оттуда. ~~Третьей парой строк в bake сюда добавится sonik.space, когда он появится.~~ Не добавится: sonik.space — прокси Docker Hub, push в него нет (решение 34).

CI собирает образ не только по тегу. Джоба docker_build_check: одна платформа, нативная для раннера, без публикации. Автоматически — по расписанию (поломка видна в течение суток), на ветках и MR — вручную и не блокируя. Мультиарх под QEMU идёт часами и остаётся за релизной джобой docker. Правило schedule выстрелит, только когда в настройках проекта заведён scheduled pipeline. Отложено решением мейнтейнера 2026-09-10 — пока джоба запускается вручную; поломка сборки без тега видна при первом ручном запуске, а не в течение суток.

Автономность станции

Обязательное условие фазы. Часть парка стоит в сетях, где открыт только sonik.space. После этой фазы станция не должна обращаться никуда больше.

Registry. registry:2 за nginx на sonik.space/v2/. Форма вынужденная: Docker-клиент всегда ходит в https://<host>/v2/..., повесить registry на префикс пути нельзя, а поддомен registry.sonik.space в тех же сетях, скорее всего, тоже закрыт. Отсюда имена вида sonik.space/soniks-client:2.3.0. Нужен client_max_body_size 0 и проброс заголовков. ~~Авторизация — nginx auth_request к порталу по станционному токену, тому же, что вводится в этой фазе.~~ Чтение анонимно, ~~push — только из CI~~ push нет вовсе (решения 27, 34): секретов в образах нет, они и сейчас публичны на Docker Hub, а станционный токен отложен. Анонимное чтение снимает с каждой станции docker login и зависимость обновлений от токена — ротация токена владельца резала бы ещё и их. ~~Хранится текущая и предыдущая версия каждого образа: без предыдущей автооткату (решение 7) некуда откатываться, если станция уже удалила старый образ.~~ Снято решением 34: предыдущий образ держит сама станция (решение 40).

~~Свои образы для всего. librespace/hamlib:4.5.4 пересобирается и публикуется как sonik.space/soniks-hamlib:4.5.4. Ни одного чужого образа в docker-compose.yml.~~ Свой хост для всего (решение 36): librespace/hamlib:4.5.4 — это debian:bookworm плюс apt-пакет libhamlib-utils=4.5.4-1+b1 и entrypoint, а наша база стоит на том же bookworm с тем же пакетом. Пересборка дала бы тот же пакет ещё одним образом на поддержке; станция тянет образ LSF через прокси, sonik.space/librespace/hamlib:4.5.4.

Состояние на 2026-09-10: registry на sonik.space ~~нет~~ сделан в портале, на серверах не включён (см. ниже), ~~вопросы 6 и 7 открыты~~ вопросы 6 и 7 отвечены (решения 26–27), проект registry — пакет 2. Переходят на него все станции, а не только закрытые: закрытых 5–15, среди них возможны волонтёрские, режется ли 443 по SNI/DPI — неизвестно. Единственное движение — у портала: его собственный образ с 2026-09-02 собирается в CI и живёт в registry GitLab, откуда его тянут стенд и прод. То есть nginx перед порталом уже умеет отдавать приложение, но /v2/ на нём не поднят, и для станции ничего не изменилось.

Форма пересмотрена 2026-09-10 — pull-through cache (решения 34–35). Разбор упёрся в противоречие: решение 27 требовало push только из CI, а решение 26 оставило CI-джобу docker незачиненной — пушить некому. Registry стал прокси Docker Hub (registry:3.0.0, proxy mode): станция тянет sonik.space/sonikspace/soniks-client:<тег>, registry при промахе берёт образ с Docker Hub и кэширует. Push-канала, секретов на станции и ретеншена нет:

  • build.sh публикует на Docker Hub, как и раньше. Digest у копии тот же — проверено смоуком: librespace/hamlib:4.5.4 через прокси отдал sha256:b18d44cd…, как и Docker Hub. Поэтому агент пиннит по digest;

  • «текущую и предыдущую версию» хранит Docker Hub, а откат агента берёт образ с самой станции (решение 40) — registry нужен только для доставки. Кэш — локальный диск сервера, неиспользуемое вытесняется через 168 ч;

  • nginx пропускает только sonikspace/* и librespace/hamlib: без белого списка это открытое зеркало всего Docker Hub за наш канал. Прокси ходит с read-only токеном Docker Hub — анонимные промахи упираются в лимит по IP. Пустые учётные данные — анонимный режим, тоже проверено смоуком;

  • сервис — под compose-профилем registry в docker-compose.yml портала, включение и сниппет nginx — раздел «Registry образов станций» в docs/dev/deploy.md портала. Сделано 2026-09-10; на серверах не включён.

Цена формы: источником остаётся Docker Hub — зависимость сервера, не станций. Переход на свой push-registry, если понадобится, — смена режима того же сервиса; имена на станциях не меняются.

Расслоение образа — сделано 2026-08-27. Dockerfile разделён на стадию base (GNU Radio, gr-soniks, flowgraphs, gr-satellites, утилиты, apt- и pip-зависимости — гигабайты, меняется редко) и стадию client (FROM ${BASE_IMAGE}, код клиента, satyaml и скрипты — десятки мегабайт, меняется часто). В закрытой сети с узким каналом это не эстетика: правка клиента перестаёт означать перекачку всего GNU Radio на каждую станцию.

Три решения, которые стоит держать в памяти:

  • один файл, а не два. Умолчание ARG BASE_IMAGE=base — имя стадии выше, поэтому docker build . по-прежнему собирает всё от начала до конца. Передать опубликованный образ — значит пропустить builder-стадию целиком: BuildKit не выполняет стадии, от которых цель не зависит. Побочный плюс: tests/test_version_consistency.py читает Dockerfile и правки не потребовал. Аргумент объявлен до первого FROM: объявленный внутри стадии виден только ей, и FROM третьей стадии получил бы пустую строку;

  • выигрыш даёт не разделение, а пиннинг базы. docker pull и раньше качал только изменившиеся слои — но пока apt и клоны шли заново на каждую выкатку, слои получали новые digest’ы и станция качала всё. Поэтому BASE_IMAGE в build.sh — неизменяемый тег, как GRSATNOGS_BRANCH и FLOWGRAPHS_BRANCH;

  • прошивка LibreSDR уехала в базу. Она приезжала вместе с COPY scripts/*, а тот принадлежит клиентской стадии — запись 4 МБ в /usr/share/uhd легла бы в клиентский слой на каждую выкатку.

Заодно build.sh перестал быть вспомогательным скриптом и стал тем, чем он фактически является, — инструментом поставки: версия позиционным аргументом, два тега, отказ публиковать без версии. Порядок — «Выкатка образа» в Участие в разработке.

Убрать git из рантайма — сделано 2026-08-28. scripts/liveupdate-satyaml.sh больше не клонирует ничего и остался тонкой точкой входа под прежним именем: на нём стоит command: в compose всего парка, и переименование убило бы станции на следующем docker compose pull. Заодно из packages.client ушёл git — других потребителей в рантайме у него не было.

satyaml и sat.cfg приезжают sat-data bundle’ом с портала (GET /api/v2/bundles/latest/, модуль src/soniks_client/sat_data.py), кэшируются на постоянном томе, версия уезжает в client_metadata.radio.sat_data рядом с flowgraphs и exit_code. Вкомпилированное в образ остаётся fallback’ом первого старта.

Четыре решения, которые стоит держать в памяти:

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

  • применение отделено от скачивания. Кэш лежит на PATHS__BASE, то есть переживает перезапуск, а каталог satyaml внутри пакета satellites — в эфемерной ФС контейнера. Значит раскладывать кэш надо на каждом старте, а не только когда приехала новая версия; иначе первый же новый образ откатывал бы станцию на определения из образа, и молча;

  • версия неизменяема. Портал отдаёт 409 на попытку опубликовать другие байты под уже опубликованной версией, а CI собирает архив воспроизводимо (tar --sort=name --mtime=@<время коммита>, gzip -n) — иначе перезапуск того же пайплайна давал бы другие байты и падал бы на собственной защите;

  • правки оператора в sat.cfg не затираются. Файл переписывается, только если совпадает с прошлым доставленным; иначе остаётся как есть, а расхождение уходит в лог. Молчать нельзя ни в ту сторону, ни в другую.

Правки клиента и портала, поэтому решение 21 здесь ограничивает одно: эндпоинт новый, под /api/v2/, и станция, которая за bundle не приходит, его появления не замечает. Ретеншен ниже это учитывает — старую раздачу выключать нельзя, пока за ней кто-то ходит.

Ретеншен. Мультиархитектурные образы с GNU Radio — гигабайты на версию, а для отката надо держать минимум предыдущую. Политика хранения на sonik.space входит в работы фазы, а не откладывается: без неё диск портала кончится молча. На 2026-09-10 политики нет ни для образов, ни для медиа: задача clean_observations, которая когда-то была объявлена и не зарегистрирована, 2026-08-29 удалена как мёртвый код, так что даже заготовки не осталось — см. открытый вопрос 4.

Медиа — решение 28 (2026-09-10). Прод хранит артефакты в S3 на Yandex Object Storage: больше 10 ТБ, вечно, быстрый рост. Порядок — сначала замер по типам (команда — в открытом вопросе 4), потом lifecycle-правило бакета: старое уходит в холодное хранилище. Кода это не требует и данных не теряет — ссылки на странице наблюдения продолжают работать, дороже только чтение. Удалять ли что-то — по цифрам замера. Две вещи из разбора кода портала, которые решают форму правила:

  • у всех типов артефактов один префикс, data_obs/{год}/{месяц}/{день}/{час}/{obs_id}/, поэтому правило по префиксу разделяет их только по возрасту, а не по типу;

  • аудио после приёма машинно читается ровно один раз — process_audio проверяет длительность; дальше это только плеер на странице. Удалять его через портал, если до этого дойдёт, нельзя простой очисткой поля: find_and_rate_failed_observations переоценит такое наблюдение в failed, а станция сможет залить аудио заново. Нужна пометка, как у archived.

Тесты

Закрыть дыры, обнаруженные при разборе: execute_observation, main.Application, api.py (404, 403, таймауты), communication_session.py. Добавить coverage с порогом в CI — сейчас покрытие не измеряется вовсе.

Сделано. Покрытие измеряется: pytest-cov в dev, джоба pytest идёт с --cov=src --cov-fail-under=64 при фактических 66%. Порог сторожит обвал и не растёт сам — поднимать вручную вместе с тестами.

2026-08-26 закрыты execute_observation (tests/test_execute_observation.py: задание выгрузки ставится после любой ошибки прохода, признак /healthz снимается даже при мёртвом планировщике) и api.py (tests/test_api_transport.py: 404, ошибка портала, таймаут, параметры Retry).

2026-08-27 закрыты последние две дыры списка:

  • communication_session.py — 21% → 83% (tests/antenna/test_communication_session.py). Заглушка Hamlib в conftest.py уже была, не хватало двойников железа: FakeRotator и FakeRig с тем же интерфейсом и историей вызовов. Покрыты жизненный цикл потока, парковка ротатора и disconnect по любому исходу цикла, ожидание просыпания rotctld, основной цикл сопровождения и знак доплеровского сдвига. Ожидания построены на Event, а не на sleep: цикл живёт в отдельном потоке, и фиксированная пауза сделала бы тест флейком;

  • main.Application — 52% → 88% (tests/test_application.py): перезапуск берёт новый экземпляр планировщика, stop гасит его без ожидания, SIGTERM доходит до stop, run возвращает управление после остановки.

При написании первого найден латентный дефект, не исправленный здесь: CommunicationSessionBase.set_session_parameters защищает не от того состояния, от которого обещает. _session_thread_active означает «запрошена остановка» (start_session его снимает, stop_session ставит), поэтому на живой сессии проверка не срабатывает вовсе, а срабатывает между остановкой и следующим запуском. В проде не стреляет: Observation создаёт обе сессии в __init__, на каждый проход приходит объект с чистым флагом. Тест закрепляет фактическое поведение и назван так, чтобы правка семантики флага его уронила.

Готовность фазы

Два сценария:

  1. Станция, отключённая от сети на час поверх трёх проходов и перезапущенная, догружает всё без потерь и без дублей.

  2. Станция с firewall, разрешающим только sonik.space, поднимается с нуля и обновляется: docker compose pull && up -d и старт контейнера не делают ни одного обращения на сторону.

Фаза 2 — агент и управление парком

soniks-agent

~~Единый станционный демон, вырастающий из soniks-monitor. Там уже есть 1357 строк того, что нужно: идентичность станции, реконнект, системная телеметрия, доступ к логам контейнеров, чтение водопада. Репозиторий переносится из личного BUSH222/soniks-monitor в группу.~~

Пишется с нуля (решение 30, 2026-09-10). Разбор soniks-monitor показал, что основой он служить не может: телеметрия и водопад уходят по WebSocket, а логи — HTTP POST по строке на сторонний сервер студентов (на портале таких приёмников нет); токен станции передаётся в query-строке URL и пишется в лог; /api/jobs/ он опрашивает анонимно (см. п. 18); desired-state, OTA, health-gate, /healthz и публикации capabilities нет вовсе. Годные куски — логи контейнеров через docker.sock и разбор .dat водопада — материал Фазы 5, где появится приёмник. Агент — продукт для массового публичного парка: Linux на arm64 и amd64 первым этапом, Windows (Docker Desktop) — вторым (решение 26).

Функции:

  • pull desired-state раз в ~60 секунд; heartbeat с фактическим состоянием;

  • OTA: сравнить target_image_digest ~~и bundle_version~~ (bundle в v1 остаётся latest, решение 39), дождаться окна без проходов (агент знает расписание), применить, прогнать health-gate, откатить при провале;

  • ~~применение конфига по тем же правилам~~ — агент v2 (решение 39);

  • ~~публикация capabilities~~ — это делает сам клиент (решение 31);

  • ~~телеметрия, логи и стрим водопада в telemetry-ingest~~ — Фаза 5 (решение 29);

  • режимы managed — катит сам, и notify — только сообщает: смешанный парк.

Агент v1 (решения 38–44, 2026-09-10)

~~Контракт записан, кода нет.~~ Написан 2026-09-11 — репозиторий soniks-agent (решения 60–66): config.py читает тот же .env, portal.pystate/ с ETag и status/ с дедупликацией, compose.py — проект из лейблов, слияние override, up/exec, gate.py — L1/L2 и окно, agent.py — машина состояний с файлом state.json (текущий, предыдущий, итог, провалившийся digest). Тесты без Docker: все подпроцессы идут через одну compose.run(). Образ — python:3.11-slim плюс бинарники docker и docker-compose из docker:27-cli. Портал принимает ключ agent (StationAgentReportSerializer), показывает его на странице станции и в форме настроек, ReleaseChannel.client_image не принимает тег (миграция 0057). build.sh клиента после --push печатает digest для канала. Блок soniks-agent в шаблонах compose закомментирован до публикации образа; включение — docs/station/agent.md. v1 — самое узкое, что закрывает готовность фазы для образа: раскат с портала, автооткат, видимые версии.

  • Меняет только образ клиента (решение 39). Bundle станция и так берёт свежий на каждом старте и сверяет по sha256. ~~Конфиг тракта (решение 6) требует L2 с новыми настройками — это агент v2.~~ Конфиг тракта применяет сам клиент (решение 46), агента v2 не будет.

  • Цель — digest, раскат — каналами (решение 38). Релиз — digest клиента на канал canary или stable, у станции поле канала. Стенд и пара станций — в canary; тот же digest в stable staff ставит руками, когда канарейка отработала.

  • Применение — compose override (решение 40), приём tools/stand.sh deploy. Агенту монтируется каталог compose станции, он пишет docker-compose.override.yml с image: sonik.space/sonikspace/soniks-client@sha256:… и делает docker compose up -d soniks-client. Устройства, тома и .env остаются в compose оператора, агент их не дублирует. Откат — тот же файл с предыдущим digest, поэтому предыдущий образ агент на станции не удаляет.

  • Health-gate — L1 и L2 (решение 42). L1 — /healthz отвечает 200 в течение 60 с. L2 — docker compose exec soniks-client timeout 60 test-flowgraph.sh, и только когда next_observation из /healthz дальше 5 минут: скрипт крутится до Ctrl-C и SDR не охраняет. Успех — код 124 (граф дожил до таймаута) и непустой test.dat. L3 — позже: провал прохода и так виден на портале по exit_code и snr_db в метаданных.

  • Режим — notify по умолчанию (решение 43): агент только сообщает, что есть новый релиз; managed включает владелец в настройках станции.

  • Статус — ключ agent в reported_status (решение 41): текущий и предыдущий digest, режим, итог последнего применения и уровень gate. Сегодня портал заменяет документ целиком, и агент стирал бы modes клиента, поэтому запись становится слиянием по ключам верхнего уровня: клиент владеет modes и client_version, агент — agent. Клиенту это ничего не меняет — он всегда шлёт полный список.

  • Где и как (решение 44): отдельный репозиторий soniks-agent, свой образ, docker.sock. Сам себя в v1 не обновляет — это был бы агент над агентом; обновляет оператор. Аутентификация — токен владельца из .env станции, станционный токен отложен (Фаза 1).

Health-gate

Уровень

Что проверяет

Окно

Реакция на провал

L1

контейнер жив, клиент отвечает на /healthz

60 с

мгновенный откат

L2

SoapySDRUtil --find плюс короткий прогон scripts/test-flowgraph.sh с новыми настройками

~2 мин

мгновенный откат

L3

первые N проходов дали непустой водопад и адекватный noise floor

часы

откат после N провалов подряд

L2 — ключевой: именно он ловит сломанный SOAPY_RX_DEVICE и невалидный gain, то есть делает безопасным удалённое управление железом. Скрипт для него уже написан и используется вручную.

Портал

~~Новые модели: StationRelease (целевой образ и bundle на станцию), StationConfig (желаемый конфиг, generation, история), StationCapability (режимы, список NORAD, диапазоны, версии).~~ Для агента v1 (решения 38–43): ReleaseChannel — две строки, canary и stable, с digest клиента, правит staff; Station.release_channel (по умолчанию stable) и Station.update_mode (notify по умолчанию, managed включает владелец). StationCapability не нужен — объявленное станцией лежит в reported_status (решение 31). StationConfig — агент v2. Бутафорские поля satnogs_soapy_rx_device и satnogs_rf_gain вместе с заготовками get_default_station_configuration* — убрать или заменить настоящими (на 2026-09-10 все на месте, чистка мёртвого кода 2026-09-01 их обошла).

Модель станции с 2026-09-06 стоит на другом фундаменте, и это надо учесть при проектировании: состояние не хранится, а выводится из last_seen, is_available и testing при каждом обращении; heartbeat по-прежнему только last_seen из GET /api/jobs/, порог — STATION_HEARTBEAT_TIME (60 минут). Появилось четвёртое состояние unavailable — «на связи, но владелец закрыл приём»; в v1 /api/stations/ оно читается как Offline, и словарь из трёх слов там трогать нельзя (правило 21). Фактические версии из status/ — это ещё одна такая же производная, а не колонка.

Фильтрация планирования по capabilities: портал не планирует режим, которого станция не умеет. Клиент параллельно отказывает неизвестный режим явной ошибкой вместо тихого FM (src/soniks_client/observation_scripts.py). Вдвоём это закрывает канал потери C.

Фильтрация сделана 2026-09-10 (решение 31), обе стороны. Портал: POST /api/v2/stations/<id>/status/ принимает от владельца станции {"modes": [...], "client_version": ...} и хранит документ целиком в Station.reported_status (JSON) с reported_status_at, миграция 0054. Режимы сортируются без повторов, неизвестные ключи отбрасываются; ответ — 200 с сохранённым документом, аноним 401, чужая станция 403. Правило — is_transmitter_mode_supported() в network/base/validators.py: нет modes — станция умеет всё, у передатчика нет режима — не фильтруется. Встроено во все пути, создающие наблюдения: get_available_stations, create_new_observation (OutOfRangeError, как проверка частоты), check_transmitter_station_pairs (API и форма — 400), predict_candidate_windows, сетевая стадия автопланирования (берёт другой передатчик) и get_available_transmitter запусков — этот путь обходит create_new_observation и без отдельной правки фильтр бы миновал. Места только для показа (список передатчиков станции в UI, next_network_pass) не фильтруются — наблюдений они не создают. Клиент: publish_station_status() шлёт MODES после первой успешной сверки, до первого успеха.

~~Явный отказ неизвестного режима в клиенте остаётся следующим шагом: только после выкатки портала на прод.~~ Сделан 2026-09-10 и выкатки не ждёт (решение 45). execute_observation отказывает неизвестный режим, только если publish_station_status() в этом процессе прошёл успешно. Принять режимы может лишь портал с фильтром — эндпоинт и фильтр пришли одним изменением, — а старый портал отвечает 404, и клиент остаётся на подмене FM. Отказ стоит до создания путей, как у NoCompatibleRxDeviceError: ни директории, ни признака прохода, ни выгрузки, в логе ошибка. Тесты — tests/test_execute_observation.py. Цена: наблюдения, запланированные до первой публикации, пока станция «умела всё», после неё отказываются — это и есть смысл решения 14.

Половина сделана досрочно 2026-08-27 — подмена перестала быть тихой. Отказывать пока нельзя: без фильтрации планирования отказ снял бы проходы, которые станция сегодня принимает. Но видимой подмена стала обоими слоями сразу: build_script_argv пишет предупреждение в лог станции, а Flowgraph.get_metadata() уносит на портал radio.mode_known — рядом с exit_code и тем же приёмом. Сырой режим лежит в radio.parameters.mode, но судить по нему нельзя, не зная версию таблицы клиента; булев флаг судит сам.

Цена тишины здесь не косметическая, и её уже платили: подставленное имя графа уходит шестым аргументом в satnogs-pre/satnogs-post, оттуда в scripts/find_samp_rate.py, а тот определяет по подстрокам в нём частоту дискретизации IQ — и для UDP-потока в gr-satellites, и для имени IQ-дампа. Это ровно механизм бага SSTV_PD120 (Фаза 0.13), только запускаемый режимом от портала, а не опечаткой в таблице.

Зародыш этого механизма введён досрочно, в Фазе 0: параметр capabilities в GET /api/jobs/ (решение 21). Форма выбрана с расчётом на здешнюю модель — StationCapability заполняется тем, что станция объявила сама, а не тем, что портал о ней предположил. Фильтрация планирования по capabilities обязана считать станцию, которая ничего не объявила, умеющей всё: иначе правило 21 нарушается ровно наоборот — необновлённой станции перестанут планировать проходы, которые она принимает сегодня.

telemetry-ingest

Перенесён в Фазу 5 решением 29 (2026-09-10). Готовности этой фазы — раскат с портала, автооткат, видимые версии — телеметрия, логи и стрим водопада не нужны; они нужны UI монитора. Текст ниже остаётся как вход в Фазу 5.

Отдельный ASGI-сервис (FastAPI/uvicorn): WebSocket для телеметрии, логов и водопада, Redis для горячих данных, Postgres для агрегатов, аутентификация тем же станционным токеном. Отдельный — потому что телеметрия раз в две секунды при пятидесяти станциях даёт около 25 запросов в секунду, чего ~~единственный sync-воркер портала не переживёт~~ портал с sync-воркерами gunicorn терпеть не должен, ~~а тащить ASGI в Django 4.0.10 значило бы связать судьбу мониторинга с судьбой большого апгрейда~~. Логи и стрим водопада в Django ORM не ложатся в принципе.

Два из трёх доводов ослабли после 2026-09-02: воркеров теперь nproc*2+1, а Django 5.2 умеет ASGI сам. Решение 13 от этого не отменяется — стрим водопада и логи остаются вне ORM, — но форма сервиса (отдельный процесс рядом или ASGI-приложение внутри портала) стала вопросом на входе в фазу, а не данностью.

Готовность фазы

Новый образ раскатывается на парк с портала; заведомо сломанный конфиг откатывается автоматически; оператор видит фактические версии всех станций.

Фаза 3 — тракт приёма

Порядок внутри фазы важен — от дешёвого и обратимого к дорогому.

  1. Реалтайм-выдача кадров gr-satellites. scripts/grsat.py перестаёт копить KISS до stop и отдаёт кадры потоком. ~~ZMQ pub там уже частично есть (GRSAT_ZMQ_PORT), просто не потребляется клиентом.~~ Кадры подхватываются тем же CreateFileHandler. Малый диф, закрывает задачу реалтайма целиком.

    Сделано 2026-09-10 (решение 33), и не через ZMQ. Разбор исходников gr-satellites показал: --zmq_pub отдаёт сериализованный PMT без метки времени, а --kiss_out пишет через буферизованный file_sink — хвост сбрасывается только при закрытии, так что читать файл по мере записи бесполезно, а SIGKILL на stop его терял. Штатный потоковый выход — --kiss_server: тот же KISS с метками по TCP. grsat.py start поднимает его на порту 8100 и фоном запускает grsat.py stream, который пишет кадр файлом data_* сразу; разбор --kiss_out на stop остался страховкой, а дубли отсеивает content-hash портала — (наблюдение, sha256) → 200 без записи.

    Попутно найдено и исправлено два дефекта меток. --start_time на живом UDP сдвигал метки назад на время подготовки прохода (он для записей, проигрываемых с --throttle) — снят. kiss.py проверял длину метки до снятия экранирования, и метка, в миллисекундах которой встречался 0xC0 или 0xDB, пропускалась — кадр получал время предыдущего.

  2. Единый выбор декодера в клиенте. Есть satyaml для NORAD — идём путём gr-satellites; нет — satnogs-граф по mode. Один осознанный выбор вместо двух независимых. Решение уезжает в client_metadata, чтобы постфактум было видно, чем декодировали.

    Сделано 2026-09-12 (решение 67). sat_data.decoder_for(norad) ищет norad: в каталоге satyaml пакета satellites — там и bundle, и штатные определения gr-satellites. Observation считает выбор раз на проход (NORAD от портала, без него из TLE), пишет radio.decoder в метаданные и отдаёт его хукам переменной SONIKS_DECODER; grsat.py start без gr-satellites в ней не запускает gr_satellites. Satnogs-граф остаётся в обоих случаях — он источник IQ, водопада и аудио; убрать его демодулятор на пути gr-satellites — это п. 4, единый граф.

  3. Сопоставимый водопад. Полоса и разрешение фиксируются независимо от бодрейта. Без этого noise_db между режимами несравним и Фаза 4 строится на песке.

    Сделано 2026-09-12 (решение 69) — нормирует клиент, графы не тронуты. Разбор показал, что «фиксировать в графе» нечем: водопад во всех 20 графах висит на выходе satnogs_doppler_compensation, а её out_samp_rate и есть всё, что осталось после децимации — 48 кГц у аудио-режимов, 57.6 у FSK 9600, 76.8 у BPSK 9600, 66.56 у APT/SSTV, 200 у PHASMA при БПФ на 1024 точки (бин 46.9–195 Гц). Фиксированная полоса требовала бы второго тракта децимации на полной частоте SDR в каждом графе — лишний фильтр на Pi. И главное: по правилу 21 старые станции шлют разнородные водопады вечно, значит нормировать пришлось бы в любом случае. get_signal_metadata() отдаёт геометрию из заголовка .dat (samp_rate_hz, bin_hz, nchan, nfft_per_row) и noise_dbhz = noise_db 10·lg(bin_hz) — плотность шума, сопоставимую между режимами: waterfall_sink масштабирует бины 1/fft_size без окна, так что мощность белого шума в бине ∝ N0·bin_hz. Смещение Max Hold (10·lg(H_n), 3.2 дБ при 4 БПФ на строку … 5.5 при 19) не вычитается — режим sink в заголовке не записан, а менять его на Mean мейнтейнер отказался: тускнеют короткие всплески. Портал client_metadata не разбирает (хранит строкой, показывает деревом), правок там не нужно.

  4. Единый граф приёма. Схлопывание двадцати копий soapy_source и доплер-сниппета через hierarchical/ — шесть блоков уже написаны и лежат без дела. Заодно вычистить мёртвый канал 1 в soapy_source, --dev-args (пустая строка во всех двадцати файлах) и --doppler-correction-per-sec (не подключён нигде).

    Сделано 2026-09-12 (решения 71–72) — золотой фронтенд вместо hier. Диффом по YAML подтверждено: soapy_source (60 полей) во всех 20 графах одинаков байт-в-байт, доплер-блок различается только out_samp_rate (пять выражений) и compensate у iq_receiver, водопад и IQ/UDP-синки — только той же частотой. Значит «единый граф» — это не новый блок, а одно место правки: generic/fm.grc — источник истины фронтенда, tools/sync_frontend.py раскладывает секции parameters: по остальным графам текстовым сплайсом (дифф построчный, блоки и соединения не трогаются, свои остаются out_samp_rate/samp_rate водопада и compensate), тест test_frontend_matches_golden держит их в согласии. Шесть hierarchical/ удалены: без soapy_source, с file_sink/ogg_encoder до-ZMQ архитектуры, а настоящий hier-блок тянул бы CMake-цепочку (grcc -u в подконтрольный HOME, установка .py в site-packages) с проверкой только в CI. Вычищено: 16 полей канала 1 (nchan: '1', шаблон soapy_source.block.yml гейтит их по nchan > 1), параметр doppler_correction_per_sec из 20 графов — диспетчер его принимает и обнуляет ради старых клиентов, клиентская настройка FLOWGRAPH__DOPPLER_CORR_PER_SEC оставлена как историческая (она в форме портала). Поля усиления под чужие devname оставлены: при custom они тоже мертвы, но трогать их без железа незачем. Раскатка проверена инвариантами tests/test_flowgraphs.py; компиляцию подтверждает CI grcc после пуша. Фиксированная ветка водопада и режим sink теперь — одна правка золотого файла, если понадобятся.

  5. gr-satellites на всех спутниках из --list_satellites — через sat-data bundle. Новый спутник не требует пересборки arm64-образа.

    Сделано 2026-09-12 (решения 73–74), портальная половина — за порталом. Паритет с --list_satellites был уже по построению: bundle раскладывается в каталог satyaml пакета satellites, его же читают gr_satellites <norad> и decoder_for(). Разрывов было два. Первый — bundle применялся только при старте контейнера, новый спутник ждал рестарта: теперь resync_sat_data() раз в SCHEDULER__SAT_DATA_SYNC_INTERVAL_IN_HOURS (6) сверяет версию, качает и раскладывает между проходами (во время прохода каталог читает gr_satellites), после чего перепубликует статус. Второй — ворота: спутник с satyaml, но режимом ∉ MODES, портал станции не планирует, а клиент отказывает (решение 45). Клиентская половина сделана: если satyaml для NORAD есть, режим подменяется satnogs-графом по модуляции первого передатчика (AFSK*AFSK, *BPSK*BPSK, *FSK*/*MSK*FSK, regex без PyYAML), baud без задания берётся из satyaml, исходный режим уезжает в radio.mode_requested; незнакомая модуляция — отказ как раньше. Демодулятор satnogs-графа при этом не нужен — нужен IQ с частотой, которую задаёт модуляция, а find_samp_rate.py считает её по имени графа как и прежде. Станция публикует satellites — список NORAD из satyaml — в status/. Контракт для портала (решение 74): принять ключ в StationReportedStatusSerializer (сейчас whitelist его отбрасывает) и в is_transmitter_mode_supported пропускать передатчик, если NORAD его спутника в reported_status.satellites, независимо от режима. Сделано 2026-09-12 (пакет 7): сериализатор, reported_satellites() в валидаторе и обход SQL-фильтра запусков; тесты test_station_modes.py (все пути планирования плюс сетевая стадия) и test_station_status.py ([] — 200, дубли, слияние). Прунинга satyaml нет — bundle только добавляет файлы, снятое определение остаётся до нового образа; переименования редки.

  6. Мёртвые данные: ~~MODES[*]["is_baudrate"] и ["framing"] без потребителей~~; --baud кладётся безусловно даже в APT и SSTV; ~~два разных представления bool в командной строке (--dc-removal=True против --enable-iq-dump=1)~~.

    Клиентская часть сделана 2026-09-12 (решение 68). is_baudrate и framing сняты. Bool сверены с диспетчером 2.6.0: оба аргумента там type=int, так что --dc-removal=True был не «другим представлением», а выходом с кодом 2 до старта графа; теперь int(...), как enable-iq-dump. ~~--baud для APT/SSTV — сторона флоуграфов, не начато.~~

    Хвост закрыт 2026-09-12 (решение 70). --baud для APT/SSTV диспетчер отбрасывал с предупреждением уже с 248e606 (2026-08-22, тест test_baudrate_only_for_modes_that_take_it) — пункт писался против более старого дерева. --dev-args принимался, форвардился и терялся: параметр был в 19 графах, а soapy_source держал dev_args: ''; теперь dev_args: dev_args через золотой фронтенд (3.4), example_flowgraph.grc получил недостающий параметр. Образ станции — librespace/gnuradio:3.10.5.1-satnogs с in-tree soapy, где поле именно dev_args (в старом форке gr-soapy оно звалось args). FLOWGRAPH__DEV_ARGS по умолчанию не задан — у станций ничего не меняется.

Готовность фазы

Кадр появляется в сети в течение секунд после приёма. Число поддерживаемых спутников равно выводу gr_satellites --list_satellites. Правка одного параметра тракта — одна правка, а не двадцать.

На 2026-09-12: первое — с 3.1; второе — по построению плюс ре-синк без рестарта, а «планируется при любом режиме» — с портальной половиной ворот (решение 74, пакет 7); третье — одна правка generic/fm.grc и python3 tools/sync_frontend.py, тест не пропустит расхождение.

Фаза 4 — качество приёма

Шаг 1, калибровка на станции. По команде с портала ~~агент~~ клиент выбирает окно без проходов, гоняет свип по gain, измеряет шумовую полку и точку компрессии реальным железом, отдаёт таблицу gain по диапазонам. Портал хранит её ~~как часть конфига~~ в reported_status.calibration. Физику надо мерить на месте: из статистики она не выводится, а на новой станции статистики ещё и нет — ровно тогда, когда помощь нужнее всего. Сделано 2026-09-11 (решения 58–59): soniks_client/sdr_survey.py, команда calibrate_gain в state.commands, график и «Применить рекомендованное» в форме настроек портала.

Шаг 2, статистическая подстройка. Портал накапливает snr_db, noise_db и peak_db по проходам и мягко корректирует таблицу. Требует сопоставимого водопада (Фаза 3.3) и честного rate_observation (Фаза 0.16). С 2026-09-12 сравнивать надо noise_dbhz, а не noise_db: геометрия водопада зависит от режима, и клиент нормирует шум на ширину бина (решение 69). Наблюдения без этого ключа — от станций старых версий — в статистику не входят; смещение Max Hold (10·lg(H_n) по nfft_per_row) при желании снимается на портале, nfft_per_row в метаданных есть.

Форма шага 2 — решения 75–77 (2026-09-12). Считает портал на лету во view формы настроек по последним 50 наблюдениям с signal.noise_dbhz; показывает, не правит: медиана и MAD noise_dbhz, медиана snr_db по проходам с кадрами, доля насыщенных (signal.saturated). Правило коррекции — по накопленным данным, отдельным решением. Показ сделан 2026-09-12: network/base/reception_quality.py (parse_value, signal_block, reception_quality), контекст quality в station_config_edit, блок в station_config.html; тесты ReceptionQualityTest в test_station_config.py. Окно — SQL client_metadata__contains="noise_dbhz" по последним 50, кадры — Count("demoddata").

Валидация DSP — сделана 2026-09-12 (пакет 7). ~~waterfall/deviation.py (519 строк) и dsp.py (333) сейчас не покрыты ничем, кроме прогона plot() на синтетическом сигнале.~~ Прогон plot() в оценщик не входил вовсе: nchan=4 упирался в guard. tests/test_deviation.py собирает спектрограмму как waterfall_sink (экспоненциальный шум, Max Hold по nfft_per_row, тона лепестками) и закрепляет каждую ветку конвейера: k-means по пикам строк, пара пиков спектра, σ_RMS, CW, weak/noise, смещение несущей и ppm, изоляция помехи, насыщение, guard — плюс ключевое утверждение Фазы 3.3: один N0 при nchan 1024 и 512 даёт один noise_dbhz (расхождение < 0.3 дБ, смещение Max Hold 10·lg(H₈) = 4.34 дБ сходится). Оценщик 93 %, примитивы 92 %, порог CI 64 → 80 %. Известные потолки — в docs/development/waterfall.md.

Аппаратная AGC остаётся выключенной. Возможен fallback на неоткалиброванной станции — но именно fallback, не решение.

Фаза 5 — монитор

UI монитора на портале ~~. Серверная часть к этому моменту уже существует (telemetry-ingest, Фаза 2), фронт переписывается под неё.~~ вместе с его серверной частью: telemetry-ingest переехал сюда из Фазы 2 (решение 29), и его форма — отдельный процесс или ASGI внутри портала (решение 13) — решается здесь. Годные куски soniks-monitor (логи через docker.sock, разбор водопада) — материал этой фазы (решение 30).

~~Открытый вопрос: под какой ui-kit. Каталога soniks-frontend на машине нет, а портал сейчас — Django-шаблоны, Bootstrap 4.6 и admin-lte, никакой дизайн-системы. Решается отдельно.~~ Фактически снято порталом 2026-09-04/06: Bootstrap 5.3 без admin-lte и без jQuery, свой слой токенов поверх переменных Bootstrap, светлая и тёмная темы, Tom Select, noUiSlider, dayjs, Chart.js. Это и есть ui-kit — монитор пишется на нём, отдельного фронта заводить незачем. Если решать иначе, то осознанно (открытый вопрос 1).

Новые контракты API

Всё новое — под /api/v2/; существующие эндпоинты не ломаем, на них сидят станции старых версий.

Метод

Путь

Назначение

GET

/api/v2/stations/<id>/state/

desired-state. Сделано 2026-09-10 (решения 46–54): {generation, config, location: {lat, lng, alt}, release: {channel, mode, client_image}, commands}; commands — с 2026-09-11 (решение 58): rescan_sdr, calibrate_gain, каждая с отметкой at; generation — номер сохранения формы конфигурации, config — плоский словарь с именами переменных окружения, client_imagesonik.space/sonikspace/soniks-client@sha256:… из ReleaseChannel. ETag — от тела (координаты и релиз меняются без generation), 304 по If-None-Match. Токен владельца, last_seen не пишет. Тот же документ — retained в MQTT stations/<id>/state

POST

/api/v2/stations/<id>/status/

heartbeat: modes, client_version, config {generation, ok, error, applied_at, actual} — сделано 2026-09-10; sdr {scanned_at, devices, error}, calibration {calibrated_at, results, recommended, lift_db, error} — 2026-09-11 (решения 57, 59); agent {version, mode, current, previous, available, last_apply} — 2026-09-11, soniks-agent (решения 60–66); satellites — список NORAD из satyaml станции, клиент шлёт с 2026-09-12, портал принимает с того же дня (решение 74, пакет 7; пустой список — 200); запись — слиянием по ключам верхнего уровня (решение 53). GET тем же путём — владельцу, для страницы: {reported_status, reported_status_at, generation}

POST

/api/v2/mqtt/auth/, /api/v2/mqtt/acl/

внутренние, для mosquitto-go-auth: логин station-<id> + токен владельца → станция вправе читать stations/<id>/state и писать `stations//status

wss://sonik.space/mqtt

MQTT через WebSocket, Mosquitto под профилем mqtt compose портала, nginx location = /mqtt. Не обязателен для станции: без него — REST раз в минуту

POST

/api/v2/observations/<id>/frames/

батч кадров, идемпотентность по content-hash, постатейный результат

PUT

/api/v2/observations/<id>/artifacts/

payload и waterfall с хешем; повтор того же хеша — 200

GET

/api/v2/bundles/<version>/

sat-data bundle: version, sha256, size и адрес архива. latest вместо версии — свежайший. Сделано 2026-08-28, анонимно: определения спутников — публичные данные, а токен между закрытой станцией и её единственным каналом обновления был бы лишней движущейся частью

POST

/api/v2/bundles/

публикация bundle из CI soniks-satyaml, staff-токен. Те же байты под той же версией — 200, другие — 409. Сделано 2026-08-28

GET

/v2/…

Docker Registry API. Обязательно в корне хоста — клиент Docker не умеет префикс пути. ~~Авторизация через nginx auth_request~~ Анонимно: pull-through cache Docker Hub, nginx пропускает только sonikspace/* и librespace/hamlib (решение 34). Сделано в портале 2026-09-10, на серверах не включён

WS

telemetry-ingest

телеметрия, логи, стрим водопада

GET

/api/v2/analytics/…

не из этого плана: аналитика портала (network, station, dynamic-demod, needs-attention) переехала под v2 2026-09-02, /api/analytics/… остались алиасами. Станция сюда не ходит, tools/portal_stats.py — тоже

Контракт v1 и v2 с 2026-09-02 описан машинно: openapi.yml в soniks-network-api-client/ генерируется под пиннутой локалью и сверяется в CI портала на каждом пуше. Для сверки того, что реально отдаёт /api/jobs/ или /api/v2/bundles/, это первоисточник, а не чтение сериализатора.

Desired-state проектируется как версионированный документ с generation — чтобы переезд на MQTT позже потребовал только транспортного адаптера, а не переписывания логики агента.

Стенд

Реальная станция, подключённая к живой сети, доступ по SSH. Роль — инструмент разработки и верификации. Нужен начиная с Фазы 0: без него канал потери A нечем даже подтвердить.

Запускается только по команде человека (решение 23, 2026-09-10). Прогон стоит часов ожидания прохода и реального наблюдения под настоящей личностью станции, поэтому решает человек, когда и что гонять. Разработка его не ждёт: без команды правка проверяется тестами репозитория, и работа идёт дальше. Команда может прийти по любой уже сделанной правке — поэтому каждая правка, которую имеет смысл проверить на железе, записывается в «Где мы» как готовая к прогону.

Dev-агент умеет: выкатить ветку или образ на стенд; дождаться ближайшего реального прохода (расписание берётся из /api/jobs/); собрать артефакты — payload.ogg, водопад .dat и .png, файлы data_*, client_metadata, логи клиента и диспетчера, код возврата; вынести вердикт.

Проверка

Порог

код возврата диспетчера

0

unrecognized arguments в логе

отсутствует

payload.ogg

существует, размер больше нуля

водопад .dat

существует, парсится, не меньше WATERFALL__MIN_VALID_SAMPLES строк

client_metadata.signal.snr_db

присутствует, не null

файлы data_*

для спутника с известным передатчиком — хотя бы один

incomplete/ после прохода

пусто

Реализация минимальная: набор команд tools/stand.sh (deploy / next-pass / watch / collect / verdict) плюс определение агента в .claude/agents/. SSH-хост и ключ — через alias в ~/.ssh/config, вне репозитория. Ничего специфичного для конкретного стенда в код проекта не попадает.

Сделано. Подробности — Стенд. Сверх исходного замысла deploy печатает дифф контракта клиент ↔ диспетчер: аргументы, которые построил бы Flowgraph, против опций flowgraph_dispatcher --help. Проверка стоит секунды вместо часа ожидания прохода и ловит весь класс отказов, а не один известный аргумент, — именно ею опровергнут канал A и найден A′. Две строки таблицы выше проверяются косвенно, и это отражено в выводе: код возврата диспетчера клиентом не логируется, а порог водопада код сравнивает с числом отсчётов, а не строк.

Сознательно не делаем сейчас — это решение, а не упущение:

  • HIL-раннер в CI, прогоняющий каждый MR на живом железе;

  • стенд как канарейка номер один в схеме раската;

  • IQ-дампы реальных проходов как фикстуры для валидации DSP.

Все три естественно вытекают из dev-агента и стоят недорого, когда он уже есть. Вернуться имеет смысл на входе в Фазу 4: там валидация DSP становится блокирующей.

Наследование от дорожной карты техдолга

Раздел «Совместная сессия с soniks-flowgraphs» в roadmap.md содержал семь пунктов, отложенных именно потому, что второй репозиторий был недоступен. Теперь доступен:

Отложенный пункт

Куда

Что именно принимает диспетчер (контракт CLI)

Фаза 0.1

SSTV_PD120 → неверный скрипт

Фаза 0.13

Версии FLOWGRAPHS_VER и ветка soniks

Фаза 1, пиннинг тегов

Нормализация булевых флагов CLI

Фаза 3.6 — сделано 2026-09-12, решение 68

is_baudrate и framing в MODES

Фаза 3.6 — сделано 2026-09-12, решение 68

scripts/gnuradio/vmcircbuf_default_factory кладётся не туда

Фаза 3.6

ipc: host и cap_add: SYS_NICE разъехались по compose

~~Фаза 1~~ отпало: проверено 2026-08-27, оба ключа стоят в обоих compose и совпадают

Решения, принятые там сознательно (отмеченные ❌ с обоснованием), здесь не переигрываются. В первую очередь — отказ от процесс-wide лока на наложившиеся проходы.

Разбор origin/soniks-update (решение 19)

Выполнен 2026-08-24. Ветка нашлась в двух репозиториях, а не в одном. Мёрж не делался ни целиком, ни по коммитам — как и записано в решении 19.

soniks-client-new — переносить нечего

Девять коммитов, 14 файлов, +2396/−276. Всё до последней строки уже есть в soniks, и почти везде — в лучшем виде. Ветка отстала настолько, что каждый её кусок либо повторён, либо исправлен:

Кусок ветки

Судьба

norad_cat_id сквозь modelssyncobservationflowgraph

уже в soniks, написано заново (п. 1)

scripts/sstv_wrapper.py и делегирование из grsat.py

уже в soniks, и на 58 строк короче

Прошивка LibreSDR, scripts/libresdr/usrp_b210_fpga.bin

уже в soniks. На ветке find -exec возвращает ненулевой код и обрывает &&-цепочку — в soniks это обёрнуто в if, иначе сборка падала

cap_add: SYS_NICE в compose

уже в soniks, вместе с ipc: host; ветка вдобавок пиннит образ latest-addons-test и держит stop_grace_period: 1s, который в soniks поднят до 15 с

Чистка packages.builder / packages.client

уже сделано, и глубже. Ветка сводит boost к libboost-all-dev — это больше, а не меньше

Реструктуризация Dockerfile (стадии, PARALLEL_JOBS, LTO, SoapyAirspy, ENV)

уже в soniks. На ветке остались satnogs-flowgraphs_*.deb (имя, из-за которого образ не собирался), --mount=type=cache для .deb (на холодном кэше glob не раскрывался) и rm -f /usr/lib/x86_64-linux-gnu/…/libairspySupport.so — путь только для amd64, на arm64 не срабатывает

waterfall.py, +2258 строк одним файлом

отклонено. В soniks водопад — пакет из четырёх модулей, и перечисленные хелперы (_kmeans2_1d, _wiener_gate, _bimodal_split и ещё 17) убраны намеренно при его обновлении

rm -rf в else-ветке liveupdate-satyaml.sh

отпало: скрипт переписан, клоны идут в mktemp -d, который сносится целиком

signal-метаданные в post_processing

уже в soniks; на ветке рядом лежит мёртвая flowgraph_metadata — присваивается и не используется

soniks-flowgraphs — одно стоит внимания, остальное не готово

25 коммитов, 36 файлов, +2699/−1630.

satyaml_lookup.py (+383) — единственное, чего в soniks нет вовсе. Читает satyaml gr-satellites и достаёт по NORAD параметры спутника (девиация, фрейминг, скремблирование), чтобы диспетчер настраивал DSP-цепочку по ним, а не по одному --mode от портала. Это прямо материал Фазы 3.2 («единый выбор декодера») и Фазы 3.5. Переносить сейчас нельзя и не нужно: модуль ищет satyaml в том числе в /var/lib/satnogs-client/.gr_satellites/satyaml/ — чужой путь, у нас /var/lib/soniks-client. Оставлен как материал для чтения.

Диспетчер ветки контракт не чинит. Он по-прежнему зовёт parse_args() и лишь добавляет --norad-cat-id; на любом другом незнакомом аргументе — включая --lo-transverter — выход с кодом 2 остаётся. То есть решение 19 («написать заново») подтвердилось фактом: перенос отсюда закрыл бы один аргумент из класса отказов.

Правки .grc — четыре сквозные ручки по двум десяткам графов. Каждая требует измерения на железе, а не переноса:

  • max_nouts: 0 8192 и realtime_scheduling: '' 1 — планировщик GNU Radio. Второе требует SYS_NICE, который в compose уже есть;

  • dev_args: '' dev_args — оживление --dev-args, пустого во всех графах (стоит в Фазе 3.6);

  • dc_blocker_xx: long_form True False — короткая форма DC-блокера дешевле по CPU и хуже давит постоянную составляющую. Компромисс, который надо мерить;

  • enable-waterfall: '1' дописан в блок optionsу блока options такого поля нет, GRC его просто проигнорирует. Похоже на недоделку.

fsk_ax25.grc (−1193) → fsk.grc (+1683) плюс правки FSK/BPSK в десятке графов спутников — отдельная задача Фазы 3, не «перенос годного».

Итог

Переносить нечего. Обе ветки — материал для чтения, и обе своё уже отдали: клиентская подтвердила, что Фаза 0 её перекрыла целиком, флоуграфовая дала satyaml_lookup.py как задел Фазы 3. ~~Удаление обеих веток — за мейнтейнером.~~ Решено 2026-09-10: ветки не удаляются, а игнорируются. Если они остались на origin, их никто не мёржит и не читает как источник правок; satyaml_lookup.py оттуда по-прежнему доступен как материал Фазы 3.

Сознательно отложено

Что

Почему

Мультиресурсная станция: N SDR — N одновременных проходов

Портал моделирует станцию как один ресурс; физика ротатора всё равно ограничивает одним сопровождаемым спутником. Объём приёма дешевле поднять Фазами 0 и 3

~~MQTT вместо pull REST~~

~~Даёт мгновенный детект offline через Last Will, но требует нового сервиса наружу. Desired-state спроектирован так, что переезд — это транспортный адаптер~~ Сделан 2026-09-10 (решение 47): не вместо REST, а рядом — REST остаётся источником истины и фолбэком

Отказ от gr-satnogs целиком

waterfall_sink, doppler_compensation, APT- и SSTV-sink пришлось бы переписывать с нуля, а водопад — это весь анализ сигнала

~~Апгрейд Django и Python на портале~~

~~Нужен, но не блокирует ни одну фазу: телеметрия уезжает в отдельный ASGI-сервис~~ Сделан 2026-09-02 вне плана: Python 3.12, Django 5.2 LTS. См. примечание к решению 13

Очередь в SQLite вместо директорий

Директории работают; проблема была в tmpfs и идемпотентности, а не в механизме

Верификация

Локально, на каждой правке. Это и есть проверка по умолчанию (решение 23): зелёные тесты и линтеры закрывают правку, дальше — следующая.

uv run --python 3.11 --extra dev ruff check .
uv run --python 3.11 --extra dev python -m pytest tests -q

docker run --rm -v "$PWD:/mnt" -w /mnt koalaman/shellcheck:stable \
  --severity=warning scripts/*.sh scripts/satnogs-pre scripts/satnogs-post build.sh
docker run --rm -i hadolint/hadolint:v2.12.0-alpine < Dockerfile

В soniks-flowgraphstests/e2e_check.py: реальное дерево процессов, живые ZMQ, проверка кода возврата и коллизии двух станций. Подключён к CI в Фазе 0 (п. 14).

На портале — tox -e deps,pytest и джоба schema: если правка меняет ответ любого эндпоинта, openapi.yml обязан измениться тем же коммитом, иначе CI падает. Для правил 21 это второй сторож после тестов: диф схемы показывает, появилось ли поле у тех, кто его не просил.

На стенде — только по команде человека, в любой момент: прогон реального прохода dev-агентом и вердикт по таблице выше. Ниже — что именно гонять, когда команда пришла; ни один сценарий не блокирует разработку.

Для Фаз 0, 1, 3 и 4 — реальный проход и вердикт.

Отдельно для Фазы 1: станция отключается от сети на час поверх трёх проходов и перезапускается; все данные должны догрузиться без потерь и без дублей. И вторым сценарием — станция за firewall, разрешающим только sonik.space, поднимается и обновляется.

Отдельно для Фазы 2: заведомо сломанный конфиг (SOAPY_RX_DEVICE=driver=nonexistent) раскатывается на стенд — L2 обязан отловить и откатить без вмешательства человека.

Открытые вопросы

  1. ui-kit для Фазы 5. ~~Каталога soniks-frontend нет, на портале дизайн-системы не существует.~~ С 2026-09-06 существует: Bootstrap 5.3 со своим слоем токенов, без jQuery и admin-lte. Вопрос сводится к «монитор пишется на ней» — и закрывается, если никто не возразит до Фазы 5.

  2. ~~Размер парка и профиль железа.~~ Отвечено 2026-09-10: около 50 станций, в основном Raspberry Pi (arm64), есть ноутбуки на Ubuntu и Windows (amd64). Отсюда решение 26.

  3. ~~Судьба origin/soniks-update~~ — решено 2026-08-24, см. решение 19 в Принятых решениях.

  4. Политика хранения на портале. ~~clean_observations объявлена, в расписании не зарегистрирована,~~ clean_observations удалена 2026-08-29 как мёртвый код; медиа растут неограниченно, заготовки больше нет. Есть только архивация аудио в zip и S3 по флагам (ZIP_AUDIO_FILES, ARCHIVE_ZIP_FILES), и это перенос, а не удаление. Вместе с вопросом 6 — одна политика на медиа и образы, не две.

    Частично отвечено 2026-09-10 (решение 28). Прод — Yandex Object Storage, больше 10 ТБ, всё вечно; zip и архивация на archive.org при аудио в S3 выключены самим порталом. Шаг 1 — замер по типам (выполняет мейнтейнер: это ключи бакета):

    aws s3 ls "s3://$BUCKET/data_obs/" --recursive \
        --endpoint-url https://storage.yandexcloud.net \
      | awk '{n=split($4,p,"."); ext=(n>1)?p[n]:"без расширения";
              size[ext]+=$3; count[ext]++}
             END {for (e in size) printf "%-16s %8d шт. %10.1f ГБ\n",
                  e, count[e], size[e]/2^30}'
    

    Шаг 2 — lifecycle-правило бакета (черновик, применять после замера):

    {"Rules": [{"ID": "data-obs-cold", "Status": "Enabled",
                "Filter": {"Prefix": "data_obs/"},
                "Transitions": [{"Days": 30, "StorageClass": "COLD"}]}]}
    

    aws s3api put-bucket-lifecycle-configuration --bucket "$BUCKET" --lifecycle-configuration file://lifecycle.json --endpoint-url https://storage.yandexcloud.net. Префикс общий у всех типов артефактов, так что правило режет только по возрасту. Удаление — отдельным решением по цифрам шага 1.

  5. ~~Формат sat-data bundle.~~ — решено 2026-08-28, см. решение 22 в Принятых решениях. Подписи у архива нет и не планируется, пока нет управления ключами: целостность держится на sha256 поверх HTTPS с того же хоста, что и всё остальное.

  6. ~~Ресурсы под registry на sonik.space.~~ Отвечено 2026-09-10: диска хватает на текущую и предыдущую версию каждого образа, больше не храним (решение 27). Исходный текст вопроса — ниже. Сколько диска и канала. Мультиарх soniks-base с GNU Radio — гигабайты, держать надо минимум текущую и предыдущую версию каждого слоя. Registry сознательно отложен 2026-08-24 до ответа на этот вопрос и на 7: без цифр проект был бы угадыванием. Промежуточный шаг сделан — образ по тегу публикуется ещё и в registry GitLab, так что sonik.space добавляется потом одной парой строк в bake. Форма — решения 34–35: прокси Docker Hub, так что ни push, ни пары строк в bake не будет.

  7. ~~Профиль закрытых сетей.~~ Отвечено 2026-09-10: закрытых 5–15, среди них возможны волонтёрские, SNI/DPI — неизвестно. На registry переходят все станции, чтение анонимное (решение 27). Проверить SNI/DPI — дело прогона второго сценария готовности Фазы 1. Форма registry — решение 34.