Архитектура¶
Что здесь есть на самом деле¶
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, а не повод перезапуска.
Признак идущего прохода — наблюдаемость, а не лок: наложившиеся проходы разрешены осознанно (см. дорожную карту), поэтому внутри множество идентификаторов, а не мьютекс.
Два постоянных задания¶
Функция |
Модуль |
Период |
Что делает |
|---|---|---|---|
|
|
|
Тянет расписание проходов с портала и сверяет его с состоянием планировщика. |
|
|
|
Пересылает всё, что осело в директории |
В первом столбце — имена функций, а не идентификаторы заданий 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 существуют не для
всплытия наверх, а чтобы сообщить, что делать с файлами:
Исключение |
Что означает |
Судьба файлов |
|---|---|---|
|
Портал недоступен, ошибка сети |
→ |
|
404: наблюдение удалено на портале |
Директория наблюдения удаляется целиком |
|
Нет 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()) и видна в форме. Отказ по железу (приёмник не открылся) повторяется на следующей сверке — устройство могли ещё не подключить; отказ по документу — нет, до следующего поколения;generation0 — владелец ничего не сохранял, станция на.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.
Карта каталогов¶
Путь |
Что внутри |
|---|---|
|
Конфигурация, типы, исключения |
|
Планировщик, HTTP, наблюдение, водопад, конфигурация с портала ( |
|
Hamlib, расчёт орбиты, сессии, стратегии слежения |
|
Задания планировщика и работа с файлами |
|
Shell/Python-хуки, копируются в |
|
Шаблон станции: compose, |
|
Подмодуль с определениями спутников |
|
Подмодуль декодера SSTV (CLI |
|
Всё, что проверяется без SDR и Hamlib — перечень в Разработке |