Дорожная карта: надёжность сети¶
Документ охватывает четыре репозитория — 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, сборка |
ничего. Три хвоста сняты решением мейнтейнера 2026-09-10: пересчёт истории оценок на паузе (п. 16), ветки |
1 — надёжность обмена |
сделано: durable spool и метаданные в нём, content-hash на портале, транспорт с |
сделано в коде, ждёт человека: registry-прокси в портале (решения 34–35) — включить на серверах и переключить шаблоны compose (решение 37). Не сделано: ретеншен медиа (решение 28: сначала замер), станционный токен (отложен осознанно). ~~Свой образ hamlib~~ снят решением 36. Оба сценария готовности фазы на стенде не прогонялись — это прогон по команде (решение 23), фазу не держит |
2 — агент и управление парком |
начата 2026-09-10 с канала C: |
Код закрыт, ждёт человека: опубликовать образ агента ( |
3 — тракт приёма |
3.1 сделана 2026-09-10 (решение 33): кадры gr-satellites потоком. 3.2 сделана 2026-09-12 (решение 67): один выбор декодера по наличию satyaml, |
|
4 — качество приёма |
шаг 1 сделан 2026-09-11 (решения 57–59): автоопределение приёмника и калибровка усиления по команде с портала, график и кнопка «Применить рекомендованное» в форме настроек Пакет 7, 2026-09-12: валидация DSP сделана — |
шаг 2b — правило коррекции |
5 — монитор |
не начата |
ui-kit фактически есть (вопрос 1); с 2026-09-10 сюда же входит |
Пакет 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):
Включить registry-прокси — руками, по разделу «Registry образов станций» в
docs/dev/deploy.mdпортала: токен Docker Hub,COMPOSE_PROFILES, каталог кэша, сниппет nginx. Сначала стенд-сервер портала, потом прод; проверка —docker pull sonik.space/sonikspace/soniks-client:latest-addonsснаружи.После подтверждённого pull — переключить шаблоны compose станции и
docs/station/наsonik.space/…(решение 37).~~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портала).Пересобрать базу: в неё входят пин gr-satellites (решение 32) и второй этап платформ,
PLATFORMS=linux/arm64,linux/amd64(решение 26).Ретеншен медиа: замер 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, у портала нет метрик, у оператора нет способа отличить работающую станцию от пишущей шум. И нет способа что-либо исправить удалённо — обратного канала от портала к станции не существует вовсе.
Цель — сделать сеть наблюдаемой, управляемой и обновляемой, и только потом поднимать качество и объём приёма. Порядок именно такой: переделка тракта приёма — единственное изменение здесь, способное одномоментно оглушить весь парк, и катить его следует поверх работающего механизма отката.
Состояние на входе¶
Репозиторий |
Роль |
Состояние |
|---|---|---|
|
клиент станции, Python 3.11 |
2.2.2, активная разработка. Тег |
|
флоуграфы GNU Radio + диспетчер |
форк satnogs-flowgraphs, ветка |
|
форк gr-satnogs (C++ OOT) |
чистое зеркало, 0 своих коммитов, ~~к сборке не подключён~~ подключён 2026-08-24 по тегу |
|
портал |
~~Django 4.0.10 / Python 3.9~~ Python 3.12 / Django 5.2 LTS с 2026-09-02, форк satnogs-network |
|
монитор станции |
1357 строк, серверной части не существует |
Прямые потери данных, происходящие сейчас¶
# |
Что |
Где |
Эффект |
|---|---|---|---|
A |
Клиент шлёт |
|
~~латентный, сейчас не стреляет~~ закрыт (п. 1/12): диспетчер разбирает |
A′ |
~~То же с |
|
латентный: по умолчанию |
B |
~~ |
|
любой пришедший кадр делал наблюдение Good. Вся статистика сети, |
C |
Портал планирует режим, которого нет в таблице клиента; клиент подставляет |
|
~~в логах ни строчки~~ виден с 2026-08-27: предупреждение в логе и |
D |
~~Вся |
|
любой перезапуск стирает невыгруженные данные |
E |
~~Одно битое задание от портала обнуляет всё расписание~~ закрыт (п. 2) |
|
проходы не планируются, пока портал отдаёт битую запись |
F |
~~Наивная метка времени от портала роняет цикл синхронизации~~ закрыт (п. 3) |
|
|
G |
~~Метаданные наблюдения при ошибке сети теряются безвозвратно~~ закрыт 2026-08-26 (Фаза 1, durable spool) |
|
блок |
H |
~~Одиночный 404 удаляет директорию наблюдения целиком~~ закрыт (п. 6) |
|
без второй попытки и без подтверждения |
I |
~~ |
|
образ с тегом |
J |
~~Приложение продолжает работать с мёртвым планировщиком~~ закрыт (п. 5) |
|
после трёх неудачных перезапусков просто пишет в лог; Docker не перезапустит |
Уточнение по каналу A: проверено на стенде 2026-08-24¶
Изначально канал A был записан как активная потеря — «сеть пишет пустоту на каждом проходе со спутником». Прогон на живой станции это опровергает. Цепочка рвётся на звено раньше:
Звено |
Факт |
|---|---|
Диспетчер |
|
Клиент |
|
Портал |
поля |
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 |
Владение парком |
Смешанный: ядро плюс волонтёры. Флаг |
2 |
Свобода менять портал |
Полная, обе стороны контракта — наша работа |
3 |
Единица обновления |
Три слоя: |
4 |
Кто обновляет |
Отдельный контейнер-агент с доступом к |
5 |
Транспорт управления |
Pull REST; desired-state как версионированный документ с |
6 |
Граница конфига |
Портал читает весь фактический конфиг; меняет тракт приёма и железо |
7 |
Критерий применения |
Трёхуровневый health-gate плюс автооткат. В агенте v1 — L1 и L2 (решение 42), L3 позже |
8 |
Архитектура тракта |
Единый граф приёма, gr-satellites как основной декодер |
9 |
Гарантия доставки |
Durable spool на диске плюс идемпотентность по content-hash |
10 |
gr-satnogs |
Подключить существующий форк |
11 |
Подбор усиления |
Гибрид: калибровка на станции плюс статистическая подстройка порталом |
12 |
soniks-monitor |
Сливается с агентом в единый |
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 на |
18 |
Внешние зависимости рантайма |
Убрать полностью: |
19 |
|
Не мёржить ни целиком, ни по коммитам. Экспериментальная ветка мейнтейнера: попытка обновить flowgraphs, качество кода и результат не проверялись. Служит материалом для чтения, не источником правок. Разбор — отдельной сессией: перенести годное, отменить негодное, ~~по итогам ветку удалить~~. Разбор выполнен 2026-08-24, переносить оказалось нечего — см. ниже. Удалять ветку не будем: 2026-09-10 решено её игнорировать |
20 |
|
Оставить |
21 |
Совместимость с парком |
Парк не обновится целиком никогда. Часть станций в закрытых сетях, часть у волонтёров, часть просто не тронут. Отсюда правило: изменение портала не имеет права менять то, что отдаётся станции, которая об этом не просила. Новое — либо по явному опт-ину клиента, либо под |
22 |
Формат и сборка sat-data bundle |
Архив |
23 |
Роль стенда в разработке |
Прогон на стенде — только по команде человека, и разработка его не ждёт. Без команды проверка — тесты и линтеры репозитория (раздел «Верификация»), и работа идёт дальше, до конца плана. Прогон может быть назначен в любой момент, по любой уже сделанной правке. Разделы «Готовность фазы» описывают, что стенд проверяет, когда прогон назначен; переход к следующей фазе они не блокируют — порядок фаз (решение 15) держится на коде и зелёных тестах. Решение про разработку, а не про выкатку на парк: выкатка по-прежнему ручная и за человеком. Принято 2026-09-10 |
24 |
Постоянный |
Любой |
25 |
Уход часов станции |
Виден уже сейчас, до health-gate: клиент сверяет часы с заголовком |
26 |
Платформы |
Все: |
27 |
Registry на |
На него переходят все станции, а не только закрытые. Чтение анонимно — образы и так публичны, как и 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 |
|
Из Фазы 2 в Фазу 5: готовности Фазы 2 (раскат, откат, версии) телеметрия, логи и стрим водопада не нужны. Форма сервиса (решение 13) решается там. Принято 2026-09-10 |
30 |
soniks-agent |
Пишется с нуля как продукт для массового публичного парка. |
31 |
Как станция объявляет режимы |
|
32 |
gr-satellites в образе |
Пиннится коммитом |
33 |
Фаза 3.1 |
|
34 |
Форма registry |
Pull-through cache Docker Hub ( |
35 |
Кэш registry |
Локальный диск сервера, |
36 |
Hamlib |
Образ LSF через прокси: он — |
37 |
Шаблоны compose станции |
Переключаются на |
38 |
Единица раската |
Каналы |
39 |
Что меняет агент v1 |
Только образ клиента. Bundle остаётся |
40 |
Как агент применяет образ |
Compose override, приём |
41 |
Два писателя в |
Слияние по ключам верхнего уровня: клиент владеет |
42 |
Health-gate агента v1 |
L1 и L2. L2 — |
43 |
Режим по умолчанию |
|
44 |
Репозиторий агента |
Отдельный |
45 |
Явный отказ неизвестного режима |
Только если |
46 |
Кто применяет конфигурацию станции |
Сам soniks-client, не агент. Всё управляемое клиент читает из |
47 |
Транспорт «сразу и в обе стороны» |
MQTT через WebSocket на 443 ( |
48 |
Что портал вправе менять |
Тракт + ротатор/риг + уровни логов: |
49 |
Форма документа конфигурации |
Плоский словарь с именами переменных окружения ( |
50 |
Проверка перед применением |
Открыть приёмник python-биндингом SoapySDR ( |
51 |
Переезд с |
Станция публикует фактическую конфигурацию ( |
52 |
Минимальный |
|
53 |
Два писателя в |
Слияние по ключам верхнего уровня сделано (уточнение 41): клиент владеет |
54 |
Портальная половина агента v1 |
Сделана: |
55 |
Поля |
Четыре поля и бутафорская вкладка удалены, заменены |
56 |
Фронтенд формы конфигурации |
Django-шаблоны, Bootstrap 5.3, vanilla JS — как весь портал (снимает открытый вопрос 1 и для этой формы). Принято 2026-09-10 |
57 |
Автоопределение приёмника |
Станция сама перечисляет приёмники SoapySDR и их возможности — входы, диапазоны усиления (общий и покаскадные), частоты дискретизации, диапазон частот ( |
58 |
Команды портала станции |
Не отдельный канал, а блок |
59 |
Калибровка усиления — Фаза 4, шаг 1 |
По команде: свип по общему усилению шагом |
60 |
Где живёт агент |
Сервис того же compose-проекта, что и клиент. |
61 |
Override |
Сливается, не перезаписывается: PyYAML читает |
62 |
Когда качать, когда применять |
|
63 |
Повтор после провала |
Провалившийся gate digest не применяется повторно |
64 |
Окружение L2 |
|
65 |
|
Только поле |
66 |
Транспорт агента |
Только REST: |
67 |
Фаза 3.2 — один выбор декодера |
Правило одно, в |
68 |
Фаза 3.6 — клиентская часть |
|
69 |
Фаза 3.3 — сопоставимый водопад |
Нормирует клиент, графы не трогаются: фиксированная полоса из точки съёма после доплер-компенсации недостижима без второго тракта децимации (CPU на Pi), а по правилу 21 разнородные водопады от старых станций будут всегда. В |
70 |
Фаза 3.6 — хвост во флоуграфах |
|
71 |
Фаза 3.4 — форма единого графа |
Золотой фронтенд, не hier-блок. |
72 |
Фаза 3.4 — что вычищено |
16 полей канала 1 из |
73 |
Фаза 3.5 — bundle без рестарта |
|
74 |
Фаза 3.5 — ворота по satyaml |
Клиент: режим ∉ |
75 |
Фаза 4, шаг 2 — где считается статистика |
Портал, на лету, во view формы настроек станции: последние 50 наблюдений станции с непустым |
76 |
Фаза 4, шаг 2 — только показ |
Блок «Качество приёма» под графиком калибровки: число проходов в окне, медиана и MAD |
77 |
Фаза 4, шаг 2 — сопоставление с кривой калибровки |
Ожидаемая связь: |
78 |
Фаза 2 — вход в настройки станции на портале |
Задача (портал, отдельная сессия). На странице станции одна кнопка «Настройки станции» вместо «Изменить», всегда видимая владельцу; форма за ней объединяет |
Правило 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_id — SerializerMethodField, который
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.satellite—null=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¶
Сделано на стороне диспетчера (см. п. 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. Подробности — ниже.Сделано.
api.py— разбор по одному заданию: битая запись пропускается, остальные планируются. Заодно проверкаisinstance(jobs_data, list): без неё ответ изменившейся схемы отсеивал все задания и уходил в синхронизацию пустым списком, то есть «портал снял все проходы». Тесты —tests/test_api_jobs.py, комментарий вtests/test_models.py:57стал правдой.Сделано.
models.py—parse_datetimeприводит к UTC: строка без смещения считается UTC, со смещением — переводится. Тестыtests/test_models.pyперевыставлены под новое поведение.Сделано.
jobs/observation.py—run_pre_script()под собственнымtry(проход продолжается, как и при падении post-скрипта), весь блок послеfile_observer.start()— вtry/finally, гдеfinallyгасит наблюдателя и ставитsend_data_after_observation.Сделано.
main.py—sys.exit(1)после исчерпания попыток.SystemExitне ловитсяexcept Exceptionвrun()и доходит до Docker. Тест —tests/test_application.py.Сделано. 404 откладывает данные в
incomplete/, а удаляет их повторная отправка, получившая 404 второй раз — не раньше чем черезRESENDING_INTERVAL_IN_MINUTES. Счётчик не понадобился: состояние — сам факт нахождения директории вincomplete/.Сделано.
all_files_sent = Falseв веткеOSError.Сделано.
station.py— пустая и пробельная строка не проходят валидацию. Проверка именно на пустоту, а не на ложность значения, как предлагалось здесь изначально:ELEVATION=0(уровень моря) иLATITUDE=0.0(экватор) — валидные величины, иif not valueзабраковал бы станцию на экваторе. Тест —tests/test_station_settings.py.Сделано.
PATHS__BASE: /var/lib/soniks-client/dataчерезenvironment:в обоих compose — на уже смонтированный постоянный том, новых томов не потребовалось./tmpостаётся tmpfs.Сделано.
stop_grace_period: 15sв обоих compose, комментарий вmain.pyприведён в соответствие.Сделано.
--set "*.args.CLIENT_VERSION=${CI_COMMIT_TAG}"вbake, плюс проверка совпадения тега сpyproject.tomlперед сборкой. Сверка трёх объявлений версии — не отдельная джоба, аtests/test_version_consistency.py: джобаpytestуже есть и идёт на каждый пуш, а тест ловит расхождение ещё до пуша.
soniks-flowgraphs¶
Сделано.
flowgraph_dispatcher.py→parse_known_args()с предупреждениемignored unknown arguments, плюс приём и обнуление--norad-cat-idи--lo-transverter— тем же приёмом, что и--framing. Тесты —tests/test_dispatcher.py, две новые проверки.Правка нужна по-прежнему, но предусловием правки портала больше не является: гейтом стал опт-ин клиента, а не состояние парка (решение 21).
Сделано, но не тем, чем записано здесь изначально. Пункт стоял как «опечатка в одну строку, проходы 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, называвший всю таблицу справочной, исправлен: именно он усыпил пункт на два обхода.Сделано.
tests/e2e_check.pyподключён к джобеflowgraphs— там образlibrespace/gnuradio, то естьpmt, ради которого тест и держали снаружи. Вapt-get installдописаныpython3-zmq,python3-numpy,python3-soundfile. Флоуграф тест подменяет своим stub’ом, поэтому SDR и собранные рядомsatnogs_*.pyему не нужны; прогон ~25 секунд, сеть только loopback. Локально не проверено — на хосте нетpmt; подтверждается первым пайплайном после пуша.Сделано. Все четыре расхождения жили в
docs/troubleshooting.md(код возврата, грейс, суффиксы) и в пареdocs/dev/grc-conventions.md+docs/outputs.md(частота).docs/dispatcher.mdбыл прав всё это время, поэтому правился расходящийся с ним текст, а не наоборот:out_samp_rateAPT и SSTV_PD120 — 66560 (4*4160*4), 48000 остаётся только уfm.grc. В обоих графах естьaudio_samp_rate = 48000, но он уходит лишь в последний ресемплер аудио — отсюда и взялась ошибка;код возврата флоуграфа пробрасывается, сигнал даёт
128+N. Оговорка сохранена: клиент этот код не проверяет, поэтому для него упавший граф по-прежнему выглядит успешным наблюдением;грейс — 2 секунды на фазу, 4 в худшем случае;
абзац про суффиксы
_0/_1удалён целиком: имена кадров имеют микросекундное разрешение, а ссылка вела на запись вknown-issues.md, которой не существует.
soniks-network¶
Сделано.
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по сети упадёт. Это и есть правда, которую баг скрывал.Сделано. Порог вынесен в
OBS_AUDIO_DURATION_TOLERANCE, умолчание 120 секунд вместо зашитых 60. Там жеscheduled_duration.secondsзаменено на.total_seconds():.secondsзаворачивается на сутках.Сделано выключателем, а не разрывом.
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 до проверки прав. Анонимный доступ — про третьих лиц, а не про парк.Сделано. Разбор метки времени вынесен в
_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» удалён вместе со второй копией.Сделано. «Watefall» → «Waterfall» (и в докстринге
serializers.py). Тела трёх ответов 403 переведены в{"detail": …, "code": "already_uploaded"}. Подстрокаhas already been uploadedсохранена дословно: на ней держитсяapi.py:229во всём парке, и тестAlreadyUploadedResponseTestсторожит именно её, а не формулировку.Отменено 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.Сделано частично, и одно отклонено.
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рядом стоит комментарий, чтобы следующий заход не «починил» это снова.Переделано 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, все три стоят недорого и ни одна не была сделана.
Имя
.debфлоуграфов — межрепозиторный контракт без единой проверки.debian/controlво флоуграфах переименовал пакет вsoniks-flowgraphs(248e606, 2026-08-22), аDockerfileклиента ставил../satnogs-flowgraphs_*.deb. Glob перестал раскрываться, apt падал, образ не собирался вовсе — и это оставалось невидимым, потому что джобаdockerидёт только по тегу, а тесты и линтеры сборку не трогают. Имя исправлено; сам класс отказа закрывается пиннингомFLOWGRAPHS_BRANCHна тег и сборкой образа не только по тегу — обе сделаны, см. «Воспроизводимость сборки» ниже.Сделано.
setup.cfg:99—python_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).Сделано, и пункт оказался крупнее записанного.
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.0–2.5.2 унаследованы от апстрима и никогда не пушились. А
ближайший из них, 2.5.2, отстаёт на 18 коммитов и не содержит ни правки
диспетчера (п. 12), ни переименования .deb (п. 24) — пиннинг на него откатил
бы Фазу 0 внутри образа.
Попутно из цепочки 2 удалён кэшбастер ADD …/repository/branches/${FLOWGRAPHS_BRANCH}:
он существовал ради плавающей ветки, на неизменяемом теге кэш слоя
переиспользуется законно, а сам endpoint на тег отвечает 404 и уронил бы сборку.
Коммит флоуграфов записывается в образ — тем же приёмом, что уже был для
gr-satnogs: flowgraphs-git-hash.txt → flowgraphs_git_hash в
core/_version.py → client_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.txt →
gr_satellites_git_hash → client_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__,
на каждый проход приходит объект с чистым флагом. Тест закрепляет фактическое
поведение и назван так, чтобы правка семантики флага его уронила.
Готовность фазы¶
Два сценария:
Станция, отключённая от сети на час поверх трёх проходов и перезапущенная, догружает всё без потерь и без дублей.
Станция с 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.py
— state/ с 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 вstablestaff ставит руками, когда канарейка отработала.Применение — 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 |
контейнер жив, клиент отвечает на |
60 с |
мгновенный откат |
L2 |
|
~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 — тракт приёма¶
Порядок внутри фазы важен — от дешёвого и обратимого к дорогому.
Реалтайм-выдача кадров 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, пропускалась — кадр получал время предыдущего.Единый выбор декодера в клиенте. Есть 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, единый граф.Сопоставимый водопад. Полоса и разрешение фиксируются независимо от бодрейта. Без этого
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не разбирает (хранит строкой, показывает деревом), правок там не нужно.Единый граф приёма. Схлопывание двадцати копий
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; компиляцию подтверждает CIgrccпосле пуша. Фиксированная ветка водопада и режим sink теперь — одна правка золотого файла, если понадобятся.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 только добавляет файлы, снятое определение остаётся до нового образа; переименования редки.Мёртвые данные: ~~
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 |
|
desired-state. Сделано 2026-09-10 (решения 46–54): |
POST |
|
heartbeat: |
POST |
|
внутренние, для |
— |
|
MQTT через WebSocket, Mosquitto под профилем |
POST |
|
батч кадров, идемпотентность по content-hash, постатейный результат |
PUT |
|
payload и waterfall с хешем; повтор того же хеша — 200 |
GET |
|
sat-data bundle: |
POST |
|
публикация bundle из CI |
GET |
|
Docker Registry API. Обязательно в корне хоста — клиент Docker не умеет префикс пути. ~~Авторизация через nginx |
WS |
|
телеметрия, логи, стрим водопада |
GET |
|
не из этого плана: аналитика портала ( |
Контракт 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 |
|
отсутствует |
|
существует, размер больше нуля |
водопад |
существует, парсится, не меньше |
|
присутствует, не |
файлы |
для спутника с известным передатчиком — хотя бы один |
|
пусто |
Реализация минимальная: набор команд 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 |
|
Фаза 0.13 |
Версии |
Фаза 1, пиннинг тегов |
Нормализация булевых флагов CLI |
Фаза 3.6 — сделано 2026-09-12, решение 68 |
|
Фаза 3.6 — сделано 2026-09-12, решение 68 |
|
Фаза 3.6 |
|
~~Фаза 1~~ отпало: проверено 2026-08-27, оба ключа стоят в обоих compose и совпадают |
Решения, принятые там сознательно (отмеченные ❌ с обоснованием), здесь не переигрываются. В первую очередь — отказ от процесс-wide лока на наложившиеся проходы.
Разбор origin/soniks-update (решение 19)¶
Выполнен 2026-08-24. Ветка нашлась в двух репозиториях, а не в одном. Мёрж не делался ни целиком, ни по коммитам — как и записано в решении 19.
soniks-client-new — переносить нечего¶
Девять коммитов, 14 файлов, +2396/−276. Всё до последней строки уже есть в
soniks, и почти везде — в лучшем виде. Ветка отстала настолько, что каждый
её кусок либо повторён, либо исправлен:
Кусок ветки |
Судьба |
|---|---|
|
уже в |
|
уже в |
Прошивка LibreSDR, |
уже в |
|
уже в |
Чистка |
уже сделано, и глубже. Ветка сводит boost к |
Реструктуризация |
уже в |
|
отклонено. В |
|
отпало: скрипт переписан, клоны идут в |
|
уже в |
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 целиком |
|
~~Апгрейд 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-flowgraphs — tests/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 обязан
отловить и откатить без вмешательства человека.
Открытые вопросы¶
ui-kit для Фазы 5. ~~Каталога
soniks-frontendнет, на портале дизайн-системы не существует.~~ С 2026-09-06 существует: Bootstrap 5.3 со своим слоем токенов, без jQuery и admin-lte. Вопрос сводится к «монитор пишется на ней» — и закрывается, если никто не возразит до Фазы 5.~~Размер парка и профиль железа.~~ Отвечено 2026-09-10: около 50 станций, в основном Raspberry Pi (arm64), есть ноутбуки на Ubuntu и Windows (amd64). Отсюда решение 26.
~~Судьба
origin/soniks-update~~ — решено 2026-08-24, см. решение 19 в Принятых решениях.Политика хранения на портале. ~~
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.~~Формат sat-data bundle.~~ — решено 2026-08-28, см. решение 22 в Принятых решениях. Подписи у архива нет и не планируется, пока нет управления ключами: целостность держится на sha256 поверх HTTPS с того же хоста, что и всё остальное.
~~Ресурсы под 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не будет.~~Профиль закрытых сетей.~~ Отвечено 2026-09-10: закрытых 5–15, среди них возможны волонтёрские, SNI/DPI — неизвестно. На registry переходят все станции, чтение анонимное (решение 27). Проверить SNI/DPI — дело прогона второго сценария готовности Фазы 1. Форма registry — решение 34.