# Потоковый граф Демодуляция целиком за пределами Python-кода: клиент запускает `flowgraph_dispatcher` — исполняемый файл из [soniks-flowgraphs](https://gitlab.com/space-education-development/soniks/client/soniks-flowgraphs), собранный в образ. Задача класса `Flowgraph` (`src/soniks_client/flowgraph.py`) — построить командную строку, запустить процесс, пробросить его вывод в лог и корректно остановить. ## Как строится командная строка Все параметры лежат в словаре `self.parameters`, собираемом в конструкторе, и превращаются в аргументы механически: ```python args = [self.dispatcher_script] for parameter, value in self.parameters.items(): if value is not None: args.append(f"--{parameter}={value}") ``` Два следствия: * **`None` = аргумент не передаётся вовсе.** Поэтому в `FlowgraphSettings` столько полей типа `X | None = None`: это способ сказать «не трогай, пусть граф решает сам». * **Имя поля настроек и имя аргумента связаны вручную**, через словарь. Это не автоматический `kebab-case` от имени переменной: например, `settings.flowgraph.RX_SAMP_RATE` уезжает в `--samp-rate-rx`, а `RF_GAIN` — в `--gain`. Часть параметров приходит не из настроек, а из самого прохода: `mode`, `rx-freq`, `baud`, `norad-cat-id`, пути к файлам. `baud` и `norad-cat-id` добавляются только если заданы. ## Как добавить параметр графа 1. Добавьте поле в `FlowgraphSettings` (`src/core/configs/flowgraph.py`). 2. Добавьте строку в словарь `self.parameters` в `Flowgraph.__init__` с тем именем аргумента, которое понимает `flowgraph_dispatcher`. 3. Опишите переменную в [справочнике](#flowgraph-vars). ```{note} Оба `bool` уезжают как `0`/`1`: `flowgraph_dispatcher` объявляет и `--dc-removal`, и `--enable-iq-dump` как `type=int`. До 2026-09-12 `dc-removal` передавался сырым значением, то есть `--dc-removal=True`, а `int("True")` роняет диспетчер с кодом 2 ещё до старта графа — `FLOWGRAPH__DC_REMOVAL=true` означало пустой проход. Сверено с тегом 2.6.0 (Фаза 3.6 в [дорожной карте сети](../roadmap-network.md)). ``` ## Соответствие режимов Два словаря в `src/core/configs/flowgraph.py` связывают строку режима, присланную порталом, с конкретным скриптом satnogs. Это **константы модуля**, а не поля настроек: через окружение они не задаются. * **`SCRIPTS`** — символическое имя → имя файла скрипта (`"FSK": "satnogs_fsk.py"`). * **`MODES`** — режим портала → `{script_filename}`. `FSK`, `GFSK`, `GMSK`, `MSK` и варианты AX.100 указывают на один `satnogs_fsk.py`: кадровую структуру различает диспетчер по `--mode`, клиенту она не нужна. ```{note} `script_filename` не справочный, хотя выбор графа от него и не зависит: имя уходит шестым аргументом в `satnogs-pre`/`satnogs-post`, а оттуда в `find_samp_rate.py`, который по подстрокам в имени определяет частоту дискретизации IQ. См. врезку про `SSTV_PD120` ниже. До 2026-09-12 в таблице лежали ещё `is_baudrate` и `framing` — без единого читателя; сняты (Фаза 3.6 в [дорожной карте сети](../roadmap-network.md)). ``` Если портал прислал неизвестный режим, исход зависит от того, знает ли портал режимы станции. Пока клиент их не опубликовал (`POST /api/v2/stations//status/`, раз на процесс после первой успешной сверки расписания), берётся `FLOWGRAPH__DEFAULT_MODE` (по умолчанию `FM`) с предупреждением в лог. После публикации портал такой режим станции не планирует, и если он всё же пришёл — проход не начинается, в логе ошибка. `SSTV` ведёт на `satnogs_fm.py` намеренно: в форке этот режим только пишет аудио, а картинку собирает библиотека `sstv` из `sstv_wrapper.py` уже после прохода. ```{note} До версии 2.2.2 `MODES["SSTV_PD120"]` тоже брал `SCRIPTS["SSTV"]`, то есть `satnogs_fm.py`. **На выбор графа это не влияло** — граф выбирает диспетчер по `--mode`, и его таблица всегда указывала на `satnogs_sstv_pd120_demod.py`. Ломалось другое: имя скрипта уходит в `satnogs-pre`/`satnogs-post`, а оттуда в `scripts/find_samp_rate.py`, который ищет в нём подстроку `_sstv`. Без неё функция возвращала 48000 вместо `4·4160·4 = 66560` — реальной `out_samp_rate` графа. Следствий было два, оба тихие: gr-satellites получал UDP-поток IQ, размеченный неверной частотой, а IQ-дамп — неверную частоту в имени файла. ``` ## Как добавить режим демодуляции Нужны **обе** части, иначе не заработает: 1. **Запись в конфигурации.** Добавьте скрипт в `SCRIPTS` и режим в `MODES` в `src/core/configs/flowgraph.py`. Ключ в `MODES` должен буква в букву совпадать со строкой режима, которую присылает портал. 2. **Скрипт в образе.** Файл `satnogs_*.py` должен присутствовать в контейнере — он приходит из `gr-satnogs` или `soniks-flowgraphs`. Если его там нет, потребуется правка `Dockerfile` или версии соответствующего компонента. Проверять — `scripts/test-flowgraph.sh` внутри контейнера: он гоняет граф без ожидания реального прохода. ```{tip} Дописать режим в `MODES`, не убедившись в наличии скрипта, — самая частая ошибка. Симптом: наблюдение стартует, граф умирает мгновенно, в логе строка про отсутствующий файл. ``` ## Жизненный цикл процесса ```python Flowgraph.start() # Popen, stdout+stderr в одну трубу, поток-логгер Flowgraph.is_running # process.poll() is None Flowgraph.stop() # SIGINT → wait(10 c) → kill ``` Остановка сигналом `SIGINT`, а не `SIGKILL`, — принципиально: по нему GNU Radio успевает закрыть выходные файлы. Убийство процесса оставит повреждённый `payload.ogg` и обрезанный `.dat` водопада. Через 10 секунд ожидания процесс всё же убивается принудительно, с предупреждением в логе. Вывод графа читает демон-поток `_log_output` и пишет его построчно с префиксом `[Flowgraph]` на уровне `LOG__FLOWGRAPH_LEVEL`. На `DEBUG` строк очень много. ## Метаданные `get_metadata()` возвращает блок, который уезжает на портал вместе с результатами наблюдения: ```python {"radio": {"name": "gr-satnogs", "version": gr_satnogs_git_hash[:8], "flowgraphs": flowgraphs_git_hash[:8], "gr_satellites": gr_satellites_git_hash[:8], "sat_data": applied_version(), # версия bundle, None — из образа "exit_code": self.exit_code, "mode_known": mode in MODES, "decoder": "gr-satellites" | "satnogs", # дописывает Observation "parameters": self.parameters}} ``` `decoder` — единственный выбор декодера на проход (Фаза 3.2 дорожной карты сети): `Observation` спрашивает `sat_data.decoder_for(norad)` — есть satyaml для NORAD в каталоге пакета `satellites` → `gr-satellites`, нет → `satnogs`. NORAD берётся из `norad_cat_id` портала, а без него — из второй строки TLE. То же значение уезжает скриптам прохода переменной `SONIKS_DECODER`, по ней `grsat.py start` решает, запускать ли `gr_satellites` вовсе. Ключ кладётся в `radio` рядом с `mode_known`, а не в `parameters`: те целиком превращаются в argv диспетчера. То есть **на портал уходит полный набор параметров графа**. Это удобно для разбора неудачных приёмов, но означает, что случайно добавленный в `self.parameters` секрет окажется в публичных метаданных наблюдения. `exit_code` — код возврата, если граф кончился **сам**, до того как его попросили остановиться; `None` означает штатный проход. Без него упавший граф неотличим от успешного наблюдения: клиент код возврата не читал вовсе, и проход, на котором диспетчер вышел по `argparse` (код 2, расхождение контракта), выглядел просто как наблюдение с пустой директорией. Судить по `process.returncode` нельзя — на штатном пути там всегда `-2` от `SIGINT`. ## Выходные файлы Граф пишет три вещи, пути к ним конструирует `Observation`: | Файл | Аргумент | Что это | |---|---|---| | `payload__<время>.ogg` | `--file-path` | Аудиозапись прохода | | `raw_waterfall__<время>.dat` | `--waterfall-file-path` | Сырой водопад, см. [Водопад](waterfall.md) | | `data__*` | `--decoded-data-file-path` | Декодированные кадры (префикс) | Кадры `data_*` подхватывает наблюдатель файловой системы и отправляет на портал не дожидаясь конца прохода — см. [Загрузку данных](upload.md). Дополнительно граф может отдавать IQ: в файл (`--enable-iq-dump`, `--iq-file-path`) и в UDP-поток (`--udp-dump-host`, `--udp-dump-port`). UDP-поток читает `grsat.py` — по нему кадры уходят декодеру `gr_satellites`, см. [Скрипты](scripts.md).