# Архитектура ## Что здесь есть на самом деле Python-код в `src/` — в основном **оркестратор**. Демодуляция и декодирование живут во внешних компонентах GNU Radio (`gr-satnogs`, `gr-satellites`, `soniks-flowgraphs`), которые вшиваются в образ на этапе сборки `Dockerfile`. Клиент их запускает, кормит параметрами и убирает за ними. Одно исключение существенное: пакет `soniks_client/waterfall/` — это ~1300 строк numpy-DSP, которые считают сами. Отрисовка водопада и весь анализ сигнала (`metadata["signal"]`: SNR, девиация, полоса, уход опорного генератора) делаются в Python, а не в GNU Radio. См. [Водопад](waterfall.md). Это объясняет главное ограничение: **вне контейнера код почти не запускается**. Модуль `Hamlib` — системный, доступен только внутри образа, поэтому всё, что импортирует `soniks_client.antenna.rig` или `rotator`, на хосте не заведётся. Осмысленная проверка — `docker compose up` на реальной станции. ## Два пакета `src/` содержит два независимых корня, импорты между ними абсолютные: * **`core`** — конфигурация, типы, исключения. Без ввода-вывода. * **`soniks_client`** — всё остальное: планировщик, HTTP, наблюдение, антенна, водопад, работа с файлами. Контейнер копирует `src/*` в `/src`, поэтому в коде не бывает импортов с префиксом `src.`. ## Цепочка запуска ``` docker compose command: liveupdate-satyaml.sh --run │ (имя историческое: обновления оттуда ушли, │ но на нём стоит compose всего парка) └─ exec python3 /src/main.py │ ├─ remote_config.bootstrap() конфигурация станции: файл состояния, │ затем GET /api/v2/stations//state/ │ ├─ sync_sat_data() satyaml и sat.cfg bundle'ом с портала │ (дальше — resync_sat_data() заданием │ планировщика, между проходами) │ ├─ start_health_server() GET /healthz, отдельный поток-демон │ ├─ start_mqtt() живой канал wss://sonik.space/mqtt, │ поток paho-mqtt │ └─ Application.run() ── супервизор │ └─ scheduler.start() APScheduler, BackgroundScheduler, UTC register_observation_jobs() register_resending_jobs() ``` `Application` (`src/main.py`) — тонкий супервизор: он крутит цикл, ловит любое исключение планировщика, перезапускает его до трёх раз и обрабатывает `SIGINT`/`SIGTERM` для аккуратного завершения. Ронять процесс из-за одного сломанного прохода нельзя — станция должна пережить его и принять следующий. ## Состояние станции `GET /healthz` (`soniks_client/health.py`, порт `HEALTH__PORT`) отдаёт JSON: жив ли планировщик, когда была последняя успешная сверка расписания (`last_sync`), сколько наблюдений ждёт в `incomplete/`, идёт ли проход и когда следующий, насколько часы станции расходятся с порталом (`clock_skew_seconds`, плюс — спешат; по заголовку `Date` ответа расписания), какое поколение конфигурации с портала применено (`config_generation`). Код 503 означает мёртвый планировщик или потерянное задание сверки — то есть станцию, которая снаружи выглядит работающей и не планирует ни одного прохода. Неудачный поход на портал кодом ответа не считается: чинить лежащий портал перезапуском станции нечего, возраст `last_sync` отдан как данные. Так же — уход часов: это сигнал оператору проверить NTP, а не повод перезапуска. Признак идущего прохода — **наблюдаемость, а не лок**: наложившиеся проходы разрешены осознанно (см. [дорожную карту](../roadmap.md)), поэтому внутри множество идентификаторов, а не мьютекс. ## Два постоянных задания | Функция | Модуль | Период | Что делает | |---|---|---|---| | `sync_observation_jobs` | `soniks_client/jobs/sync.py` | `SCHEDULER__JOB_REQUEST_INTERVAL_IN_MINUTES` (1 мин) | Тянет расписание проходов с портала и сверяет его с состоянием планировщика. | | `resending_jobs` | `soniks_client/jobs/resending.py` | `SCHEDULER__RESENDING_INTERVAL_IN_MINUTES` (20 мин) | Пересылает всё, что осело в директории `incomplete/`. | В первом столбце — **имена функций**, а не идентификаторы заданий APScheduler: их планировщик назначает сам. Синхронизация — это именно сверка, а не «добавить всё подряд». Задания делятся на три категории (добавить / обновить / удалить) и приводятся к тому, что прислал портал. Работает это благодаря простому соглашению: **идентификатор задания на портале и есть идентификатор задания в планировщике**, а задания наблюдений отличаются от служебных префиксом `JOBS__PREFIX` в имени. Отменённый на портале проход исчезает и у станции. ## Жизненный цикл наблюдения Один проход — это одно срабатывание `execute_observation` (`src/soniks_client/jobs/observation.py`), запланированное по дате начала. Вся логика умещается в одну функцию, и порядок шагов важен: ``` execute_observation(job_id, tle, start, end, frequency, mode, baud, ...) │ ├─ 1. Observation.set_observation_parameters() │ ├─ выбор SDR по частоте прохода rx_device.py │ │ → NoCompatibleRxDeviceError = выход │ ├─ построение путей файлов observation_files.py │ │ payload.ogg, raw_waterfall.dat, data_* │ └─ конструирование Flowgraph │ ├─ 2. watchdog Observer на директорию наблюдения │ декодированные кадры (data_*) уходят на портал ВО ВРЕМЯ прохода │ ├─ 3. satnogs-pre observation_scripts.py │ внешние декодеры стартуют │ ├─ 4. Observation.run() │ ├─ поток слежения ротатора каждые 3 с │ ├─ поток доплеровской коррекции rig каждые 0.1 с │ ├─ Flowgraph.start() subprocess: flowgraph_dispatcher │ ├─ ожидание до observation_end или смерти графа │ └─ остановка: SIGINT графу, стоп потоков, ротатор в (0,0) │ ├─ 5. satnogs-post observation_scripts.py │ декодеры останавливаются │ ├─ 6. Observation.post_processing() │ ├─ отрисовка PNG водопада из .dat post_processing.py │ ├─ анализ сигнала → metadata["signal"] │ ├─ удаление сырого .dat только если водопад построен │ └─ запись metadata_*.json в директорию наблюдения, не в сеть │ └─ 7. одноразовое задание send_data_after_observation выгрузка метаданных, аудио, водопада и оставшихся кадров ``` Шаг 2 не косметика. Кадры декодируются в течение всего прохода, и если ждать его конца, при обрыве связи или падении контейнера потеряется всё. Наблюдатель файловой системы отправляет их пачками с задержкой `OBSERVATION__BATCH_DELAY`, так что данные оказываются на портале ещё до того, как спутник зашёл. ## Потоковый граф `Flowgraph` (`src/soniks_client/flowgraph.py`) — обёртка над `subprocess.Popen`. Каждое поле `settings.flowgraph.*` превращается в аргумент `--kebab-case=значение`, поля со значением `None` не передаются. Вывод процесса читается отдельным потоком и уходит в лог с уровнем `LOG__FLOWGRAPH_LEVEL`. Сам запуск и чтение вывода — общие со скриптами прохода, они живут в `subprocess_log.py`. Там же лежит важное для скриптов правило: `stdout` закрывает поток-читатель, а не вызывающий. Скрипт, оставивший после себя фоновый процесс (`bandscan.sh start` из `satnogs-post`), отдаёт ему свой `stdout` по наследству, и `close()` из вызывающего ждал бы смерти этого процесса — то есть до следующего прохода. Следствие для разработки: имя поля настроек и имя аргумента связаны **вручную**, через словарь `self.parameters`, — автоматического `kebab-case` от имени переменной нет (`RX_SAMP_RATE` уезжает в `--samp-rate-rx`, `RF_GAIN` — в `--gain`). Поэтому новый параметр графа требует правки двух файлов. Подробнее — в [Потоковом графе](flowgraph.md). Останов — `SIGINT` с ожиданием 10 секунд, потом `kill`. Именно `SIGINT`, потому что GNU Radio по нему корректно закрывает файлы; `SIGKILL` оставит битый `.ogg`. ## Слежение за спутником Положение считает `SatelliteParametersCalculator` (Skyfield) из TLE и координат станции. Дальше два фоновых потока (`CommunicationSessionBase` и наследники): * **`RotatorTrackingSession`** — пересчитывает азимут и угол места, применяет стратегию и двигает ротатор, если рассогласование больше `ANTENNA__ROTATOR__THRESHOLD`. В `finally` всегда паркует антенну в (0, 0). * **`RigRXDopplerCorrectedSession`** — считает радиальную скорость и скорректированную частоту, отдаёт её `rigctld`. Стратегия выбирается фабрикой `create_tracking_strategy()` по `ANTENNA__ROTATOR__MODE`: `Normal`, `Flip` или `250`. Поведение каждой описано в [Поворотном устройстве](../station/rotator.md#режимы-слежения). ```{important} Общая угловая математика — `shortest_angular_delta`, `closest_equivalent`, ограничение по минимальному углу места — живёт в базовом классе `TrackingStrategy` (`antenna/tracking/base.py`). Правки, связанные с переходом через 0/360, делайте там, а не в конкретной стратегии: иначе исправление достанется одному режиму из трёх. ``` Стратегии намеренно не зависят от Hamlib — поэтому они единственная часть проекта, которую можно осмысленно покрыть тестами на хосте (`tests/antenna/tracking/test_strategies.py`). ## Обработка ошибок Сбои логируются и **гасятся**, а не пробрасываются: сломанный проход не должен уронить планировщик. Исключения в `core/exceptions.py` существуют не для всплытия наверх, а чтобы сообщить, **что делать с файлами**: | Исключение | Что означает | Судьба файлов | |---|---|---| | `FileNotUploadedError` | Портал недоступен, ошибка сети | → `incomplete/`, повтор раз в 20 минут | | `ObservationNotFoundError` | 404: наблюдение удалено на портале | Директория наблюдения удаляется целиком | | `NoCompatibleRxDeviceError` | Нет SDR под частоту прохода | Наблюдение не начинается | Подробнее о раскладке файлов — в [Загрузке данных](upload.md). ## Конфигурация Всё через `pydantic-settings` с разделителем `__`. Секции собираются в единственный синглтон `settings` в `core/configs/__init__.py`; там же берутся именованные логгеры `logger` и `raw_logger`. ```{warning} `Settings()` инстанцируется **на импорте модуля**. Это значит, что любой `import` чего угодно из `src/` читает `.env` и валидирует обязательные `STATION__*`. Пустое значение у типизированного поля — ошибка валидации, а не значение по умолчанию. Если при локальном запуске сыплются ошибки pydantic — почти наверняка дело в локальном `.env`, а не в коде. Переопределяйте переменные в командной строке. ``` **Диска импорт не трогает.** Всё, что пишет на диск, поднимает явный `configure_runtime()` — его зовёт `main()` первой строкой: * файловые обработчики логов, вместе с каталогом `LOG__DIRECTORY`; * рабочие директории из `PATHS__*` (`PathSettings.create_directories()`). Раньше и то и другое было побочным эффектом импорта: валидатор `PathSettings` делал `mkdir`, а `configure_logger()` открывал файл лога. Из-за этого `tests/conftest.py` и `docs/conf.py` вынуждены были уводить пути во временные каталоги просто чтобы что-нибудь импортировать. Пути `output`/`complete`/`incomplete` — свойства, считаемые от `PATHS__BASE`; снаружи задаются только сам `PATHS__BASE` и имена подкаталогов `PATHS__*_DIR`. Синглтон на импорте здесь остался только у `settings`. Всё остальное строится явно и лениво: * планировщик — фабрикой `create_scheduler()` (`soniks_client/scheduler.py`); `Application` принимает именно фабрику и на перезапуске собирает новый экземпляр, а не воскрешает остановленный; * контроллеры железа — `get_rig_controller()` и `get_rotator_controller()` с `lru_cache`. Контроллер создаётся при первом проходе, а не при импорте: неизвестная `ANTENNA__ROTATOR__MODEL` теперь пишет ошибку в лог, а не роняет клиент трейсбеком на старте. Кэш сбрасывает `remote_config` при смене настроек с портала — поэтому и только вне прохода. ### Конфигурация с портала Владелец настраивает станцию в форме на `sonik.space`; `.env` остаётся для идентичности (`STATION__ID`, `STATION__TOKEN`) и того, что портал не трогает (адреса, пути, планировщик). Модуль `soniks_client/remote_config.py`: * **документ** — `GET /api/v2/stations//state/`: `generation` (номер сохранения формы), `config` — плоский словарь с именами переменных окружения (`{"FLOWGRAPH__RF_GAIN": 25}`), `location`, `release`. Тот же документ приходит по MQTT (`soniks_client/mqtt.py`) в момент сохранения; REST — источник истины и фолбэк, забирается на старте и на каждой сверке с `If-None-Match`; * **применение — в процессе**, без перезапуска: разделы `settings` пересобираются через pydantic поверх снимка значений из `.env` (`_BASELINE`, не поверх текущих — снятый с портала ключ возвращается к `.env`), значения зеркалятся в `os.environ` (скрипты из `scripts/` читают переменные сами), уровни логов — `setLevel`, кэш контроллеров сбрасывается. Список того, что портал вправе менять, — `MANAGED_FIELDS`; ключ вне списка — отказ всего документа; * **проверка перед применением** — `soniks_client/sdr_check.py` открывает приёмник python-биндингом SoapySDR в подпроцессе с таймаутом, только когда менялись параметры приёмника и только вне прохода. Идущий проход откладывает применение до следующей сверки. Без биндинга (старая база образа) проверка пропускается с предупреждением; * **отказ** — прежняя конфигурация остаётся, причина уезжает на портал в `config` документа статуса (`api.status_body()`) и видна в форме. Отказ по железу (приёмник не открылся) повторяется на следующей сверке — устройство могли ещё не подключить; отказ по документу — нет, до следующего поколения; * **`generation` 0** — владелец ничего не сохранял, станция на `.env`; * **файл состояния** `PATHS__STATE_FILE` — последний полученный документ; станция без портала стартует на нём. В нём же координаты: если в `.env` их нет, они берутся из `location`; * **обследование приёмника** — `soniks_client/sdr_survey.py`, на том же фундаменте, что `sdr_check` (SoapySDR в подпроцессе с таймаутом). Поиск устройств с их возможностями (`SoapySDR.Device.enumerate()` плюс входы, диапазоны усиления, частоты дискретизации) — на старте, раз в `SDR__SCAN_INTERVAL_IN_MINUTES` в простое и по команде; калибровка усиления (свип, подъём шумовой полки на `SDR__NOISE_LIFT_DB`) — по команде. Команды — блок `commands` документа состояния, каждая с отметкой `at`: выполняется раз на отметку, выполненные помнятся в `sdr-survey.json`. Работает в одном фоновом потоке и только когда до ближайшего прохода дальше `SDR__IDLE_WINDOW_IN_MINUTES` (`health.seconds_to_next_observation()`): занятый приёмник граф не откроет. Итог — ключи `sdr` и `calibration` статуса, поток сам публикует его REST'ом и по MQTT. ## Карта каталогов | Путь | Что внутри | |---|---| | `src/core/` | Конфигурация, типы, исключения | | `src/soniks_client/` | Планировщик, HTTP, наблюдение, водопад, конфигурация с портала (`remote_config`, `sdr_check`, `sdr_survey`, `mqtt`) | | `src/soniks_client/antenna/` | Hamlib, расчёт орбиты, сессии, стратегии слежения | | `src/soniks_client/jobs/` | Задания планировщика и работа с файлами | | `scripts/` | Shell/Python-хуки, копируются в `/usr/local/bin` образа | | `client/` | Шаблон станции: compose, `.env`, udev, blacklist | | `soniks-satyaml/` | Подмодуль с определениями спутников | | `sstv/` | Подмодуль декодера SSTV (CLI `sstv`, ставится в образ из него) | | `tests/` | Всё, что проверяется без SDR и Hamlib — перечень в [Разработке](contributing.md) |