# Дорожная карта: надёжность сети Документ охватывает четыре репозитория — `soniks-client-new`, `soniks-flowgraphs`, `gr-soniks`, `soniks-network` — плюс `soniks-monitor`, который по итогам разбора перестал быть отдельным продуктом. Составлен по итогам совместной сессии 2026-08-22/24, когда впервые были доступны все репозитории сразу. Существующая [дорожная карта закрытия техдолга](roadmap.md) остаётся на месте: она ретроспективна и в основном закрыта, этот документ перспективен. Принятые там решения здесь **не переигрываются** — см. §8. **Актуализирован 2026-09-10** по состоянию портала `soniks-network@9ece8db4`: за 28 августа — 10 сентября он ушёл на 115 коммитов, и часть утверждений ниже о нём устарела. Устаревшее зачёркнуто, а не удалено — по нему видно, откуда шли. Сводка того, что изменилось для клиента, — в [](ai/context.md), раздел «Снимок». ## Где мы на 2026-09-10 | Фаза | Состояние | Что держит открытой | |---|---|---| | 0 — остановить кровотечение | **код закрыт целиком**, все 26 пунктов. Реальный проход на стенде 2026-08-26 (наблюдение 1548706, сборка `2.3.0-1-ge1fe2fa-stand`) дал `payload.ogg`, водопад `.dat` и `.png`, два `data_*`, метаданные с `snr_db` 12.1 дБ, пустой `incomplete/`. Код возврата диспетчера в той сборке ещё не логировался (`exit_code` появился 2026-08-27) | **ничего.** Три хвоста сняты решением мейнтейнера 2026-09-10: пересчёт истории оценок на паузе (п. 16), ветки `soniks-update` игнорируются (решение 19), scheduled pipeline под `docker_build_check` — потом. Переключение `JOBS_ALLOW_ANONYMOUS` — по счётчику (п. 18), фазу не держит | | 1 — надёжность обмена | **сделано**: durable spool и метаданные в нём, content-hash на портале, транспорт с `Retry`, `/healthz`, пиннинг сборки, версия из тега, расслоение образа, sat-data bundle, git убран из рантайма, тесты и порог покрытия. 2026-09-10: постоянный `400` в две ступени (решение 24), уход часов в `/healthz` (решение 25), пин gr-satellites (решение 32), `buildx` в `build.sh` (решение 26) | **сделано в коде, ждёт человека**: registry-прокси в портале (решения 34–35) — включить на серверах и переключить шаблоны compose (решение 37). **Не сделано**: ретеншен медиа (решение 28: сначала замер), станционный токен (отложен осознанно). ~~Свой образ hamlib~~ снят решением 36. Оба сценария готовности фазы на стенде не прогонялись — это прогон по команде (решение 23), фазу не держит | | 2 — агент и управление парком | **начата 2026-09-10** с канала C: `POST /api/v2/stations//status/`, `Station.reported_status`, фильтр планирования по режимам; клиент публикует `MODES` (решение 31) и отказывает неизвестный режим, когда портал их знает (решение 45) — канал C закрыт. Контракт агента v1 записан (решения 38–44). **Тем же днём, пакет 3 (решения 46–56): конфигурация станции с портала** — форма `stations//config/`, `GET state/`, клиент применяет в процессе (`remote_config`, проверка приёмника `sdr_check`), MQTT через `wss://sonik.space/mqtt`, слияние `reported_status`, `ReleaseChannel` и `update_mode` — портальная половина агента v1 сделана. **2026-09-11 — soniks-agent v1 написан** (решения 60–66): репозиторий `soniks-agent`, свой образ, pull `state/`, override с digest, L1/L2, откат, ключ `agent` в `status/`; портал его принимает и показывает, `ReleaseChannel.client_image` принимает только digest, `build.sh` печатает digest после `--push` | **Код закрыт, ждёт человека**: опубликовать образ агента (`./build.sh 0.1.0 --push` в его репозитории), раскомментировать блок `soniks-agent` в шаблонах compose, поставить digest на `canary`. Пересборка базы образа (paho-mqtt, python3-soapysdr), профиль `mqtt` и nginx на серверах — по-прежнему за человеком. `telemetry-ingest` уехал в Фазу 5 (решение 29) | | 3 — тракт приёма | **3.1 сделана 2026-09-10** (решение 33): кадры gr-satellites потоком. **3.2 сделана 2026-09-12** (решение 67): один выбор декодера по наличию satyaml, `radio.decoder` в метаданных, `gr_satellites` без satyaml не запускается. **3.6 — клиентская часть 2026-09-12** (решение 68): `MODES` без `is_baudrate`/`framing`, `--dc-removal` как `int` — прежняя форма роняла диспетчер. **Пакет 6, 2026-09-12 (решения 69–74): 3.3** — геометрия водопада и `noise_dbhz` в `client_metadata.signal`, нормирует клиент; **3.4** — общий фронтенд в `generic/fm.grc` + `tools/sync_frontend.py`, канал 1 и `doppler_correction_per_sec` вычищены, `hierarchical/` удалён; **3.5** — bundle подтягивается между проходами без рестарта, спутник с satyaml принимается при чужом режиме, `satellites` в статусе; **3.6** закрыт — `--baud` для APT/SSTV диспетчер отбрасывал с `248e606` (2026-08-22), `dev_args` доезжает до `soapy_source`. **Пакет 7, 2026-09-12: портальная половина ворот 3.5 сделана** — `satellites` принимается, `is_transmitter_mode_supported` и SQL-фильтр запусков пропускают спутник из списка при любом режиме (решение 74). **Фаза закрыта целиком** | `vmcircbuf_default_factory` (нужен запущенный GNU Radio). Фиксированная ветка водопада — одна правка золотого файла, когда понадобится (решение 69). CI `grcc` флоуграфов после пуша мейнтейнера — единственная проверка раскатки | | 4 — качество приёма | **шаг 1 сделан 2026-09-11** (решения 57–59): автоопределение приёмника и калибровка усиления по команде с портала, график и кнопка «Применить рекомендованное» в форме настроек **Пакет 7, 2026-09-12**: валидация DSP сделана — `tests/test_deviation.py` на синтетических спектрограммах, оценщик 93 %, порог покрытия CI 80 %; `signal.saturated` в метаданных; шаг 2 спроектирован (решения 75–77) **Шаг 2a сделан тем же днём**: `network/base/reception_quality.py`, блок «Качество приёма» в форме настроек портала — на лету, только показ | шаг 2b — правило коррекции `FLOWGRAPH__RF_GAIN`, отдельным решением по накопленным данным; сходимость шкал калибровки и водопада (решение 77) — проверка на стенде по команде | | 5 — монитор | не начата | ui-kit фактически есть (вопрос 1); с 2026-09-10 сюда же входит `telemetry-ingest` (решение 29) | **Пакет 1 закрыт 2026-09-10** — grill с мейнтейнером, решения 24–33. Три шага «Чем продолжать» прошлой редакции (судьба `400`, вопросы 2/6/7, Фаза 3.1) сделаны или отвечены. **Пакет 4, 2026-09-11** — soniks-agent v1 написан, решения 60–66; см. Фазу 2, «Агент v1». **Пакет 5, 2026-09-12** — Фаза 3.2 и клиентский хвост 3.6, решения 67–68. Попутно найдено: `--dc-removal=True` от клиента роняет диспетчер 2.6.0 (`type=int`) с кодом 2 — `FLOWGRAPH__DC_REMOVAL=true` означало пустой проход. **Пакет 6, 2026-09-12** — Фаза 3 до конца: 3.3, 3.4, 3.5 и хвост 3.6, решения 69–74. Попутно найдено: `docs/outputs.md` флоуграфов называл полосу FSK 9600 «38.4 кГц при децимации 4», а `find_decimation(9600, 2, 48000)` даёт 6 — 57.6 кГц (76.8 у BPSK); `doppler_correction_per_sec` был мёртв с рождения — в 20 графах параметр, в диспетчере аргумент, ни одного читателя; фронтенд 20 графов оказался тождественным байт-в-байт, кроме двух комментариев и `scale_factor` в недостижимом `example_flowgraph`. **Пакет 7, 2026-09-12** — Фаза 3 закрыта портальной половиной решения 74; вход в Фазу 4: валидация DSP и контракт шага 2, решения 75–77. Попутно найдено: тесты водопада в оценщик не входили вовсе (`nchan=4` упирался в guard `nchan < 16`, `dsp.py` исполнялся на 6 %), а «прогон `plot()` на синтетическом сигнале» из Фазы 4 был щедрой формулировкой; критерий насыщения `bw99 > 0.85·samp_rate` недостижим из-за среза ±0.45 (шапка обещает `/2`); `interferer_rejected` значит «оценён сигнал не по центру», а не «помеха отброшена». **Пакет 2 закрыт в тот же день** — grill, решения 34–45. Registry стал прокси Docker Hub вместо своего push-канала, свой hamlib снят, контракт агента v1 записан, явный отказ неизвестного режима сделан без ожидания выкатки портала. **Чем продолжать (пакет 3).** Каждый шаг закрывается тестами репозитория, стенда не ждёт (решение 23): 1. Включить registry-прокси — руками, по разделу «Registry образов станций» в `docs/dev/deploy.md` портала: токен Docker Hub, `COMPOSE_PROFILES`, каталог кэша, сниппет nginx. Сначала стенд-сервер портала, потом прод; проверка — `docker pull sonik.space/sonikspace/soniks-client:latest-addons` снаружи. 2. После подтверждённого pull — переключить шаблоны compose станции и `docs/station/` на `sonik.space/…` (решение 37). 3. ~~soniks-agent v1 (решения 38–44): отдельный репозиторий~~ **Сделано 2026-09-11** (пакет 4, решения 60–66): репозиторий `soniks-agent`, портал принимает ключ `agent`. За человеком: `git init` и первый коммит репозитория агента, `./build.sh 0.1.0 --push` там же, раскомментировать блок `soniks-agent` в шаблонах compose (решение 37 по аналогии: пока образа нет, активный блок уронил бы `up` новой станции), digest клиента на канал `canary` в админке. Перед выкаткой клиента с конфигурацией с портала — пересобрать базу (`paho-mqtt`, `python3-soapysdr`) и включить профиль `mqtt` портала с nginx-сниппетом («MQTT-брокер» в `docs/dev/deploy.md` портала). 4. Пересобрать базу: в неё входят пин gr-satellites (решение 32) и второй этап платформ, `PLATFORMS=linux/arm64,linux/amd64` (решение 26). 5. Ретеншен медиа: замер S3 (команда — в открытом вопросе 4) выполняет мейнтейнер, lifecycle-правило — по его цифрам (решение 28). **Прогоны на стенде, готовые к команде.** Ждут не очереди, а слова человека: * два сценария готовности Фазы 1 — час без сети поверх трёх проходов, и старт за firewall с одним `sonik.space` (после шагов 1–2 «Чем продолжать» образы идут через registry-прокси, решение 34; до них второй упрётся в Docker Hub — это и будет измеренным результатом); * реальный проход на текущей сборке: в прогоне 2026-08-26 ещё не было `exit_code`, `mode_known` и `sat_data` в метаданных; * Фаза 3.1: кадр gr-satellites появляется на портале через секунды после приёма, дублей нет, в `/healthz` есть `clock_skew_seconds`; * Фаза 2, агент: на стенде раскомментирован `soniks-agent`, станция в `managed`, на `canary` — digest сборки стенда; агент применяет его в окне между проходами, `agent.last_apply.gate == "L2"` на портале. Затем заведомо сломанный конфиг (`OBSERVATION__SOAPY_RX_DEVICE=driver=nonexistent` в форме портала) плюс новый digest — L2 проваливается, агент откатывает, `last_apply.ok == false`, клиент отвечает на `/healthz` старым образом. **Отложено решением мейнтейнера 2026-09-10** — к фазам не привязано и ничего не держит: * пересчёт истории оценок на проде. Завышенный `success_rate` за период до починки п. 16 допустим; важно, что после обновления оценка честная; * удаление веток `soniks-update` в клиенте и флоуграфах. Остаются как есть и игнорируются — материалом для чтения они по-прежнему служат; * scheduled pipeline под `docker_build_check`. Джоба есть и запускается вручную; расписание — когда понадобится. ## Зачем **Сеть теряет данные, не зная об этом.** Найдено несколько независимых каналов тихой потери: от разорванного контракта клиент↔диспетчер до сломанной оценки успешности наблюдений на портале. Общее у них одно — ни один не виден. Нет ошибки в логе, нет провалившегося наблюдения, нет сигнала оператору. Станция может месяцами писать шум и считаться исправной. Причина в том, что ни один слой системы не наблюдаем: у станции нет health, у портала нет метрик, у оператора нет способа отличить работающую станцию от пишущей шум. И нет способа что-либо исправить удалённо — обратного канала от портала к станции не существует вовсе. Цель — сделать сеть наблюдаемой, управляемой и обновляемой, и только потом поднимать качество и объём приёма. Порядок именно такой: переделка тракта приёма — единственное изменение здесь, способное одномоментно оглушить весь парк, и катить его следует поверх работающего механизма отката. ## Состояние на входе | Репозиторий | Роль | Состояние | |---|---|---| | `soniks-client-new` | клиент станции, Python 3.11 | 2.2.2, активная разработка. Тег `2.3.0` поставлен, версию задаёт тег GitLab (см. Фазу 1) | | `soniks-flowgraphs` | флоуграфы GNU Radio + диспетчер | форк satnogs-flowgraphs, ветка `soniks`. С 2026-08-24 пиннится тегом `2.6.0`, новых коммитов с тех пор нет | | `gr-soniks` | форк gr-satnogs (C++ OOT) | **чистое зеркало, 0 своих коммитов, ~~к сборке не подключён~~** подключён 2026-08-24 по тегу `v3.1.0.1` | | `soniks-network` | портал | ~~Django 4.0.10 / Python 3.9~~ **Python 3.12 / Django 5.2 LTS с 2026-09-02**, форк satnogs-network | | `soniks-monitor` | монитор станции | 1357 строк, серверной части не существует | ### Прямые потери данных, происходящие сейчас | # | Что | Где | Эффект | |---|---|---|---| | A | Клиент шлёт `--norad-cat-id`, диспетчер ветки `soniks` такого аргумента не знает → `parse_args()` вышел бы с кодом 2 **до** запуска графа | `src/soniks_client/flowgraph.py:64` против `flowgraph_dispatcher.py:496` | ~~латентный, сейчас не стреляет~~ **закрыт (п. 1/12)**: диспетчер разбирает `parse_known_args()` и знает оба аргумента. Снимается с парка выкаткой образа, не коммитом | | A′ | ~~То же с `--lo-transverter`: клиент шлёт, диспетчер не знает~~ **закрыт (п. 1/12)** | `src/soniks_client/flowgraph.py:41` | латентный: по умолчанию `None` и не отправляется, но станция, задавшая `FLOWGRAPH__LO_TRANSVERTER`, глохнет так же и так же тихо | | B | ~~`rate_observation` сравнивает объект `Mode` со строками: `not in ["CW","FM"]` всегда истинно~~ **закрыт (п. 16)** | `network/base/rating_tasks.py` | любой пришедший кадр делал наблюдение Good. Вся статистика сети, `success_rate` и «needs attention» были построены на этом. Пересчёт истории на паузе (решение мейнтейнера 2026-09-10): старый период остаётся завышенным | | C | Портал планирует режим, которого нет в таблице клиента; клиент подставляет `FM` | `network/base/tasks.py:489` создаёт `Mode` из фида SatNOGS DB; `src/soniks_client/observation_scripts.py` | ~~в логах ни строчки~~ **виден с 2026-08-27**: предупреждение в логе и `client_metadata.radio.mode_known`. Сама подмена остаётся до фильтрации планирования — Фаза 2 | | D | ~~Вся `PATHS__BASE`, включая очередь `incomplete/`, лежит в tmpfs~~ **закрыт (п. 9)** | `src/core/configs/path.py:15` плюс `type: tmpfs` в обоих compose | любой перезапуск стирает невыгруженные данные | | E | ~~Одно битое задание от портала обнуляет всё расписание~~ **закрыт (п. 2)** | `src/soniks_client/api.py:74` — список собирается внутри одного `try` | проходы не планируются, пока портал отдаёт битую запись | | F | ~~Наивная метка времени от портала роняет цикл синхронизации~~ **закрыт (п. 3)** | `src/soniks_client/models.py:57` → `sync.py:140` | `TypeError` при сравнении, задания не обновляются | | G | ~~Метаданные наблюдения при ошибке сети теряются безвозвратно~~ **закрыт 2026-08-26** (Фаза 1, durable spool) | `src/soniks_client/api.py:88` | блок `signal` (SNR, девиация, ppm) исчезал, очереди для него не было | | H | ~~Одиночный 404 удаляет директорию наблюдения целиком~~ **закрыт (п. 6)** | `src/soniks_client/jobs/sending_data.py:147` | без второй попытки и без подтверждения | | I | ~~`CLIENT_VERSION` не прокидывается в `buildx bake`~~ **закрыт (п. 11)** | `.gitlab-ci.yml:216` | образ с тегом `2.3.0` рапортует порталу `2.2.2` | | J | ~~Приложение продолжает работать с мёртвым планировщиком~~ **закрыт (п. 5)** | `src/main.py:111` | после трёх неудачных перезапусков просто пишет в лог; Docker не перезапустит | #### Уточнение по каналу A: проверено на стенде 2026-08-24 Изначально канал A был записан как активная потеря — «сеть пишет пустоту на каждом проходе со спутником». Прогон на живой станции это **опровергает**. Цепочка рвётся на звено раньше: | Звено | Факт | |---|---| | Диспетчер | `flowgraph_dispatcher --help` → код 0, `--norad-cat-id` в списке опций **отсутствует** | | Клиент | `--norad-cat-id` **шлёт**, если `norad_cat_id is not None` | | Портал `/api/jobs/` | поля `norad_cat_id` **нет ни в одном из 59 заданий** — его нет в сериализаторе вовсе | `job_data.get("norad_cat_id")` всегда `None`, аргумент не добавляется, `parse_args()` не падает. В логах станции за 48 часов ноль `unrecognized arguments`, проходы отрабатывают штатно. **Клиентская сторона — не дефект, а задел.** `--norad-cat-id` добавлен в клиент намеренно: gr-satellites выбирает декодер по NORAD через satyaml, и без этого аргумента функционал не включить. То есть из трёх звеньев контракта клиентское уже готово, а недоделаны два других — диспетчер и `JobSerializer`. **Следствие для порядка работ.** Приоритет Фазы 0 из-за канала A поднимать не нужно: сеть данные не теряет. Опасен именно порядок доделки. Если `norad_cat_id` появится в `JobSerializer` раньше, чем станция научится аргументу, `parse_args()` начнёт валиться на каждом задании со спутником и парк оглохнет разом, без единой ошибки в логе. До 2026-08-27 отсюда следовал вывод «сначала выкатить исправленный образ на парк, потом править портал». **Вывод снят решением 21: выкатка на весь парк невыполнима в принципе.** Ждать нечего — июльские станции останутся июльскими навсегда, а именно они и опасны: `--norad-cat-id` появился в клиенте в `2.2.0` (`cc54042`, 2026-07-01), образ парк получил 19 июля, то есть комбинация «клиент шлёт, диспетчер не знает» уже стоит на станциях. Станции на `1.x` аргумент не шлют и безопасны. Правильный порядок — не выкатка, а **опт-ин**: портал отдаёт `norad_cat_id` только той станции, которая объявила, что понимает его. Необновлённая получает ответ байт-в-байт как раньше и сломаться не может, сколько бы её ни не обновляли. **Состояние на 2026-08-27.** Обе половины опт-ина сделаны — клиентская (`CAPABILITIES` в `src/soniks_client/api.py`) и портальная (`JobSerializer.norad_cat_id`), подробности [ниже](#правило-21-на-практике). ```{warning} `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. Чинить надо не её, а тот канал, который работает, — см. «Выкатка образа» в [](development/contributing.md). ``` Тем же диффом контракта найден канал 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`, то есть чужая страница с ``). При этом `GET /api/jobs/` открыт анонимно — ~~у `JobView` нет `permission_classes`~~ с 2026-08-24 это выключатель `JOBS_ALLOW_ANONYMOUS` (п. 18): токен нужен только чтобы обновился `last_seen`. Готовый класс `ClientIDAuthentication` написан и никуда не подключён — на 2026-09-10 по-прежнему. ~~**Два независимых декодера на один сигнал.**~~ Первый — `satnogs_*.py`, реалтайм, выбор по `mode` от портала. Второй — `gr_satellites`, выбор по NORAD через satyaml, питается UDP-потоком IQ от того же графа, ~~но кадры разбираются только на `stop` (`scripts/grsat.py:281`) — то есть уже после остановки watchdog~~ (потоком с 2026-09-10, Фаза 3.1). Из двадцати флоуграфов блоки gr-satellites используют два. **С 2026-09-12 выбор один** (Фаза 3.2, решение 67): клиент проверяет satyaml для NORAD, без него `gr_satellites` не запускается — раньше он стартовал на каждом проходе и умирал молча в `DEVNULL`. **Двадцатикратная копипаста.** Блок `soapy_source` с ~45 полями (включая мёртвый канал 1) и доплер-сниппет продублированы построчно во всех графах. Каталог `hierarchical/` с шестью hier-блоками задуман как лекарство и не используется ни одним графом. **Усиление статическое.** Аппаратная AGC жёстко выключена во всех графах — и это осознанно: для узкополосных пакетных сигналов она подстраивается под сильнейший сигнал в полосе, обычно помеху. Но при `GAIN_MODE=Overall` и незаданном `RF_GAIN` аргумент `--gain` не передаётся вовсе, и работает драйверный дефолт. Обратная связь существует — водопад считает `snr_db`, `noise_db`, `peak_db` — и никуда не заводится. **Водопады несопоставимы.** Полоса равна `out_samp_rate`, а он зависит от бодрейта режима: 38.4 кГц для 9600, 48 кГц для FM, 66.56 кГц для APT. Значит `noise_db` между режимами сравнивать нельзя. **Станция требует открытого интернета, а его нет.** Часть парка стоит в закрытых сетях, где доступен только `sonik.space`. На входе станция тянула извне четыре вещи: `docker.io/librespace/hamlib:4.5.4` (rigctld, rotctld), `docker.io/sonikspace/soniks-client`, и — **при каждом старте контейнера** — git-клоны `github.com/daniestevez/gr-satellites` и `gitlab.com/.../soniks-satyaml` (`scripts/liveupdate-satyaml.sh`). В закрытой сети клон падал, скрипт печатал предупреждение, станция стартовала с вкомпилированным satyaml — то есть тихо устаревшим. **Два последних канала закрыты 2026-08-28**: определения приезжают bundle'ом с портала. Осталось два обращения, оба за образами; ~~оба ждут registry на `sonik.space` — то есть ответа на открытые вопросы 6 и 7~~ registry-прокси сделан (решение 34), они закрываются его включением на сервере и переключением шаблонов compose (решение 37). ~~**Портал на пределе.** Один sync-воркер gunicorn без `--timeout`, Django 4.0.10 (вне поддержки с апреля 2023), Python 3.9 (EOL), Sentry импортирован но не инициализирован, `CORS_ALLOW_ALL_ORIGINS = True`, `.env` копируется в образ и уезжает в registry.~~ **Закрыто целиком к 2026-09-04.** Воркеры по формуле gunicorn и таймаут 600 (п. 23), Sentry снят вместе с SDK (п. 21), `.env` в образ не попадает (п. 22), `CORS_ALLOW_ALL_ORIGINS` оставлен сознательно (решение 20). Апгрейд, который здесь считался «нужен, но не блокирует», сделан 2026-09-02: Python 3.12, Django 5.2 LTS, DRF 3.18, Redis 7.4, Debian bookworm; портал по-прежнему WSGI, push к станции невозможен как и раньше. Сверх плана: Bootstrap 5.3 вместо 4.6 и admin-lte, jQuery снят целиком, OpenAPI-схема генерируется под пиннутой локалью и сверяется в CI. ~~**Проверять по-настоящему нечем.** Нет ни одного теста на `execute_observation`, `main.Application`, `api.py`, `communication_session.py` (360 строк конкурентного кода), `waterfall/deviation.py` (519 строк DSP). Файл `tests/e2e_check.py` в flowgraphs написан и к CI не подключён.~~ **Закрыто на клиенте и во флоуграфах** 2026-08-24/27 (Фаза 0.14, Фаза 1 «Тесты»); открытой осталась только валидация DSP — Фаза 4. На портале за тот же срок число тестовых функций выросло с ~180 до 920 с лишним, и среди них — сторожа контрактов, на которые опирается клиент (`JobCapabilitiesTest`, `AlreadyUploadedResponseTest`, `DeprecatedVettedFieldsTest`). ## Принятые решения | # | Вопрос | Решение | |---|---|---| | 1 | Владение парком | Смешанный: ядро плюс волонтёры. Флаг `managed` / `self-managed` на станции | | 2 | Свобода менять портал | Полная, обе стороны контракта — наша работа | | 3 | Единица обновления | Три слоя: `soniks-base` (GNU Radio, gr-soniks, flowgraphs — гигабайты, меняется редко) → `soniks-client` (`FROM base`, десятки МБ, меняется часто) → sat-data bundle (satyaml, `sat.cfg`, таблица режимов; версионируется отдельно, раздаётся порталом). Версия станции = digest клиентского образа плюс версия bundle | | 4 | Кто обновляет | Отдельный контейнер-агент с доступом к `docker.sock` | | 5 | Транспорт управления | Pull REST; desired-state как версионированный документ с `generation` — задел под MQTT | | 6 | Граница конфига | Портал **читает** весь фактический конфиг; **меняет** тракт приёма и железо | | 7 | Критерий применения | Трёхуровневый health-gate плюс автооткат. **В агенте v1 — L1 и L2** (решение 42), L3 позже | | 8 | Архитектура тракта | Единый граф приёма, gr-satellites как основной декодер | | 9 | Гарантия доставки | Durable spool на диске плюс идемпотентность по content-hash | | 10 | gr-satnogs | Подключить существующий форк `gr-soniks`, пиннить тег, свои правки туда | | 11 | Подбор усиления | Гибрид: калибровка на станции плюс статистическая подстройка порталом | | 12 | soniks-monitor | Сливается с агентом в единый `soniks-agent`. **Смягчено решением 30**: агент пишется с нуля, монитор — только материал | | 13 | Приём телеметрии | Отдельный ASGI-сервис рядом с порталом. **Одно из оснований отпало 2026-09-02**: довод «тащить ASGI в Django 4.0.10 значит связать мониторинг с большим апгрейдом» снят самим апгрейдом. Остались довод о нагрузке и о том, что логи и стрим водопада в ORM не ложатся. Решение не переигрывается здесь — ~~перепроверить на входе в Фазу 2~~ сервис уехал в Фазу 5 (решение 29), там и перепроверить | | 14 | Согласование возможностей | Станция публикует capabilities → портал фильтрует планирование → клиент отказывает явно. **Все три звена сделаны 2026-09-10** (решения 31 и 45) | | 15 | Порядок фаз | Строго 0 → 1 → 2 → 3 → 4 → 5 | | 16 | Реальный стенд | Dev-агент с SSH-доступом, инструмент разработки | | 17 | Доставка образов | Свой registry на `sonik.space/v2/`. ~~Ни одного чужого образа на станции, включая пересобранный hamlib~~ **Ни одного чужого хоста** — переписано решением 36: registry — прокси Docker Hub (решение 34), hamlib — образ LSF через него | | 18 | Внешние зависимости рантайма | Убрать полностью: `liveupdate-satyaml.sh` перестаёт клонировать из git. **Сделано 2026-08-28** — см. решение 22 и Фазу 1 | | 19 | `origin/soniks-update` | **Не мёржить ни целиком, ни по коммитам.** Экспериментальная ветка мейнтейнера: попытка обновить flowgraphs, качество кода и результат не проверялись. Служит материалом для чтения, не источником правок. Разбор — отдельной сессией: перенести годное, отменить негодное, ~~по итогам ветку удалить~~. **Разбор выполнен 2026-08-24**, переносить оказалось нечего — см. [ниже](#разбор-originsoniks-update-решение-19). Удалять ветку не будем: 2026-09-10 решено её игнорировать | | 20 | `CORS_ALLOW_ALL_ORIGINS` на портале | **Оставить `True`.** Сеть открытая, её API предназначен для запросов откуда угодно — это назначение сайта, а не упущение. Предлагалось свести к `DEBUG` со списком источников и отклонено 2026-08-24. В `settings.py` рядом стоит комментарий | | 21 | Совместимость с парком | **Парк не обновится целиком никогда.** Часть станций в закрытых сетях, часть у волонтёров, часть просто не тронут. Отсюда правило: изменение портала **не имеет права менять то, что отдаётся станции, которая об этом не просила**. Новое — либо по явному опт-ину клиента, либо под `/api/v2/`. Станция на `1.2.8` работает как раньше бессрочно, обновлённая получает больше функционала. Принято 2026-08-27; отменяет предусловие «сначала выкатить образ на парк» везде, где оно стояло — см. [ниже](#правило-21-на-практике) | | 22 | Формат и сборка sat-data bundle | Архив `tar.gz` с `satyaml/` и `sat.cfg`; собирает и публикует CI `soniks-satyaml`, версия — короткий SHA его коммита; целостность — sha256 поверх HTTPS, подписи нет; опубликованная версия неизменяема. Отсюда же: `sat.cfg` переехал в `soniks-satyaml` — это данные спутников, а не конфиг станции, и версионироваться они обязаны одним коммитом. Принято 2026-08-28, снимает открытый вопрос 5 | | 23 | Роль стенда в разработке | **Прогон на стенде — только по команде человека, и разработка его не ждёт.** Без команды проверка — тесты и линтеры репозитория (раздел «Верификация»), и работа идёт дальше, до конца плана. Прогон может быть назначен в любой момент, по любой уже сделанной правке. Разделы «Готовность фазы» описывают, что стенд проверяет, когда прогон назначен; переход к следующей фазе они не блокируют — порядок фаз (решение 15) держится на коде и зелёных тестах. Решение про разработку, а не про выкатку на парк: выкатка по-прежнему ручная и за человеком. Принято 2026-09-10 | | 24 | Постоянный `400` на файл выгрузки | Любой `400` окончателен, код ответа не разбирается. Две ступени, как у 404: первый отказ — файл в `incomplete/`, повторный при повторной отправке — файл закрывается как выгруженный, по `REMOVE_OBSERVATION_DATA` (удалить или в `complete/`). Одно правило для кадров и метаданных. Принято 2026-09-10 | | 25 | Уход часов станции | Виден уже сейчас, до health-gate: клиент сверяет часы с заголовком `Date` ответа `/api/jobs/`, расхождение — `clock_skew_seconds` в `/healthz`, больше 60 с — предупреждение в лог. Принято 2026-09-10 | | 26 | Платформы | Все: `linux/arm64` (RPi, большинство из ~50 станций) и `linux/amd64` (ноутбуки на Ubuntu и Windows/Docker Desktop). Выкатка в два этапа — сначала arm64. Сборка — `docker buildx` в `build.sh`, CI-джоба `docker` не чинится. Принято 2026-09-10 | | 27 | Registry на `sonik.space` | На него переходят все станции, а не только закрытые. Чтение анонимно — образы и так публичны, как и bundle; ~~push — только CI. Хранится текущая и предыдущая версия каждого образа — ради отката~~. Принято 2026-09-10, снимает вопросы 6 и 7. **Пересмотрено решением 34 в тот же день**: «push только CI» противоречило решению 26 — CI-джоба сознательно не чинится. Registry — прокси, push нет вовсе, версии хранит Docker Hub | | 28 | Ретеншен медиа | S3 на Yandex Object Storage, больше 10 ТБ, всё хранится вечно. Сначала замер объёма по типам, потом lifecycle-правило бакета — старое в холодное хранилище. Удаление решается по цифрам замера. Принято 2026-09-10 | | 29 | `telemetry-ingest` | Из Фазы 2 в Фазу 5: готовности Фазы 2 (раскат, откат, версии) телеметрия, логи и стрим водопада не нужны. Форма сервиса (решение 13) решается там. Принято 2026-09-10 | | 30 | soniks-agent | Пишется с нуля как продукт для массового публичного парка. `soniks-monitor` — студенческий, шлёт данные на сторонний сервер, токен — в URL и в логе; его куски (логи через `docker.sock`, разбор водопада) — материал Фазы 5. Смягчает решение 12. Принято 2026-09-10 | | 31 | Как станция объявляет режимы | `POST /api/v2/stations//status/` с `modes`; портал хранит документ целиком в JSON-поле `Station.reported_status` — агент потом допишет ключи без миграций. Станция без `modes` умеет всё (правило 21). Принято 2026-09-10. **Уточнено решением 41**: портал пишет документ целиком, и агент стирал бы `modes` — запись станет слиянием по ключам верхнего уровня | | 32 | gr-satellites в образе | Пиннится коммитом `main` (`GRSATELLITES_REF`), хеш уезжает в `client_metadata.radio.gr_satellites` — как gr-soniks и флоуграфы. Принято 2026-09-10 | | 33 | Фаза 3.1 | `--kiss_server` gr_satellites плюс потоковый читатель `grsat.py stream`; `--kiss_out` остаётся страховкой, дубли отсеивает content-hash портала. ZMQ не используется: в сообщении нет метки времени и нужен `pmt`. Принято 2026-09-10 | | 34 | Форма registry | **Pull-through cache Docker Hub** (`registry:3.0.0`, proxy mode). Push нет, `build.sh` публикует на Docker Hub как раньше. Имена на станции: `sonik.space/sonikspace/soniks-client:<тег>`, `sonik.space/librespace/hamlib:4.5.4`. nginx пропускает только эти репозитории — иначе открытое зеркало всего Docker Hub; прокси ходит с read-only токеном Docker Hub. Пересматривает 27. Принято 2026-09-10 | | 35 | Кэш registry | Локальный диск сервера, `/opt/storage/network_volumes/registry`, неиспользуемое вытесняется через 168 ч. Сервис под compose-профилем `registry`: локально не стартует; сначала стенд-сервер портала, потом прод. Принято 2026-09-10 | | 36 | Hamlib | Образ LSF через прокси: он — `debian:bookworm` плюс apt-пакет `libhamlib-utils=4.5.4-1+b1`, наша база на том же bookworm с тем же пакетом. Пересборка дала бы байт-в-байт то же ещё одним образом на поддержке. Переписывает 17. Принято 2026-09-10 | | 37 | Шаблоны compose станции | Переключаются на `sonik.space/…` только после того, как человек подтвердит `docker pull` через прокси: раньше новая установка не поднимется. Уже стоящие станции не затрагиваются — compose лежит у них. Принято 2026-09-10 | | 38 | Единица раската | Каналы `canary` и `stable`, цель — digest, а не тег. Продвижение canary → stable вручную, staff в админке: на 2–3 станциях канарейки «N часов без провала» ничего не доказывает. Принято 2026-09-10 | | 39 | Что меняет агент v1 | Только образ клиента. Bundle остаётся `latest`, как сейчас; конфиг тракта (решение 6) — агент v2. Принято 2026-09-10 | | 40 | Как агент применяет образ | Compose override, приём `tools/stand.sh`: `docker-compose.override.yml` с `image: …@sha256:…`, `docker compose up -d soniks-client`. Откат — тот же файл с предыдущим digest; предыдущий образ агент на станции не удаляет. Принято 2026-09-10 | | 41 | Два писателя в `reported_status` | Слияние по ключам верхнего уровня: клиент владеет `modes` и `client_version`, агент — `agent`. Правка view портала — вместе с агентом. Уточняет 31. Принято 2026-09-10 | | 42 | Health-gate агента v1 | L1 и L2. L2 — `timeout 60 test-flowgraph.sh` только когда следующий проход дальше 5 минут; успех — код 124 и непустой `test.dat`. L3 — позже, провал прохода виден на портале по `exit_code` и `snr_db`. Уточняет 7. Принято 2026-09-10 | | 43 | Режим по умолчанию | `notify`; `managed` включает владелец в настройках станции. Волонтёрская станция не катится без согласия владельца. Принято 2026-09-10 | | 44 | Репозиторий агента | Отдельный `soniks-agent`, свой образ. Самообновления в v1 нет — агента обновляет оператор. Аутентификация — токен владельца из `.env` станции, станционный токен отложен. Принято 2026-09-10 | | 45 | Явный отказ неизвестного режима | Только если `publish_station_status()` в этом процессе прошёл успешно: принять режимы может лишь портал с фильтром, значит станции такой режим планироваться не должен был. Отказ до создания путей, как у `NoCompatibleRxDeviceError`; иначе — подмена на `DEFAULT_MODE`, как раньше. Выкатки портала не ждёт. Закрывает третье звено решения 14. Принято 2026-09-10 | | 46 | Кто применяет конфигурацию станции | **Сам soniks-client, не агент.** Всё управляемое клиент читает из `settings` в момент использования: аргументы графа строятся на каждый проход, контроллеры ротатора и рига — под `lru_cache`, который сбрасывается, уровни логов — `setLevel`. Перезапуск контейнера не нужен, «агент v2 для конфига» из решения 39 снимается; агент — только образ. Уточняет 6. Принято 2026-09-10 | | 47 | Транспорт «сразу и в обе стороны» | **MQTT через WebSocket на 443** (`wss://sonik.space/mqtt`): Mosquitto + `mosquitto-go-auth` с HTTP-auth в портал (`/api/v2/mqtt/auth/`, `/acl/`, закрыты nginx снаружи), профиль compose `mqtt`. Портал публикует `stations//state` (retained) при сохранении формы, мост `mqtt_bridge` пишет `status`/`online` (Last Will) в БД. REST `GET state/` с ETag — источник истины и фолбэк на старте и каждой сверке. Переписывает 5, снимает строку «MQTT вместо pull REST» из «Сознательно отложено». Браузер к брокеру не подключается — страница опрашивает owner-only GET. Принято 2026-09-10 | | 48 | Что портал вправе менять | Тракт + ротатор/риг + уровни логов: `FLOWGRAPH__*` (кроме `UDP_DUMP_*`, `FLOWGRAPH_DISPATCHER`, `DEFAULT_MODE`), `OBSERVATION__SOAPY_RX_DEVICE`, `REMOVE_*`, `RUN_*_SCRIPT`, `ANTENNA__ROTATOR__*`, `ANTENNA__RIG__*`, `LOG__LEVEL/SCRIPT_LEVEL/FLOWGRAPH_LEVEL` — 34 имени, `MANAGED_FIELDS` клиента = `STATION_CONFIG_FIELDS` портала. Не управляются `STATION__*`, `URL__*`, `PATHS__*`, `HEALTH__*`, `SCHEDULER__*`, `API__*` и модель ротатора в compose `rotctld`. Принято 2026-09-10 | | 49 | Форма документа конфигурации | **Плоский словарь с именами переменных окружения** (`{"FLOWGRAPH__RF_GAIN": 25}`) — та же лексика, что `docs/station/environment_variables.md`; один словарь имён на портал, клиент и документацию, сторож — `test_managed_fields_are_documented`. Клиент собирает разделы `model_validate({**baseline_из_.env, **overrides})`: снятый с портала ключ возвращается к `.env`, а не остаётся от прошлого поколения. Принято 2026-09-10 | | 50 | Проверка перед применением | Открыть приёмник python-биндингом SoapySDR (`sdr_check`) в подпроцессе с таймаутом 20 с, только когда менялись параметры приёмника и только вне прохода; идущий проход откладывает применение до следующей сверки. Отказ по железу повторяется на следующей сверке (приёмник могли ещё не подключить), отказ по документу — нет. Без биндинга (старая база) проверка пропускается с предупреждением. L2 из решения 42 для конфига не нужен. Принято 2026-09-10 | | 51 | Переезд с `.env` на портал | Станция публикует фактическую конфигурацию (`config.actual` в `status/`); форма портала предзаполняется ею, пока владелец ничего не сохранил, и кнопкой «Заполнить с станции» после. `generation` 0 — станция на `.env`; после первого сохранения портал главный. Принято 2026-09-10 | | 52 | Минимальный `.env` | `STATION__ID` и `STATION__TOKEN`. Координаты берутся из `state.location`; заданные в `.env` имеют приоритет и уходят в `/api/jobs/` как раньше, незаданные — не уходят (портал пишет только `last_seen`). Принято 2026-09-10 | | 53 | Два писателя в `reported_status` | **Слияние по ключам верхнего уровня сделано** (уточнение 41): клиент владеет `modes`, `client_version`, `config`; мост MQTT — `connection`; агент потом — `agent`. ~~`modes` пока обязателен — клиент всегда шлёт полный документ; ослабить вместе с агентом~~ — с 2026-09-11 `modes` необязателен (отсутствует — прежний список остаётся, пустой — `400`), а `client_version` из статуса обновляет колонку `Station.client_version`, которую раньше писала только выгрузка наблюдения. Принято 2026-09-10 | | 54 | Портальная половина агента v1 | Сделана: `ReleaseChannel` (`canary`/`stable`, `client_image`, правит staff), `Station.release_channel`, `Station.update_mode` (владелец, в форме конфигурации), `state.release`. Сам агент не пишется. Принято 2026-09-10 | | 55 | Поля `satnogs_*` на портале | Четыре поля и бутафорская вкладка удалены, заменены `desired_config`/`config_generation`. `get_default_station_configuration*` остаются — на них ссылается миграция 0005. Принято 2026-09-10 | | 56 | Фронтенд формы конфигурации | Django-шаблоны, Bootstrap 5.3, vanilla JS — как весь портал (снимает открытый вопрос 1 и для этой формы). Принято 2026-09-10 | | 57 | Автоопределение приёмника | Станция сама перечисляет приёмники SoapySDR и их возможности — входы, диапазоны усиления (общий и покаскадные), частоты дискретизации, диапазон частот (`src/soniks_client/sdr_survey.py`, на фундаменте `sdr_check`): на старте, раз в `SDR__SCAN_INTERVAL_IN_MINUTES` в простое и по команде с портала. Итог — ключ `sdr` статуса; в форме портала у каждого найденного приёмника кнопка «Использовать» подставляет строку устройства, вход, границы усиления и частоту дискретизации. Принято 2026-09-11 | | 58 | Команды портала станции | Не отдельный канал, а блок `commands` документа `state/`: `{"rescan_sdr": {"at": iso}, "calibrate_gain": {"at": iso, "frequencies": [Гц]}}`, на портале — `Station.commands`, кнопки в форме настроек публикуют документ в MQTT. Команда выполняется раз на отметку `at`, только вне прохода и когда до ближайшего дальше `SDR__IDLE_WINDOW_IN_MINUTES`; выполненные отметки — в `sdr-survey.json` рядом с файлом состояния. ETag `state/` считается по телу, поэтому команда доезжает и REST'ом. Принято 2026-09-11 | | 59 | Калибровка усиления — Фаза 4, шаг 1 | По команде: свип по общему усилению шагом `SDR__GAIN_STEP_DB` на частотах антенн станции (портал берёт середины диапазонов), средняя мощность на шаге. Рекомендация — первое усиление, на котором шумовая полка поднялась на `SDR__NOISE_LIFT_DB` (10 дБ) над минимумом; одно значение на все диапазоны — наибольшее из рекомендованных. Итог — ключ `calibration` статуса; портал рисует график (Chart.js) и кнопкой ставит рекомендованное в `FLOWGRAPH__RF_GAIN`. Шаг 2 (статистическая подстройка) по-прежнему ждёт Фазу 3.3. Принято 2026-09-11 | | 60 | Где живёт агент | **Сервис того же compose-проекта, что и клиент.** `/healthz` клиента не выведен на хост, а `docker compose up -d soniks-client` из агента обязан попасть в проект хоста: имя проекта, рабочий каталог и файлы конфигурации агент берёт из лейблов своего контейнера (`com.docker.compose.project*`), каталог станции монтируется в него по тому же пути (`${PWD}:${PWD}`). Каталога нет — ошибка в лог, агент только наблюдает. Принято 2026-09-11 | | 61 | Override | Сливается, не перезаписывается: PyYAML читает `docker-compose.override.yml`, меняется только `services.soniks-client.image`. У файла есть второй писатель — `tools/stand.sh deploy` и оператор. Принято 2026-09-11 | | 62 | Когда качать, когда применять | `docker pull` — сразу, применение (`up -d`) — только в окне: проход не идёт и до ближайшего дальше `AGENT__WINDOW_IN_MINUTES` (5). Один критерий на применение и на L2 (решение 42). Принято 2026-09-11 | | 63 | Повтор после провала | Провалившийся gate digest не применяется повторно `AGENT__RETRY_AFTER_HOURS` (24): иначе агент раз в минуту рестартовал бы клиента об тот же образ. Новый digest в канале — сразу. Ошибка `pull` (сеть) повтора не задерживает. Принято 2026-09-11 | | 64 | Окружение L2 | `docker compose exec -e` со всеми парами `state.config`: это фактические настройки клиента, а `test-flowgraph.sh` падает на `${FLOWGRAPH__RX_SAMP_RATE?}` у станции с минимальным `.env` (решение 52). Принято 2026-09-11 | | 65 | `notify` | Только поле `available` в `agent` и строка в лог раз на digest. Принято 2026-09-11 | | 66 | Транспорт агента | Только REST: `state/` раз в минуту с ETag, `status/` при изменении документа. MQTT не нужен — и та же личность `station-` в двух paho-клиентах давала бы взаимные дисконнекты. Принято 2026-09-11 | | 67 | Фаза 3.2 — один выбор декодера | Правило одно, в `sat_data.decoder_for()`: есть `norad:` в satyaml пакета `satellites` → `gr-satellites`, иначе `satnogs`; нет пакета — `satnogs`, честно. Без кэша: сотни файлов в миллисекунды, а кэш пришлось бы сбрасывать после `apply()`. До скриптов едет переменной `SONIKS_DECODER` — они пакет не импортируют, а позиционный контракт хуков жёсткий; дефолт в `grsat.py` сохраняет ручной запуск. В метаданные — ключом `radio.decoder`, не в `parameters`: те целиком уходят в argv диспетчера. `transmitter_parameters` не подключается — выбор по NORAD, потребителя параметрам нет. Принято 2026-09-12 | | 68 | Фаза 3.6 — клиентская часть | `is_baudrate`/`framing` из `MODES` сняты (переигрывает «оставить» из roadmap.md: за год потребитель не появился). `--dc-removal` — `int`, после сверки с argparse диспетчера 2.6.0. `vmcircbuf_default_factory` не трогается: адрес проверяется только запущенным GNU Radio. Принято 2026-09-12 | | 69 | Фаза 3.3 — сопоставимый водопад | Нормирует клиент, графы не трогаются: фиксированная полоса из точки съёма после доплер-компенсации недостижима без второго тракта децимации (CPU на Pi), а по правилу 21 разнородные водопады от старых станций будут всегда. В `signal` — геометрия из заголовка `.dat` и `noise_dbhz = noise_db − 10·lg(bin_hz)`. Режим sink остаётся Max Hold (смещение `10·lg(H_n)` 3.2–5.5 дБ известно, `nfft_per_row` в метаданных); Mean тускнит короткие всплески — отказ мейнтейнера. Фиксированная ветка водопада, если понадобится, — одна правка золотого фронтенда. Принято 2026-09-12 | | 70 | Фаза 3.6 — хвост во флоуграфах | `--baud` для APT/SSTV уже отбрасывался диспетчером с `248e606`; `dev_args: dev_args` в 20 графах через золотой фронтенд, `example_flowgraph.grc` получает параметр. Безопасно: `FLOWGRAPH__DEV_ARGS` по умолчанию не задан. Принято 2026-09-12 | | 71 | Фаза 3.4 — форма единого графа | **Золотой фронтенд, не hier-блок.** `generic/fm.grc` — источник истины для `soapy_source`, доплер-компенсации, водопада, IQ ZMQ и UDP; `tools/sync_frontend.py` раскладывает `parameters:` текстовым сплайсом, `test_frontend_matches_golden` сторожит. Свои у графа только `out_samp_rate`/`samp_rate` водопада и `compensate`. `hierarchical/` удалён: шесть блоков отражали до-ZMQ архитектуру, не содержали `soapy_source`, а настоящий hier-блок требовал CMake-цепочки с проверкой только в CI и рантайм-импорта на станции без стенда. Принято 2026-09-12 | | 72 | Фаза 3.4 — что вычищено | 16 полей канала 1 из `soapy_source` (кодоген гейтит их по `nchan > 1`); параметр `doppler_correction_per_sec` из 20 графов, диспетчер обнуляет его как `norad-cat-id` — старые клиенты шлют. Оставлены: поля усиления под чужие `devname` (мертвы при `custom`, но без железа не трогаем), рудименты `enable_iq_dump`/`iq_file_path`/`file_path`/`decoded_data_file_path` (их читает диспетчер), клиентская `FLOWGRAPH__DOPPLER_CORR_PER_SEC` (в форме портала и `remote_config`, помечена исторической). Принято 2026-09-12 | | 73 | Фаза 3.5 — bundle без рестарта | `resync_sat_data()` заданием планировщика раз в `SCHEDULER__SAT_DATA_SYNC_INTERVAL_IN_HOURS` (6): сверка версии с применённой, скачивание и раскладка только между проходами (`health.seconds_to_next_observation() == 0.0` — отложить), после новой версии — перепубликация статуса. `sync_sat_data()` на старте не меняется: раскладка на каждом старте остаётся. Принято 2026-09-12 | | 74 | Фаза 3.5 — ворота по satyaml | Клиент: режим ∉ `MODES` при опубликованных режимах больше не отказ, если satyaml для NORAD есть — satnogs-граф берётся по модуляции первого передатчика (`AFSK`/`BPSK`/`FSK`, regex), `baud` при пустом задании — из satyaml, исходный режим — в `radio.mode_requested`; `status/` получает `satellites` (NORAD из satyaml). **Портал — сделано 2026-09-12 (пакет 7):** `satellites` — `ListField(IntegerField(min_value=1))`, без `allow_empty=False` (станция без bundle шлёт `[]` в каждом heartbeat, `400` ослепил бы её); `reported_satellites()` рядом с `reported_modes()`, `is_transmitter_mode_supported` пропускает передатчик, чей `satellite.norad_cat_id` в списке, — режим проверяется первым, `.satellite` трогается только при непустом списке (никто не делает `select_related`); тот же обход у SQL-фильтра `get_available_transmitter` запусков, который валидатор минует. `transmitter_parameters` по-прежнему не подключается. Принято 2026-09-12 | | 75 | Фаза 4, шаг 2 — где считается статистика | **Портал, на лету, во view формы настроек станции**: последние 50 наблюдений станции с непустым `client_metadata` и `signal.noise_dbhz` (старые клиенты не входят). Значения `signal` — строки с единицами («−69.4 dB»), число берётся первым числом строки, как `parse_value` в `tools/portal_stats.py`. Без миграции, Celery и колонки: 50 `json.loads` на показ дёшево, история переживает рестарт станции и не зависит от версии клиента. Отклонено: скользящее окно на станции (теряется с томом, дублирует то, что портал уже хранит). Принято 2026-09-12 | | 76 | Фаза 4, шаг 2 — только показ | Блок «Качество приёма» под графиком калибровки: число проходов в окне, медиана и MAD `noise_dbhz`, медиана `snr_db` по проходам с `demoddata`, доля проходов с `signal.saturated` (клиент шлёт с пакета 7; флаг ловит касание опорой цели края полосы). Правило коррекции `FLOWGRAPH__RF_GAIN` и кнопка — отдельным решением по накопленным данным: ни одна станция ещё не калибровалась, править нечего. Автоприменение для `managed` не проектируется. Принято 2026-09-12, **показ сделан тем же днём** | | 77 | Фаза 4, шаг 2 — сопоставление с кривой калибровки | Ожидаемая связь: `noise_dbhz ≈ P(gain) − 10·lg(RX_SAMP_RATE) + 10·lg(H_n)`, где `P` — точка кривой шага 1 на текущем усилении (широкополосная мощность IQ), `H_n` — смещение Max Hold по `nfft_per_row`. Сходимость шкал не доказана — до проверки на стенде (по команде) величины показываются рядом числами, на график калибровки не накладываются. Принято 2026-09-12 | | 78 | Фаза 2 — вход в настройки станции на портале | **Задача (портал, отдельная сессия).** На странице станции одна кнопка «Настройки станции» вместо «Изменить», всегда видимая владельцу; форма за ней объединяет `station_edit` (имя, координаты, антенны) со всеми полями `station_config_edit`. **Дефект, найденный на стенде 2026-09-13:** после первого сохранения конфигурации вход в неё со страницы станции пропадает. Вкладка «Конфигурация» и карточка «Настроить станцию» в `station_view.html` показываются только при `station.client_id`, а `client_id` заполняет лишь старый поток регистрации через `ClientIDAuthentication`, который никуда не подключён, — у станции 46 на dev он пуст, как и у всего парка. Единственный видимый вход — баннер «Настроить в один клик», а он спрятан условием `not station.config_generation` и исчезает ровно после первого сохранения. Данные при этом целы: `desired_config` 23 ключа, `reported_status.config.actual` 34. **Результат:** владелец с любой страницы станции попадает в полную форму настроек независимо от `client_id` и поколения; условие на `client_id` из шаблона убрать, баннер первого запуска оставить как подсказку, а не как единственный вход. Принято 2026-09-13 | ## Правило 21 на практике Решение 21 — не лозунг о совместимости, а проверяемое ограничение на каждую правку портала. Формулировка, по которой правку можно принять или отклонить: > Возьмите станцию, которая никогда не обновится. Если после вашей правки она > ведёт себя иначе, чем до неё, — правка неверна независимо от пользы. **Механизм — опт-ин, а не версия.** Клиент объявляет, что понимает, параметром `capabilities` в `GET /api/jobs/` (`CAPABILITIES` в `src/soniks_client/api.py`). Портал отдаёт новое поле только попросившему. Три следствия, ради которых выбрана именно эта форма: * станция, которая параметр не прислала, получает ответ **байт-в-байт** как раньше — сломать её нельзя физически, а не «мы постарались»; * заявление честно по построению: клиент и `flowgraph_dispatcher` едут в одном образе, значит объявленное клиентом умеет и диспетчер. Гейт по `client_version` этого не даёт — версия бывает пустой и протухшей, а ошибка сравнения строк-версий стоит глухой станции; * порядок выкатки становится безопасным в любую сторону. Клиент шлёт параметр, портал его пока игнорирует — не происходит ничего. Портал научится позже — поле поедет само. **Сделано 2026-08-27, обе половины.** Клиентская: `capabilities=norad_cat_id`, тест `test_request_declares_client_capabilities`. Портальная: `JobSerializer.norad_cat_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//` с `demoddata`: `malformed_filename` (п. 19) и `frame_outside_window` (2026-08-28: метка кадра дальше `DEMODDATA_TIME_TOLERANCE_MINUTES`, по умолчанию 5 минут, от окна наблюдения). Что с ними делает клиент — см. Фазу 1, durable spool. ## Целевая архитектура Всё, что станция видит из сети, — один хост `sonik.space`. Других обращений нет. ```text СТАНЦИЯ sonik.space — единственный доступный хост ┌──────────────────────────────┐ │ soniks-agent (docker.sock) │◄──── GET /api/v2/stations//state/ ─┐ │ • desired-state pull │────► POST /api/v2/stations//status/ │ │ • OTA + health-gate + откат │◄──── GET /v2/ (registry) │ │ • применение конфига │◄──── GET /api/v2/bundles// │ │ • калибровка gain │ ┌───────────▼────────────┐ │ • телеметрия / логи / WF │────► WS ──────────┐ │ soniks-network │ └───────────┬──────────────────┘ │ │ (Django, WSGI) │ │ │ │ • jobs, observations │ │ управляет │ │ • ReleaseChannel │ ┌───────────▼──────────────────┐ │ │ • reported_status │ │ soniks-client │ │ │ • state/ (desired) │ │ • планировщик проходов │────► PUT/POST ────┼─────────►│ • UI монитора │ │ • durable spool (persistent)│ артефакты │ ├────────────────────────┤ │ • /healthz │ │ │ registry-прокси /v2/ │ └───────────┬──────────────────┘ │ │ (кэш Docker Hub) │ │ subprocess │ │ sonikspace/soniks-* │ ┌───────────▼──────────────────┐ ┌─────────▼────────┐ │ librespace/hamlib │ │ flowgraph_dispatcher │ │ telemetry-ingest │ └────────────────────────┘ │ ┌─ единый граф приёма ────┐ │ │ (FastAPI/uvicorn)│ │ │ SDR→доплер→WF/IQ/аудио │ │ │ Redis + Postgres │ │ └──────────┬──────────────┘ │ └──────────────────┘ │ │ поток │ │ ┌──────────▼──────────────┐ │ │ │ gr-satellites (satyaml) │ │ ← основной декодер, потоковая выдача кадров │ │ satnogs-графы (APT/SSTV/│ │ ← только там, где gr-satellites не умеет │ │ CW/FM-аудио) │ │ │ └─────────────────────────┘ │ └──────────────────────────────┘ ``` ## Фаза 0 — остановить кровотечение Только баги. Никакой архитектуры. Каждый пункт независим и проверяем. ### soniks-client-new 1. **Сделано на стороне диспетчера** (см. п. 12), клиент правки не требовал. `parse_known_args()` плюс явные `--norad-cat-id` и `--lo-transverter`, которые принимаются, но никуда не передаются: ни один `.grc` их не объявляет. `origin/soniks-update` не использовалась — см. решение 19: ветка экспериментальная и мёржу не подлежит. Поведение написано заново, а не перенесено оттуда. Осталось: правка живёт в исходниках `soniks-flowgraphs`, а на станции диспетчер лежит внутри образа. Пока образ не пересобран, `stand.sh contract` продолжает показывать отказ. **Предусловие для портала снято решением 21.** Оно требовало выкатки на весь парк, а её не будет никогда. Вместо него — опт-ин: портал отдаёт `norad_cat_id` тому, кто объявил `capabilities`. Обе половины сделаны 2026-08-27. Подробности — [ниже](#правило-21-на-практике). 2. **Сделано.** `api.py` — разбор по одному заданию: битая запись пропускается, остальные планируются. Заодно проверка `isinstance(jobs_data, list)`: без неё ответ изменившейся схемы отсеивал все задания и уходил в синхронизацию пустым списком, то есть «портал снял все проходы». Тесты — `tests/test_api_jobs.py`, комментарий в `tests/test_models.py:57` стал правдой. 3. **Сделано.** `models.py` — `parse_datetime` приводит к UTC: строка без смещения считается UTC, со смещением — переводится. Тесты `tests/test_models.py` перевыставлены под новое поведение. 4. **Сделано.** `jobs/observation.py` — `run_pre_script()` под собственным `try` (проход продолжается, как и при падении post-скрипта), весь блок после `file_observer.start()` — в `try/finally`, где `finally` гасит наблюдателя и ставит `send_data_after_observation`. 5. **Сделано.** `main.py` — `sys.exit(1)` после исчерпания попыток. `SystemExit` не ловится `except Exception` в `run()` и доходит до Docker. Тест — `tests/test_application.py`. 6. **Сделано.** 404 откладывает данные в `incomplete/`, а удаляет их повторная отправка, получившая 404 второй раз — не раньше чем через `RESENDING_INTERVAL_IN_MINUTES`. Счётчик не понадобился: состояние — сам факт нахождения директории в `incomplete/`. 7. **Сделано.** `all_files_sent = False` в ветке `OSError`. 8. **Сделано.** `station.py` — пустая и пробельная строка не проходят валидацию. Проверка именно на пустоту, а не на ложность значения, как предлагалось здесь изначально: `ELEVATION=0` (уровень моря) и `LATITUDE=0.0` (экватор) — валидные величины, и `if not value` забраковал бы станцию на экваторе. Тест — `tests/test_station_settings.py`. 9. **Сделано.** `PATHS__BASE: /var/lib/soniks-client/data` через `environment:` в обоих compose — на уже смонтированный постоянный том, новых томов не потребовалось. `/tmp` остаётся tmpfs. 10. **Сделано.** `stop_grace_period: 15s` в обоих compose, комментарий в `main.py` приведён в соответствие. 11. **Сделано.** `--set "*.args.CLIENT_VERSION=${CI_COMMIT_TAG}"` в `bake`, плюс проверка совпадения тега с `pyproject.toml` перед сборкой. Сверка трёх объявлений версии — не отдельная джоба, а `tests/test_version_consistency.py`: джоба `pytest` уже есть и идёт на каждый пуш, а тест ловит расхождение ещё до пуша. ### soniks-flowgraphs 12. **Сделано.** `flowgraph_dispatcher.py` → `parse_known_args()` с предупреждением `ignored unknown arguments`, плюс приём и обнуление `--norad-cat-id` и `--lo-transverter` — тем же приёмом, что и `--framing`. Тесты — `tests/test_dispatcher.py`, две новые проверки. Правка нужна по-прежнему, но предусловием правки портала больше не является: гейтом стал опт-ин клиента, а не состояние парка (решение 21). 13. **Сделано, но не тем, чем записано здесь изначально.** Пункт стоял как «опечатка в одну строку, проходы 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`, называвший всю таблицу справочной, исправлен: именно он усыпил пункт на два обхода. 14. **Сделано.** `tests/e2e_check.py` подключён к джобе `flowgraphs` — там образ `librespace/gnuradio`, то есть `pmt`, ради которого тест и держали снаружи. В `apt-get install` дописаны `python3-zmq`, `python3-numpy`, `python3-soundfile`. Флоуграф тест подменяет своим stub'ом, поэтому SDR и собранные рядом `satnogs_*.py` ему не нужны; прогон ~25 секунд, сеть только loopback. **Локально не проверено** — на хосте нет `pmt`; подтверждается первым пайплайном после пуша. 15. **Сделано.** Все четыре расхождения жили в `docs/troubleshooting.md` (код возврата, грейс, суффиксы) и в паре `docs/dev/grc-conventions.md` + `docs/outputs.md` (частота). `docs/dispatcher.md` был прав всё это время, поэтому правился расходящийся с ним текст, а не наоборот: * `out_samp_rate` APT и SSTV_PD120 — 66560 (`4*4160*4`), 48000 остаётся только у `fm.grc`. В обоих графах есть `audio_samp_rate = 48000`, но он уходит лишь в последний ресемплер аудио — отсюда и взялась ошибка; * код возврата флоуграфа пробрасывается, сигнал даёт `128+N`. Оговорка сохранена: клиент этот код не проверяет, поэтому для него упавший граф по-прежнему выглядит успешным наблюдением; * грейс — 2 секунды на фазу, 4 в худшем случае; * абзац про суффиксы `_0`/`_1` удалён целиком: имена кадров имеют микросекундное разрешение, а ссылка вела на запись в `known-issues.md`, которой не существует. ### soniks-network 16. **Сделано.** `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` по сети упадёт. Это и есть правда, которую баг скрывал. 17. **Сделано.** Порог вынесен в `OBS_AUDIO_DURATION_TOLERANCE`, умолчание 120 секунд вместо зашитых 60. Там же `scheduled_duration.seconds` заменено на `.total_seconds()`: `.seconds` заворачивается на сутках. 18. **Сделано выключателем, а не разрывом.** `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 до проверки прав. Анонимный доступ — про третьих лиц, а не про парк. 19. **Сделано.** Разбор метки времени вынесен в `_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» удалён вместе со второй копией. 20. **Сделано.** «Watefall» → «Waterfall» (и в докстринге `serializers.py`). Тела трёх ответов 403 переведены в `{"detail": …, "code": "already_uploaded"}`. Подстрока `has already been uploaded` сохранена **дословно**: на ней держится `api.py:229` во всём парке, и тест `AlreadyUploadedResponseTest` сторожит именно её, а не формулировку. 21. **Отменено 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. 22. **Сделано частично, и одно отклонено.** `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` рядом стоит комментарий, чтобы следующий заход не «починил» это снова. 23. **Переделано 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, все три стоят недорого и ни одна не была сделана. 24. **Имя `.deb` флоуграфов — межрепозиторный контракт без единой проверки.** `debian/control` во флоуграфах переименовал пакет в `soniks-flowgraphs` (`248e606`, 2026-08-22), а `Dockerfile` клиента ставил `../satnogs-flowgraphs_*.deb`. Glob перестал раскрываться, apt падал, **образ не собирался вовсе** — и это оставалось невидимым, потому что джоба `docker` идёт только по тегу, а тесты и линтеры сборку не трогают. Имя исправлено; сам класс отказа закрывается пиннингом `FLOWGRAPHS_BRANCH` на тег и сборкой образа не только по тегу — **обе сделаны**, см. «Воспроизводимость сборки» ниже. 25. **Сделано.** `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`). 26. **Сделано, и пункт оказался крупнее записанного.** `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__<время>.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//frames/` **из объёма выведен**: на портале его нет, а заводить его до content-hash смысла нет — идемпотентность нужна раньше батча. Портальная сторона — **сделано 2026-08-27**, см. Durable spool выше: идемпотентность по хешу вместо имени файла, `select_for_update` с пути `update` снят. Батч остаётся выведенным из объёма: теперь уже не потому, что нет идемпотентности, а потому, что на портале эндпоинта нет и парк его не просит. ### Флаг активного прохода Не лок. Взаимоисключающий лок на `execute_observation` уже рассматривался и был **отклонён** ([roadmap.md](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: ` без схемы, а клиент шлёт `Token <пользовательский токен>` — включение «как есть» оглушает парк. Совместимый путь — своя схема (`Authorization: ClientID `) рядом с `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`) — «Выкатка образа» в [](development/contributing.md). **Версия клиента больше не объявляется в репозитории.** Раньше число лежало в трёх местах (`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:///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` перестал быть вспомогательным скриптом и стал тем, чем он фактически является, — инструментом поставки: версия позиционным аргументом, два тега, отказ публиковать без версии. Порядок — «Выкатка образа» в [](development/contributing.md). **Убрать git из рантайма — сделано 2026-08-28.** `scripts/liveupdate-satyaml.sh` больше не клонирует ничего и остался тонкой точкой входа под прежним именем: на нём стоит `command:` в compose всего парка, и переименование убило бы станции на следующем `docker compose pull`. Заодно из `packages.client` ушёл `git` — других потребителей в рантайме у него не было. satyaml и `sat.cfg` приезжают sat-data bundle'ом с портала (`GET /api/v2/bundles/latest/`, модуль `src/soniks_client/sat_data.py`), кэшируются на постоянном томе, версия уезжает в `client_metadata.radio.sat_data` рядом с `flowgraphs` и `exit_code`. Вкомпилированное в образ остаётся fallback'ом первого старта. Четыре решения, которые стоит держать в памяти: * **старт не зависит от портала.** Портал лежит, архив битый, sha256 не сошёлся — предупреждение в лог и работа на кэше либо на данных из образа. Станция, которая не может подняться из-за необязательных данных, — худший отказ, чем устаревшие определения; * **применение отделено от скачивания.** Кэш лежит на `PATHS__BASE`, то есть переживает перезапуск, а каталог satyaml внутри пакета `satellites` — в эфемерной ФС контейнера. Значит раскладывать кэш надо на **каждом** старте, а не только когда приехала новая версия; иначе первый же новый образ откатывал бы станцию на определения из образа, и молча; * **версия неизменяема.** Портал отдаёт 409 на попытку опубликовать другие байты под уже опубликованной версией, а CI собирает архив воспроизводимо (`tar --sort=name --mtime=@<время коммита>`, `gzip -n`) — иначе перезапуск того же пайплайна давал бы другие байты и падал бы на собственной защите; * **правки оператора в `sat.cfg` не затираются.** Файл переписывается, только если совпадает с прошлым доставленным; иначе остаётся как есть, а расхождение уходит в лог. Молчать нельзя ни в ту сторону, ни в другую. Правки клиента и портала, поэтому решение 21 здесь ограничивает одно: эндпоинт новый, под `/api/v2/`, и станция, которая за bundle не приходит, его появления не замечает. Ретеншен ниже это учитывает — старую раздачу выключать нельзя, пока за ней кто-то ходит. **Ретеншен.** Мультиархитектурные образы с GNU Radio — гигабайты на версию, а для отката надо держать минимум предыдущую. Политика хранения на `sonik.space` входит в работы фазы, а не откладывается: без неё диск портала кончится молча. На 2026-09-10 политики нет ни для образов, ни для медиа: задача `clean_observations`, которая когда-то была объявлена и не зарегистрирована, 2026-08-29 удалена как мёртвый код, так что даже заготовки не осталось — см. открытый вопрос 4. **Медиа — решение 28 (2026-09-10).** Прод хранит артефакты в S3 на Yandex Object Storage: больше 10 ТБ, вечно, быстрый рост. Порядок — сначала замер по типам (команда — в открытом вопросе 4), потом lifecycle-правило бакета: старое уходит в холодное хранилище. Кода это не требует и данных не теряет — ссылки на странице наблюдения продолжают работать, дороже только чтение. Удалять ли что-то — по цифрам замера. Две вещи из разбора кода портала, которые решают форму правила: * у всех типов артефактов один префикс, `data_obs/{год}/{месяц}/{день}/{час}/{obs_id}/`, поэтому правило по префиксу разделяет их только по возрасту, а не по типу; * аудио после приёма машинно читается ровно один раз — `process_audio` проверяет длительность; дальше это только плеер на странице. Удалять его через портал, если до этого дойдёт, нельзя простой очисткой поля: `find_and_rate_failed_observations` переоценит такое наблюдение в failed, а станция сможет залить аудио заново. Нужна пометка, как у `archived`. ### Тесты Закрыть дыры, обнаруженные при разборе: `execute_observation`, `main.Application`, `api.py` (404, 403, таймауты), `communication_session.py`. Добавить `coverage` с порогом в CI — сейчас покрытие не измеряется вовсе. **Сделано.** Покрытие измеряется: `pytest-cov` в `dev`, джоба `pytest` идёт с `--cov=src --cov-fail-under=64` при фактических 66%. Порог сторожит обвал и не растёт сам — поднимать вручную вместе с тестами. 2026-08-26 закрыты `execute_observation` (`tests/test_execute_observation.py`: задание выгрузки ставится после любой ошибки прохода, признак `/healthz` снимается даже при мёртвом планировщике) и `api.py` (`tests/test_api_transport.py`: 404, ошибка портала, таймаут, параметры `Retry`). 2026-08-27 закрыты последние две дыры списка: * `communication_session.py` — 21% → 83% (`tests/antenna/test_communication_session.py`). Заглушка `Hamlib` в `conftest.py` уже была, не хватало двойников железа: `FakeRotator` и `FakeRig` с тем же интерфейсом и историей вызовов. Покрыты жизненный цикл потока, парковка ротатора и `disconnect` по любому исходу цикла, ожидание просыпания rotctld, основной цикл сопровождения и знак доплеровского сдвига. Ожидания построены на `Event`, а не на `sleep`: цикл живёт в отдельном потоке, и фиксированная пауза сделала бы тест флейком; * `main.Application` — 52% → 88% (`tests/test_application.py`): перезапуск берёт **новый** экземпляр планировщика, `stop` гасит его без ожидания, `SIGTERM` доходит до `stop`, `run` возвращает управление после остановки. При написании первого найден латентный дефект, **не исправленный** здесь: `CommunicationSessionBase.set_session_parameters` защищает не от того состояния, от которого обещает. `_session_thread_active` означает «запрошена остановка» (`start_session` его снимает, `stop_session` ставит), поэтому на живой сессии проверка не срабатывает вовсе, а срабатывает между остановкой и следующим запуском. В проде не стреляет: `Observation` создаёт обе сессии в `__init__`, на каждый проход приходит объект с чистым флагом. Тест закрепляет фактическое поведение и назван так, чтобы правка семантики флага его уронила. ### Готовность фазы Два сценария: 1. Станция, отключённая от сети на час поверх трёх проходов и перезапущенная, догружает всё без потерь и без дублей. 2. Станция с firewall, разрешающим **только** `sonik.space`, поднимается с нуля и обновляется: `docker compose pull && up -d` и старт контейнера не делают ни одного обращения на сторону. ## Фаза 2 — агент и управление парком ### soniks-agent ~~Единый станционный демон, вырастающий из `soniks-monitor`. Там уже есть 1357 строк того, что нужно: идентичность станции, реконнект, системная телеметрия, доступ к логам контейнеров, чтение водопада. Репозиторий переносится из личного `BUSH222/soniks-monitor` в группу.~~ **Пишется с нуля (решение 30, 2026-09-10).** Разбор `soniks-monitor` показал, что основой он служить не может: телеметрия и водопад уходят по WebSocket, а логи — HTTP POST по строке на сторонний сервер студентов (на портале таких приёмников нет); токен станции передаётся в query-строке URL и пишется в лог; `/api/jobs/` он опрашивает анонимно (см. п. 18); desired-state, OTA, health-gate, `/healthz` и публикации capabilities нет вовсе. Годные куски — логи контейнеров через `docker.sock` и разбор `.dat` водопада — материал Фазы 5, где появится приёмник. Агент — продукт для массового публичного парка: Linux на arm64 и amd64 первым этапом, Windows (Docker Desktop) — вторым (решение 26). Функции: * pull desired-state раз в ~60 секунд; heartbeat с фактическим состоянием; * OTA: сравнить `target_image_digest` ~~и `bundle_version`~~ (bundle в v1 остаётся `latest`, решение 39), дождаться окна без проходов (агент знает расписание), применить, прогнать health-gate, откатить при провале; * ~~применение конфига по тем же правилам~~ — агент v2 (решение 39); * ~~публикация capabilities~~ — это делает сам клиент (решение 31); * ~~телеметрия, логи и стрим водопада в `telemetry-ingest`~~ — Фаза 5 (решение 29); * режимы `managed` — катит сам, и `notify` — только сообщает: смешанный парк. ### Агент v1 (решения 38–44, 2026-09-10) ~~Контракт записан, кода нет.~~ **Написан 2026-09-11** — репозиторий `soniks-agent` (решения 60–66): `config.py` читает тот же `.env`, `portal.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 в `stable` staff ставит руками, когда канарейка отработала. * **Применение — compose override** (решение 40), приём `tools/stand.sh deploy`. Агенту монтируется каталог compose станции, он пишет `docker-compose.override.yml` с `image: sonik.space/sonikspace/soniks-client@sha256:…` и делает `docker compose up -d soniks-client`. Устройства, тома и `.env` остаются в compose оператора, агент их не дублирует. Откат — тот же файл с предыдущим digest, поэтому предыдущий образ агент на станции не удаляет. * **Health-gate — L1 и L2** (решение 42). L1 — `/healthz` отвечает 200 в течение 60 с. L2 — `docker compose exec soniks-client timeout 60 test-flowgraph.sh`, и только когда `next_observation` из `/healthz` дальше 5 минут: скрипт крутится до Ctrl-C и SDR не охраняет. Успех — код 124 (граф дожил до таймаута) и непустой `test.dat`. L3 — позже: провал прохода и так виден на портале по `exit_code` и `snr_db` в метаданных. * **Режим — `notify` по умолчанию** (решение 43): агент только сообщает, что есть новый релиз; `managed` включает владелец в настройках станции. * **Статус — ключ `agent` в `reported_status`** (решение 41): текущий и предыдущий digest, режим, итог последнего применения и уровень gate. Сегодня портал заменяет документ целиком, и агент стирал бы `modes` клиента, поэтому запись становится слиянием по ключам верхнего уровня: клиент владеет `modes` и `client_version`, агент — `agent`. Клиенту это ничего не меняет — он всегда шлёт полный список. * **Где и как** (решение 44): отдельный репозиторий `soniks-agent`, свой образ, `docker.sock`. Сам себя в v1 не обновляет — это был бы агент над агентом; обновляет оператор. Аутентификация — токен владельца из `.env` станции, станционный токен отложен (Фаза 1). ### Health-gate | Уровень | Что проверяет | Окно | Реакция на провал | |---|---|---|---| | L1 | контейнер жив, клиент отвечает на `/healthz` | 60 с | мгновенный откат | | L2 | `SoapySDRUtil --find` плюс короткий прогон `scripts/test-flowgraph.sh` с новыми настройками | ~2 мин | мгновенный откат | | L3 | первые N проходов дали непустой водопад и адекватный noise floor | часы | откат после N провалов подряд | L2 — ключевой: именно он ловит сломанный `SOAPY_RX_DEVICE` и невалидный gain, то есть делает безопасным удалённое управление железом. Скрипт для него уже написан и используется вручную. ### Портал ~~Новые модели: `StationRelease` (целевой образ и bundle на станцию), `StationConfig` (желаемый конфиг, `generation`, история), `StationCapability` (режимы, список NORAD, диапазоны, версии).~~ Для агента v1 (решения 38–43): `ReleaseChannel` — две строки, `canary` и `stable`, с digest клиента, правит staff; `Station.release_channel` (по умолчанию `stable`) и `Station.update_mode` (`notify` по умолчанию, `managed` включает владелец). `StationCapability` не нужен — объявленное станцией лежит в `reported_status` (решение 31). `StationConfig` — агент v2. Бутафорские поля `satnogs_soapy_rx_device` и `satnogs_rf_gain` вместе с заготовками `get_default_station_configuration*` — убрать или заменить настоящими (на 2026-09-10 все на месте, чистка мёртвого кода 2026-09-01 их обошла). Модель станции с 2026-09-06 стоит на другом фундаменте, и это надо учесть при проектировании: состояние **не хранится**, а выводится из `last_seen`, `is_available` и `testing` при каждом обращении; heartbeat по-прежнему только `last_seen` из `GET /api/jobs/`, порог — `STATION_HEARTBEAT_TIME` (60 минут). Появилось четвёртое состояние `unavailable` — «на связи, но владелец закрыл приём»; в v1 `/api/stations/` оно читается как `Offline`, и словарь из трёх слов там трогать нельзя (правило 21). Фактические версии из `status/` — это ещё одна такая же производная, а не колонка. **Фильтрация планирования** по capabilities: портал не планирует режим, которого станция не умеет. Клиент параллельно отказывает неизвестный режим явной ошибкой вместо тихого `FM` (`src/soniks_client/observation_scripts.py`). Вдвоём это закрывает канал потери C. **Фильтрация сделана 2026-09-10 (решение 31), обе стороны.** Портал: `POST /api/v2/stations//status/` принимает от владельца станции `{"modes": [...], "client_version": ...}` и хранит документ целиком в `Station.reported_status` (JSON) с `reported_status_at`, миграция `0054`. Режимы сортируются без повторов, неизвестные ключи отбрасываются; ответ — 200 с сохранённым документом, аноним 401, чужая станция 403. Правило — `is_transmitter_mode_supported()` в `network/base/validators.py`: нет `modes` — станция умеет всё, у передатчика нет режима — не фильтруется. Встроено во все пути, создающие наблюдения: `get_available_stations`, `create_new_observation` (`OutOfRangeError`, как проверка частоты), `check_transmitter_station_pairs` (API и форма — 400), `predict_candidate_windows`, сетевая стадия автопланирования (берёт другой передатчик) и `get_available_transmitter` запусков — этот путь обходит `create_new_observation` и без отдельной правки фильтр бы миновал. Места только для показа (список передатчиков станции в UI, `next_network_pass`) не фильтруются — наблюдений они не создают. Клиент: `publish_station_status()` шлёт `MODES` после первой успешной сверки, до первого успеха. ~~Явный отказ неизвестного режима в клиенте остаётся следующим шагом: только после выкатки портала на прод.~~ **Сделан 2026-09-10 и выкатки не ждёт** (решение 45). `execute_observation` отказывает неизвестный режим, только если `publish_station_status()` в этом процессе прошёл успешно. Принять режимы может лишь портал с фильтром — эндпоинт и фильтр пришли одним изменением, — а старый портал отвечает 404, и клиент остаётся на подмене `FM`. Отказ стоит до создания путей, как у `NoCompatibleRxDeviceError`: ни директории, ни признака прохода, ни выгрузки, в логе ошибка. Тесты — `tests/test_execute_observation.py`. Цена: наблюдения, запланированные до первой публикации, пока станция «умела всё», после неё отказываются — это и есть смысл решения 14. **Половина сделана досрочно 2026-08-27 — подмена перестала быть тихой.** Отказывать пока нельзя: без фильтрации планирования отказ снял бы проходы, которые станция сегодня принимает. Но видимой подмена стала обоими слоями сразу: `build_script_argv` пишет предупреждение в лог станции, а `Flowgraph.get_metadata()` уносит на портал `radio.mode_known` — рядом с `exit_code` и тем же приёмом. Сырой режим лежит в `radio.parameters.mode`, но судить по нему нельзя, не зная версию таблицы клиента; булев флаг судит сам. Цена тишины здесь не косметическая, и её уже платили: подставленное имя графа уходит шестым аргументом в `satnogs-pre`/`satnogs-post`, оттуда в `scripts/find_samp_rate.py`, а тот определяет по подстрокам в нём частоту дискретизации IQ — и для UDP-потока в gr-satellites, и для имени IQ-дампа. Это ровно механизм бага `SSTV_PD120` (Фаза 0.13), только запускаемый режимом от портала, а не опечаткой в таблице. Зародыш этого механизма введён досрочно, в Фазе 0: параметр `capabilities` в `GET /api/jobs/` (решение 21). Форма выбрана с расчётом на здешнюю модель — `StationCapability` заполняется тем, что станция объявила сама, а не тем, что портал о ней предположил. Фильтрация планирования по capabilities обязана считать станцию, которая ничего не объявила, умеющей **всё**: иначе правило 21 нарушается ровно наоборот — необновлённой станции перестанут планировать проходы, которые она принимает сегодня. ### telemetry-ingest **Перенесён в Фазу 5 решением 29 (2026-09-10).** Готовности этой фазы — раскат с портала, автооткат, видимые версии — телеметрия, логи и стрим водопада не нужны; они нужны UI монитора. Текст ниже остаётся как вход в Фазу 5. Отдельный ASGI-сервис (FastAPI/uvicorn): WebSocket для телеметрии, логов и водопада, Redis для горячих данных, Postgres для агрегатов, аутентификация тем же станционным токеном. Отдельный — потому что телеметрия раз в две секунды при пятидесяти станциях даёт около 25 запросов в секунду, чего ~~единственный sync-воркер портала не переживёт~~ портал с sync-воркерами gunicorn терпеть не должен, ~~а тащить ASGI в Django 4.0.10 значило бы связать судьбу мониторинга с судьбой большого апгрейда~~. Логи и стрим водопада в Django ORM не ложатся в принципе. Два из трёх доводов ослабли после 2026-09-02: воркеров теперь `nproc*2+1`, а Django 5.2 умеет ASGI сам. Решение 13 от этого не отменяется — стрим водопада и логи остаются вне ORM, — но форма сервиса (отдельный процесс рядом или ASGI-приложение внутри портала) стала вопросом на входе в фазу, а не данностью. ### Готовность фазы Новый образ раскатывается на парк с портала; заведомо сломанный конфиг откатывается автоматически; оператор видит фактические версии всех станций. ## Фаза 3 — тракт приёма Порядок внутри фазы важен — от дешёвого и обратимого к дорогому. 1. **Реалтайм-выдача кадров gr-satellites.** `scripts/grsat.py` перестаёт копить KISS до `stop` и отдаёт кадры потоком. ~~ZMQ pub там уже частично есть (`GRSAT_ZMQ_PORT`), просто не потребляется клиентом.~~ Кадры подхватываются тем же `CreateFileHandler`. Малый диф, закрывает задачу реалтайма целиком. **Сделано 2026-09-10 (решение 33), и не через ZMQ.** Разбор исходников gr-satellites показал: `--zmq_pub` отдаёт сериализованный PMT без метки времени, а `--kiss_out` пишет через буферизованный `file_sink` — хвост сбрасывается только при закрытии, так что читать файл по мере записи бесполезно, а SIGKILL на `stop` его терял. Штатный потоковый выход — `--kiss_server`: тот же KISS с метками по TCP. `grsat.py start` поднимает его на порту 8100 и фоном запускает `grsat.py stream`, который пишет кадр файлом `data_*` сразу; разбор `--kiss_out` на `stop` остался страховкой, а дубли отсеивает content-hash портала — `(наблюдение, sha256)` → 200 без записи. Попутно найдено и исправлено два дефекта меток. `--start_time` на живом UDP сдвигал метки назад на время подготовки прохода (он для записей, проигрываемых с `--throttle`) — снят. `kiss.py` проверял длину метки до снятия экранирования, и метка, в миллисекундах которой встречался `0xC0` или `0xDB`, пропускалась — кадр получал время предыдущего. 2. **Единый выбор декодера в клиенте.** Есть satyaml для NORAD — идём путём gr-satellites; нет — satnogs-граф по `mode`. Один осознанный выбор вместо двух независимых. Решение уезжает в `client_metadata`, чтобы постфактум было видно, чем декодировали. **Сделано 2026-09-12 (решение 67).** `sat_data.decoder_for(norad)` ищет `norad:` в каталоге satyaml пакета `satellites` — там и bundle, и штатные определения gr-satellites. `Observation` считает выбор раз на проход (NORAD от портала, без него из TLE), пишет `radio.decoder` в метаданные и отдаёт его хукам переменной `SONIKS_DECODER`; `grsat.py start` без `gr-satellites` в ней не запускает `gr_satellites`. Satnogs-граф остаётся в обоих случаях — он источник IQ, водопада и аудио; убрать его демодулятор на пути gr-satellites — это п. 4, единый граф. 3. **Сопоставимый водопад.** Полоса и разрешение фиксируются независимо от бодрейта. Без этого `noise_db` между режимами несравним и Фаза 4 строится на песке. **Сделано 2026-09-12 (решение 69) — нормирует клиент, графы не тронуты.** Разбор показал, что «фиксировать в графе» нечем: водопад во всех 20 графах висит на выходе `satnogs_doppler_compensation`, а её `out_samp_rate` и есть всё, что осталось после децимации — 48 кГц у аудио-режимов, 57.6 у FSK 9600, 76.8 у BPSK 9600, 66.56 у APT/SSTV, 200 у PHASMA при БПФ на 1024 точки (бин 46.9–195 Гц). Фиксированная полоса требовала бы второго тракта децимации на полной частоте SDR в каждом графе — лишний фильтр на Pi. И главное: по правилу 21 старые станции шлют разнородные водопады вечно, значит нормировать пришлось бы в любом случае. `get_signal_metadata()` отдаёт геометрию из заголовка `.dat` (`samp_rate_hz`, `bin_hz`, `nchan`, `nfft_per_row`) и `noise_dbhz = noise_db − 10·lg(bin_hz)` — плотность шума, сопоставимую между режимами: `waterfall_sink` масштабирует бины `1/fft_size` без окна, так что мощность белого шума в бине ∝ `N0·bin_hz`. Смещение Max Hold (`10·lg(H_n)`, 3.2 дБ при 4 БПФ на строку … 5.5 при 19) не вычитается — режим sink в заголовке не записан, а менять его на Mean мейнтейнер отказался: тускнеют короткие всплески. Портал `client_metadata` не разбирает (хранит строкой, показывает деревом), правок там не нужно. 4. **Единый граф приёма.** Схлопывание двадцати копий `soapy_source` и доплер-сниппета через `hierarchical/` — шесть блоков уже написаны и лежат без дела. Заодно вычистить мёртвый канал 1 в `soapy_source`, `--dev-args` (пустая строка во всех двадцати файлах) и `--doppler-correction-per-sec` (не подключён нигде). **Сделано 2026-09-12 (решения 71–72) — золотой фронтенд вместо hier.** Диффом по YAML подтверждено: `soapy_source` (60 полей) во всех 20 графах одинаков байт-в-байт, доплер-блок различается только `out_samp_rate` (пять выражений) и `compensate` у `iq_receiver`, водопад и IQ/UDP-синки — только той же частотой. Значит «единый граф» — это не новый блок, а одно место правки: `generic/fm.grc` — источник истины фронтенда, `tools/sync_frontend.py` раскладывает секции `parameters:` по остальным графам текстовым сплайсом (дифф построчный, блоки и соединения не трогаются, свои остаются `out_samp_rate`/`samp_rate` водопада и `compensate`), тест `test_frontend_matches_golden` держит их в согласии. Шесть `hierarchical/` удалены: без `soapy_source`, с `file_sink`/`ogg_encoder` до-ZMQ архитектуры, а настоящий hier-блок тянул бы CMake-цепочку (`grcc -u` в подконтрольный HOME, установка `.py` в site-packages) с проверкой только в CI. Вычищено: 16 полей канала 1 (`nchan: '1'`, шаблон `soapy_source.block.yml` гейтит их по `nchan > 1`), параметр `doppler_correction_per_sec` из 20 графов — диспетчер его принимает и обнуляет ради старых клиентов, клиентская настройка `FLOWGRAPH__DOPPLER_CORR_PER_SEC` оставлена как историческая (она в форме портала). Поля усиления под чужие `devname` оставлены: при `custom` они тоже мертвы, но трогать их без железа незачем. Раскатка проверена инвариантами `tests/test_flowgraphs.py`; компиляцию подтверждает CI `grcc` после пуша. Фиксированная ветка водопада и режим sink теперь — одна правка золотого файла, если понадобятся. 5. **gr-satellites на всех спутниках из `--list_satellites`** — через sat-data bundle. Новый спутник не требует пересборки arm64-образа. **Сделано 2026-09-12 (решения 73–74), портальная половина — за порталом.** Паритет с `--list_satellites` был уже по построению: bundle раскладывается в каталог satyaml пакета `satellites`, его же читают `gr_satellites ` и `decoder_for()`. Разрывов было два. Первый — bundle применялся только при старте контейнера, новый спутник ждал рестарта: теперь `resync_sat_data()` раз в `SCHEDULER__SAT_DATA_SYNC_INTERVAL_IN_HOURS` (6) сверяет версию, качает и раскладывает между проходами (во время прохода каталог читает `gr_satellites`), после чего перепубликует статус. Второй — ворота: спутник с satyaml, но режимом ∉ `MODES`, портал станции не планирует, а клиент отказывает (решение 45). Клиентская половина сделана: если satyaml для NORAD есть, режим подменяется satnogs-графом по модуляции первого передатчика (`AFSK*` → `AFSK`, `*BPSK*` → `BPSK`, `*FSK*`/`*MSK*` → `FSK`, regex без PyYAML), `baud` без задания берётся из satyaml, исходный режим уезжает в `radio.mode_requested`; незнакомая модуляция — отказ как раньше. Демодулятор satnogs-графа при этом не нужен — нужен IQ с частотой, которую задаёт модуляция, а `find_samp_rate.py` считает её по имени графа как и прежде. Станция публикует `satellites` — список NORAD из satyaml — в `status/`. **Контракт для портала** (решение 74): принять ключ в `StationReportedStatusSerializer` (сейчас whitelist его отбрасывает) и в `is_transmitter_mode_supported` пропускать передатчик, если NORAD его спутника в `reported_status.satellites`, независимо от режима. **Сделано 2026-09-12 (пакет 7)**: сериализатор, `reported_satellites()` в валидаторе и обход SQL-фильтра запусков; тесты `test_station_modes.py` (все пути планирования плюс сетевая стадия) и `test_station_status.py` (`[]` — 200, дубли, слияние). Прунинга satyaml нет — bundle только добавляет файлы, снятое определение остаётся до нового образа; переименования редки. 6. **Мёртвые данные:** ~~`MODES[*]["is_baudrate"]` и `["framing"]` без потребителей~~; `--baud` кладётся безусловно даже в APT и SSTV; ~~два разных представления bool в командной строке (`--dc-removal=True` против `--enable-iq-dump=1`)~~. **Клиентская часть сделана 2026-09-12 (решение 68).** `is_baudrate` и `framing` сняты. Bool сверены с диспетчером 2.6.0: оба аргумента там `type=int`, так что `--dc-removal=True` был не «другим представлением», а выходом с кодом 2 до старта графа; теперь `int(...)`, как `enable-iq-dump`. ~~`--baud` для APT/SSTV — сторона флоуграфов, не начато.~~ **Хвост закрыт 2026-09-12 (решение 70).** `--baud` для APT/SSTV диспетчер отбрасывал с предупреждением уже с `248e606` (2026-08-22, тест `test_baudrate_only_for_modes_that_take_it`) — пункт писался против более старого дерева. `--dev-args` принимался, форвардился и терялся: параметр был в 19 графах, а `soapy_source` держал `dev_args: ''`; теперь `dev_args: dev_args` через золотой фронтенд (3.4), `example_flowgraph.grc` получил недостающий параметр. Образ станции — `librespace/gnuradio:3.10.5.1-satnogs` с in-tree soapy, где поле именно `dev_args` (в старом форке gr-soapy оно звалось `args`). `FLOWGRAPH__DEV_ARGS` по умолчанию не задан — у станций ничего не меняется. ### Готовность фазы Кадр появляется в сети в течение секунд после приёма. Число поддерживаемых спутников равно выводу `gr_satellites --list_satellites`. Правка одного параметра тракта — одна правка, а не двадцать. **На 2026-09-12:** первое — с 3.1; второе — по построению плюс ре-синк без рестарта, а «планируется при любом режиме» — с портальной половиной ворот (решение 74, пакет 7); третье — одна правка `generic/fm.grc` и `python3 tools/sync_frontend.py`, тест не пропустит расхождение. ## Фаза 4 — качество приёма **Шаг 1, калибровка на станции.** По команде с портала ~~агент~~ клиент выбирает окно без проходов, гоняет свип по gain, измеряет шумовую полку и точку компрессии реальным железом, отдаёт таблицу gain по диапазонам. Портал хранит её ~~как часть конфига~~ в `reported_status.calibration`. Физику надо мерить на месте: из статистики она не выводится, а на новой станции статистики ещё и нет — ровно тогда, когда помощь нужнее всего. **Сделано 2026-09-11** (решения 58–59): `soniks_client/sdr_survey.py`, команда `calibrate_gain` в `state.commands`, график и «Применить рекомендованное» в форме настроек портала. **Шаг 2, статистическая подстройка.** Портал накапливает `snr_db`, `noise_db` и `peak_db` по проходам и мягко корректирует таблицу. Требует сопоставимого водопада (Фаза 3.3) и честного `rate_observation` (Фаза 0.16). С 2026-09-12 сравнивать надо `noise_dbhz`, а не `noise_db`: геометрия водопада зависит от режима, и клиент нормирует шум на ширину бина (решение 69). Наблюдения без этого ключа — от станций старых версий — в статистику не входят; смещение Max Hold (`10·lg(H_n)` по `nfft_per_row`) при желании снимается на портале, `nfft_per_row` в метаданных есть. **Форма шага 2 — решения 75–77 (2026-09-12).** Считает портал на лету во view формы настроек по последним 50 наблюдениям с `signal.noise_dbhz`; показывает, не правит: медиана и MAD `noise_dbhz`, медиана `snr_db` по проходам с кадрами, доля насыщенных (`signal.saturated`). Правило коррекции — по накопленным данным, отдельным решением. **Показ сделан 2026-09-12**: `network/base/reception_quality.py` (`parse_value`, `signal_block`, `reception_quality`), контекст `quality` в `station_config_edit`, блок в `station_config.html`; тесты `ReceptionQualityTest` в `test_station_config.py`. Окно — SQL `client_metadata__contains="noise_dbhz"` по последним 50, кадры — `Count("demoddata")`. **Валидация DSP — сделана 2026-09-12 (пакет 7).** ~~`waterfall/deviation.py` (519 строк) и `dsp.py` (333) сейчас не покрыты ничем, кроме прогона `plot()` на синтетическом сигнале.~~ Прогон `plot()` в оценщик не входил вовсе: `nchan=4` упирался в guard. `tests/test_deviation.py` собирает спектрограмму как `waterfall_sink` (экспоненциальный шум, Max Hold по `nfft_per_row`, тона лепестками) и закрепляет каждую ветку конвейера: k-means по пикам строк, пара пиков спектра, σ_RMS, CW, `weak/noise`, смещение несущей и ppm, изоляция помехи, насыщение, guard — плюс ключевое утверждение Фазы 3.3: один N0 при `nchan` 1024 и 512 даёт один `noise_dbhz` (расхождение < 0.3 дБ, смещение Max Hold 10·lg(H₈) = 4.34 дБ сходится). Оценщик 93 %, примитивы 92 %, порог CI 64 → 80 %. Известные потолки — в `docs/development/waterfall.md`. Аппаратная AGC остаётся выключенной. Возможен fallback на неоткалиброванной станции — но именно fallback, не решение. ## Фаза 5 — монитор UI монитора на портале ~~. Серверная часть к этому моменту уже существует (`telemetry-ingest`, Фаза 2), фронт переписывается под неё.~~ вместе с его серверной частью: `telemetry-ingest` переехал сюда из Фазы 2 (решение 29), и его форма — отдельный процесс или ASGI внутри портала (решение 13) — решается здесь. Годные куски `soniks-monitor` (логи через `docker.sock`, разбор водопада) — материал этой фазы (решение 30). ~~Открытый вопрос: под какой ui-kit. Каталога `soniks-frontend` на машине нет, а портал сейчас — Django-шаблоны, Bootstrap 4.6 и admin-lte, никакой дизайн-системы. Решается отдельно.~~ Фактически снято порталом 2026-09-04/06: Bootstrap 5.3 без admin-lte и без jQuery, свой слой токенов поверх переменных Bootstrap, светлая и тёмная темы, Tom Select, noUiSlider, dayjs, Chart.js. Это и есть ui-kit — монитор пишется на нём, отдельного фронта заводить незачем. Если решать иначе, то осознанно (открытый вопрос 1). ## Новые контракты API Всё новое — под `/api/v2/`; существующие эндпоинты не ломаем, на них сидят станции старых версий. | Метод | Путь | Назначение | |---|---|---| | GET | `/api/v2/stations//state/` | desired-state. **Сделано 2026-09-10** (решения 46–54): `{generation, config, location: {lat, lng, alt}, release: {channel, mode, client_image}, commands}`; `commands` — с 2026-09-11 (решение 58): `rescan_sdr`, `calibrate_gain`, каждая с отметкой `at`; `generation` — номер сохранения формы конфигурации, `config` — плоский словарь с именами переменных окружения, `client_image` — `sonik.space/sonikspace/soniks-client@sha256:…` из `ReleaseChannel`. `ETag` — от тела (координаты и релиз меняются без `generation`), 304 по `If-None-Match`. Токен владельца, `last_seen` не пишет. Тот же документ — retained в MQTT `stations//state` | | POST | `/api/v2/stations//status/` | heartbeat: **`modes`, `client_version`, `config` `{generation, ok, error, applied_at, actual}`** — сделано 2026-09-10; **`sdr` `{scanned_at, devices, error}`, `calibration` `{calibrated_at, results, recommended, lift_db, error}`** — 2026-09-11 (решения 57, 59); **`agent` `{version, mode, current, previous, available, last_apply}`** — 2026-09-11, soniks-agent (решения 60–66); **`satellites`** — список NORAD из satyaml станции, клиент шлёт с 2026-09-12, портал принимает с того же дня (решение 74, пакет 7; пустой список — 200); запись — **слиянием по ключам верхнего уровня** (решение 53). `GET` тем же путём — владельцу, для страницы: `{reported_status, reported_status_at, generation}` | | POST | `/api/v2/mqtt/auth/`, `/api/v2/mqtt/acl/` | внутренние, для `mosquitto-go-auth`: логин `station-` + токен владельца → станция вправе читать `stations//state` и писать `stations//status|online`; служебный пользователь портала — всё. Снаружи закрыты nginx (`return 404`), в OpenAPI не входят | | — | `wss://sonik.space/mqtt` | MQTT через WebSocket, Mosquitto под профилем `mqtt` compose портала, nginx `location = /mqtt`. Не обязателен для станции: без него — REST раз в минуту | | POST | `/api/v2/observations//frames/` | батч кадров, идемпотентность по content-hash, постатейный результат | | PUT | `/api/v2/observations//artifacts/` | payload и waterfall с хешем; повтор того же хеша — 200 | | GET | `/api/v2/bundles//` | sat-data bundle: `version`, `sha256`, `size` и адрес архива. `latest` вместо версии — свежайший. **Сделано 2026-08-28**, анонимно: определения спутников — публичные данные, а токен между закрытой станцией и её единственным каналом обновления был бы лишней движущейся частью | | POST | `/api/v2/bundles/` | публикация bundle из CI `soniks-satyaml`, staff-токен. Те же байты под той же версией — 200, другие — 409. **Сделано 2026-08-28** | | GET | `/v2/…` | Docker Registry API. Обязательно в корне хоста — клиент Docker не умеет префикс пути. ~~Авторизация через nginx `auth_request`~~ Анонимно: pull-through cache Docker Hub, nginx пропускает только `sonikspace/*` и `librespace/hamlib` (решение 34). Сделано в портале 2026-09-10, на серверах не включён | | WS | `telemetry-ingest` | телеметрия, логи, стрим водопада | | GET | `/api/v2/analytics/…` | не из этого плана: аналитика портала (`network`, `station`, `dynamic-demod`, `needs-attention`) переехала под v2 2026-09-02, `/api/analytics/…` остались алиасами. Станция сюда не ходит, `tools/portal_stats.py` — тоже | Контракт v1 и v2 с 2026-09-02 описан машинно: `openapi.yml` в `soniks-network-api-client/` генерируется под пиннутой локалью и сверяется в CI портала на каждом пуше. Для сверки того, что реально отдаёт `/api/jobs/` или `/api/v2/bundles/`, это первоисточник, а не чтение сериализатора. Desired-state проектируется как версионированный документ с `generation` — чтобы переезд на MQTT позже потребовал только транспортного адаптера, а не переписывания логики агента. ## Стенд Реальная станция, подключённая к живой сети, доступ по SSH. Роль — инструмент разработки и верификации. Нужен начиная с Фазы 0: без него канал потери A нечем даже подтвердить. **Запускается только по команде человека** (решение 23, 2026-09-10). Прогон стоит часов ожидания прохода и реального наблюдения под настоящей личностью станции, поэтому решает человек, когда и что гонять. Разработка его не ждёт: без команды правка проверяется тестами репозитория, и работа идёт дальше. Команда может прийти по любой уже сделанной правке — поэтому каждая правка, которую имеет смысл проверить на железе, записывается в «Где мы» как готовая к прогону. Dev-агент умеет: выкатить ветку или образ на стенд; дождаться ближайшего реального прохода (расписание берётся из `/api/jobs/`); собрать артефакты — `payload.ogg`, водопад `.dat` и `.png`, файлы `data_*`, `client_metadata`, логи клиента и диспетчера, код возврата; вынести вердикт. | Проверка | Порог | |---|---| | код возврата диспетчера | 0 | | `unrecognized arguments` в логе | отсутствует | | `payload.ogg` | существует, размер больше нуля | | водопад `.dat` | существует, парсится, не меньше `WATERFALL__MIN_VALID_SAMPLES` строк | | `client_metadata.signal.snr_db` | присутствует, не `null` | | файлы `data_*` | для спутника с известным передатчиком — хотя бы один | | `incomplete/` после прохода | пусто | Реализация минимальная: набор команд `tools/stand.sh` (`deploy` / `next-pass` / `watch` / `collect` / `verdict`) плюс определение агента в `.claude/agents/`. SSH-хост и ключ — через alias в `~/.ssh/config`, вне репозитория. Ничего специфичного для конкретного стенда в код проекта не попадает. **Сделано.** Подробности — [](development/stand.md). Сверх исходного замысла `deploy` печатает **дифф контракта** клиент ↔ диспетчер: аргументы, которые построил бы `Flowgraph`, против опций `flowgraph_dispatcher --help`. Проверка стоит секунды вместо часа ожидания прохода и ловит весь класс отказов, а не один известный аргумент, — именно ею опровергнут канал A и найден A′. Две строки таблицы выше проверяются косвенно, и это отражено в выводе: код возврата диспетчера клиентом не логируется, а порог водопада код сравнивает с числом отсчётов, а не строк. **Сознательно не делаем сейчас** — это решение, а не упущение: * HIL-раннер в CI, прогоняющий каждый MR на живом железе; * стенд как канарейка номер один в схеме раската; * IQ-дампы реальных проходов как фикстуры для валидации DSP. Все три естественно вытекают из dev-агента и стоят недорого, когда он уже есть. Вернуться имеет смысл на входе в Фазу 4: там валидация DSP становится блокирующей. ## Наследование от дорожной карты техдолга Раздел «Совместная сессия с `soniks-flowgraphs`» в [roadmap.md](roadmap.md) содержал семь пунктов, отложенных именно потому, что второй репозиторий был недоступен. Теперь доступен: | Отложенный пункт | Куда | |---|---| | Что именно принимает диспетчер (контракт CLI) | Фаза 0.1 | | `SSTV_PD120` → неверный скрипт | Фаза 0.13 | | Версии `FLOWGRAPHS_VER` и ветка `soniks` | Фаза 1, пиннинг тегов | | Нормализация булевых флагов CLI | Фаза 3.6 — **сделано 2026-09-12**, решение 68 | | `is_baudrate` и `framing` в `MODES` | Фаза 3.6 — **сделано 2026-09-12**, решение 68 | | `scripts/gnuradio/vmcircbuf_default_factory` кладётся не туда | Фаза 3.6 | | `ipc: host` и `cap_add: SYS_NICE` разъехались по compose | ~~Фаза 1~~ **отпало**: проверено 2026-08-27, оба ключа стоят в обоих compose и совпадают | Решения, принятые там **сознательно** (отмеченные ❌ с обоснованием), здесь не переигрываются. В первую очередь — отказ от процесс-wide лока на наложившиеся проходы. ## Разбор `origin/soniks-update` (решение 19) Выполнен 2026-08-24. Ветка нашлась в **двух** репозиториях, а не в одном. Мёрж не делался ни целиком, ни по коммитам — как и записано в решении 19. ### `soniks-client-new` — переносить нечего Девять коммитов, 14 файлов, +2396/−276. **Всё до последней строки уже есть в `soniks`, и почти везде — в лучшем виде.** Ветка отстала настолько, что каждый её кусок либо повторён, либо исправлен: | Кусок ветки | Судьба | |---|---| | `norad_cat_id` сквозь `models` → `sync` → `observation` → `flowgraph` | уже в `soniks`, написано заново (п. 1) | | `scripts/sstv_wrapper.py` и делегирование из `grsat.py` | уже в `soniks`, и на 58 строк короче | | Прошивка LibreSDR, `scripts/libresdr/usrp_b210_fpga.bin` | уже в `soniks`. На ветке `find … -exec` возвращает ненулевой код и обрывает `&&`-цепочку — в `soniks` это обёрнуто в `if`, иначе сборка падала | | `cap_add: SYS_NICE` в compose | уже в `soniks`, вместе с `ipc: host`; ветка вдобавок пиннит образ `latest-addons-test` и держит `stop_grace_period: 1s`, который в `soniks` поднят до 15 с | | Чистка `packages.builder` / `packages.client` | уже сделано, и глубже. Ветка сводит boost к `libboost-all-dev` — это *больше*, а не меньше | | Реструктуризация `Dockerfile` (стадии, `PARALLEL_JOBS`, LTO, SoapyAirspy, ENV) | уже в `soniks`. На ветке остались `satnogs-flowgraphs_*.deb` (имя, из-за которого образ не собирался), `--mount=type=cache` для `.deb` (на холодном кэше glob не раскрывался) и `rm -f /usr/lib/x86_64-linux-gnu/…/libairspySupport.so` — путь только для amd64, на arm64 не срабатывает | | `waterfall.py`, +2258 строк одним файлом | отклонено. В `soniks` водопад — пакет из четырёх модулей, и перечисленные хелперы (`_kmeans2_1d`, `_wiener_gate`, `_bimodal_split` и ещё 17) **убраны намеренно** при его обновлении | | `rm -rf` в else-ветке `liveupdate-satyaml.sh` | отпало: скрипт переписан, клоны идут в `mktemp -d`, который сносится целиком | | `signal`-метаданные в `post_processing` | уже в `soniks`; на ветке рядом лежит мёртвая `flowgraph_metadata` — присваивается и не используется | ### `soniks-flowgraphs` — одно стоит внимания, остальное не готово 25 коммитов, 36 файлов, +2699/−1630. **`satyaml_lookup.py` (+383) — единственное, чего в `soniks` нет вовсе.** Читает satyaml gr-satellites и достаёт по NORAD параметры спутника (девиация, фрейминг, скремблирование), чтобы диспетчер настраивал DSP-цепочку по ним, а не по одному `--mode` от портала. Это прямо материал **Фазы 3.2** («единый выбор декодера») и Фазы 3.5. Переносить сейчас нельзя и не нужно: модуль ищет satyaml в том числе в `/var/lib/satnogs-client/.gr_satellites/satyaml/` — чужой путь, у нас `/var/lib/soniks-client`. Оставлен как материал для чтения. **Диспетчер ветки контракт не чинит.** Он по-прежнему зовёт `parse_args()` и лишь добавляет `--norad-cat-id`; на любом другом незнакомом аргументе — включая `--lo-transverter` — выход с кодом 2 остаётся. То есть решение 19 («написать заново») подтвердилось фактом: перенос отсюда закрыл бы один аргумент из класса отказов. **Правки `.grc` — четыре сквозные ручки по двум десяткам графов.** Каждая требует измерения на железе, а не переноса: * `max_nouts: 0 → 8192` и `realtime_scheduling: '' → 1` — планировщик GNU Radio. Второе требует `SYS_NICE`, который в compose уже есть; * `dev_args: '' → dev_args` — оживление `--dev-args`, пустого во всех графах (стоит в Фазе 3.6); * `dc_blocker_xx: long_form True → False` — короткая форма DC-блокера дешевле по CPU и хуже давит постоянную составляющую. Компромисс, который надо мерить; * `enable-waterfall: '1'` дописан в блок `options` — **у блока `options` такого поля нет**, GRC его просто проигнорирует. Похоже на недоделку. **`fsk_ax25.grc` (−1193) → `fsk.grc` (+1683)** плюс правки FSK/BPSK в десятке графов спутников — отдельная задача Фазы 3, не «перенос годного». ### Итог Переносить нечего. Обе ветки — материал для чтения, и обе своё уже отдали: клиентская подтвердила, что Фаза 0 её перекрыла целиком, флоуграфовая дала `satyaml_lookup.py` как задел Фазы 3. ~~Удаление обеих веток — за мейнтейнером.~~ **Решено 2026-09-10: ветки не удаляются, а игнорируются.** Если они остались на origin, их никто не мёржит и не читает как источник правок; `satyaml_lookup.py` оттуда по-прежнему доступен как материал Фазы 3. ## Сознательно отложено | Что | Почему | |---|---| | Мультиресурсная станция: N SDR — N одновременных проходов | Портал моделирует станцию как один ресурс; физика ротатора всё равно ограничивает одним сопровождаемым спутником. Объём приёма дешевле поднять Фазами 0 и 3 | | ~~MQTT вместо pull REST~~ | ~~Даёт мгновенный детект offline через Last Will, но требует нового сервиса наружу. Desired-state спроектирован так, что переезд — это транспортный адаптер~~ **Сделан 2026-09-10** (решение 47): не вместо REST, а рядом — REST остаётся источником истины и фолбэком | | Отказ от gr-satnogs целиком | `waterfall_sink`, `doppler_compensation`, APT- и SSTV-sink пришлось бы переписывать с нуля, а водопад — это весь анализ сигнала | | ~~Апгрейд Django и Python на портале~~ | ~~Нужен, но не блокирует ни одну фазу: телеметрия уезжает в отдельный ASGI-сервис~~ **Сделан 2026-09-02** вне плана: Python 3.12, Django 5.2 LTS. См. примечание к решению 13 | | Очередь в SQLite вместо директорий | Директории работают; проблема была в tmpfs и идемпотентности, а не в механизме | ## Верификация Локально, на каждой правке. **Это и есть проверка по умолчанию** (решение 23): зелёные тесты и линтеры закрывают правку, дальше — следующая. ```bash 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 обязан отловить и откатить без вмешательства человека. ## Открытые вопросы 1. **ui-kit для Фазы 5.** ~~Каталога `soniks-frontend` нет, на портале дизайн-системы не существует.~~ С 2026-09-06 существует: Bootstrap 5.3 со своим слоем токенов, без jQuery и admin-lte. Вопрос сводится к «монитор пишется на ней» — и закрывается, если никто не возразит до Фазы 5. 2. ~~**Размер парка и профиль железа.**~~ Отвечено 2026-09-10: около 50 станций, в основном Raspberry Pi (arm64), есть ноутбуки на Ubuntu и Windows (amd64). Отсюда решение 26. 3. ~~**Судьба `origin/soniks-update`**~~ — решено 2026-08-24, см. решение 19 в [Принятых решениях](#принятые-решения). 4. **Политика хранения на портале.** ~~`clean_observations` объявлена, в расписании не зарегистрирована,~~ `clean_observations` удалена 2026-08-29 как мёртвый код; медиа растут неограниченно, заготовки больше нет. Есть только архивация аудио в zip и S3 по флагам (`ZIP_AUDIO_FILES`, `ARCHIVE_ZIP_FILES`), и это перенос, а не удаление. Вместе с вопросом 6 — одна политика на медиа и образы, не две. **Частично отвечено 2026-09-10 (решение 28).** Прод — Yandex Object Storage, больше 10 ТБ, всё вечно; zip и архивация на archive.org при аудио в S3 выключены самим порталом. Шаг 1 — замер по типам (выполняет мейнтейнер: это ключи бакета): ```bash 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-правило бакета (черновик, применять после замера): ```json {"Rules": [{"ID": "data-obs-cold", "Status": "Enabled", "Filter": {"Prefix": "data_obs/"}, "Transitions": [{"Days": 30, "StorageClass": "COLD"}]}]} ``` `aws s3api put-bucket-lifecycle-configuration --bucket "$BUCKET" --lifecycle-configuration file://lifecycle.json --endpoint-url https://storage.yandexcloud.net`. Префикс общий у всех типов артефактов, так что правило режет только по возрасту. Удаление — отдельным решением по цифрам шага 1. 5. ~~**Формат sat-data bundle.**~~ — решено 2026-08-28, см. решение 22 в [Принятых решениях](#принятые-решения). Подписи у архива нет и не планируется, пока нет управления ключами: целостность держится на sha256 поверх HTTPS с того же хоста, что и всё остальное. 6. ~~**Ресурсы под registry на `sonik.space`.**~~ Отвечено 2026-09-10: диска хватает на текущую и предыдущую версию каждого образа, больше не храним (решение 27). Исходный текст вопроса — ниже. Сколько диска и канала. Мультиарх `soniks-base` с GNU Radio — гигабайты, держать надо минимум текущую и предыдущую версию каждого слоя. **Registry сознательно отложен 2026-08-24** до ответа на этот вопрос и на 7: без цифр проект был бы угадыванием. Промежуточный шаг сделан — образ по тегу публикуется ещё и в registry GitLab, так что `sonik.space` добавляется потом одной парой строк в `bake`. **Форма — решения 34–35**: прокси Docker Hub, так что ни push, ни пары строк в `bake` не будет. 7. ~~**Профиль закрытых сетей.**~~ Отвечено 2026-09-10: закрытых 5–15, среди них возможны волонтёрские, SNI/DPI — неизвестно. На registry переходят все станции, чтение анонимное (решение 27). Проверить SNI/DPI — дело прогона второго сценария готовности Фазы 1. Форма registry — решение 34.