Архитектура

Что здесь есть на самом деле

Python-код в src/ — в основном оркестратор. Демодуляция и декодирование живут во внешних компонентах GNU Radio (gr-satnogs, gr-satellites, soniks-flowgraphs), которые вшиваются в образ на этапе сборки Dockerfile. Клиент их запускает, кормит параметрами и убирает за ними.

Одно исключение существенное: пакет soniks_client/waterfall/ — это ~1300 строк numpy-DSP, которые считают сами. Отрисовка водопада и весь анализ сигнала (metadata["signal"]: SNR, девиация, полоса, уход опорного генератора) делаются в Python, а не в GNU Radio. См. Водопад.

Это объясняет главное ограничение: вне контейнера код почти не запускается. Модуль 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/<id>/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, а не повод перезапуска.

Признак идущего прохода — наблюдаемость, а не лок: наложившиеся проходы разрешены осознанно (см. дорожную карту), поэтому внутри множество идентификаторов, а не мьютекс.

Два постоянных задания

Функция

Модуль

Период

Что делает

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). Поэтому новый параметр графа требует правки двух файлов. Подробнее — в Потоковом графе.

Останов — 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. Поведение каждой описано в Поворотном устройстве.

Важно

Общая угловая математика — 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 под частоту прохода

Наблюдение не начинается

Подробнее о раскладке файлов — в Загрузке данных.

Конфигурация

Всё через pydantic-settings с разделителем __. Секции собираются в единственный синглтон settings в core/configs/__init__.py; там же берутся именованные логгеры logger и raw_logger.

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

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/<id>/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 — перечень в Разработке