"""Observation profile dataclass and DB CRUD. An ObservationProfile describes *how* to capture a particular satellite: frequency, decoder type, gain, bandwidth, minimum elevation, and whether to record raw IQ in SigMF format. """ from __future__ import annotations import json from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Any from utils.logging import get_logger logger = get_logger('intercept.ground_station.profile') VALID_TASK_TYPES = { 'telemetry_ax25', 'telemetry_gmsk', 'telemetry_bpsk', 'weather_meteor_lrpt', 'record_iq', } def legacy_decoder_to_tasks(decoder_type: str | None, record_iq: bool = False) -> list[str]: decoder = (decoder_type or 'fm').lower() tasks: list[str] = [] if decoder in ('fm', 'afsk'): tasks.append('telemetry_ax25') elif decoder == 'gmsk': tasks.append('telemetry_gmsk') elif decoder == 'bpsk': tasks.append('telemetry_bpsk') elif decoder == 'iq_only': tasks.append('record_iq') if record_iq and 'record_iq' not in tasks: tasks.append('record_iq') return tasks def tasks_to_legacy_decoder(tasks: list[str]) -> str: normalized = normalize_tasks(tasks) if 'telemetry_bpsk' in normalized: return 'bpsk' if 'telemetry_gmsk' in normalized: return 'gmsk' if 'telemetry_ax25' in normalized: return 'afsk' return 'iq_only' def normalize_tasks(tasks: list[str] | None) -> list[str]: result: list[str] = [] for task in tasks or []: value = str(task or '').strip().lower() if value and value in VALID_TASK_TYPES and value not in result: result.append(value) return result @dataclass class ObservationProfile: """Per-satellite capture configuration.""" norad_id: int name: str # Human-readable label frequency_mhz: float decoder_type: str # 'fm', 'afsk', 'bpsk', 'gmsk', 'iq_only' gain: float = 40.0 bandwidth_hz: int = 200_000 min_elevation: float = 10.0 enabled: bool = True record_iq: bool = False iq_sample_rate: int = 2_400_000 tasks: list[str] = field(default_factory=list) id: int | None = None created_at: str = field( default_factory=lambda: datetime.now(timezone.utc).isoformat() ) def to_dict(self) -> dict[str, Any]: normalized_tasks = self.get_tasks() return { 'id': self.id, 'norad_id': self.norad_id, 'name': self.name, 'frequency_mhz': self.frequency_mhz, 'decoder_type': self.decoder_type, 'legacy_decoder_type': self.decoder_type, 'gain': self.gain, 'bandwidth_hz': self.bandwidth_hz, 'min_elevation': self.min_elevation, 'enabled': self.enabled, 'record_iq': self.record_iq, 'iq_sample_rate': self.iq_sample_rate, 'tasks': normalized_tasks, 'created_at': self.created_at, } def get_tasks(self) -> list[str]: tasks = normalize_tasks(self.tasks) if not tasks: tasks = legacy_decoder_to_tasks(self.decoder_type, self.record_iq) if self.record_iq and 'record_iq' not in tasks: tasks.append('record_iq') if 'weather_meteor_lrpt' in tasks and 'record_iq' not in tasks: tasks.append('record_iq') return tasks @classmethod def from_row(cls, row) -> 'ObservationProfile': tasks = [] raw_tasks = row['tasks_json'] if 'tasks_json' in row.keys() else None if raw_tasks: try: tasks = normalize_tasks(json.loads(raw_tasks)) except (TypeError, ValueError, json.JSONDecodeError): tasks = [] return cls( id=row['id'], norad_id=row['norad_id'], name=row['name'], frequency_mhz=row['frequency_mhz'], decoder_type=row['decoder_type'], gain=row['gain'], bandwidth_hz=row['bandwidth_hz'], min_elevation=row['min_elevation'], enabled=bool(row['enabled']), record_iq=bool(row['record_iq']), iq_sample_rate=row['iq_sample_rate'], tasks=tasks, created_at=row['created_at'], ) # --------------------------------------------------------------------------- # DB CRUD # --------------------------------------------------------------------------- def list_profiles() -> list[ObservationProfile]: """Return all observation profiles from the database.""" from utils.database import get_db with get_db() as conn: rows = conn.execute( 'SELECT * FROM observation_profiles ORDER BY created_at DESC' ).fetchall() return [ObservationProfile.from_row(r) for r in rows] def get_profile(norad_id: int) -> ObservationProfile | None: """Return the profile for a NORAD ID, or None if not found.""" from utils.database import get_db with get_db() as conn: row = conn.execute( 'SELECT * FROM observation_profiles WHERE norad_id = ?', (norad_id,) ).fetchone() return ObservationProfile.from_row(row) if row else None def save_profile(profile: ObservationProfile) -> ObservationProfile: """Insert or replace an observation profile. Returns the saved profile.""" from utils.database import get_db normalized_tasks = profile.get_tasks() profile.tasks = normalized_tasks profile.record_iq = 'record_iq' in normalized_tasks profile.decoder_type = tasks_to_legacy_decoder(normalized_tasks) with get_db() as conn: conn.execute(''' INSERT INTO observation_profiles (norad_id, name, frequency_mhz, decoder_type, tasks_json, gain, bandwidth_hz, min_elevation, enabled, record_iq, iq_sample_rate, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) ON CONFLICT(norad_id) DO UPDATE SET name=excluded.name, frequency_mhz=excluded.frequency_mhz, decoder_type=excluded.decoder_type, tasks_json=excluded.tasks_json, gain=excluded.gain, bandwidth_hz=excluded.bandwidth_hz, min_elevation=excluded.min_elevation, enabled=excluded.enabled, record_iq=excluded.record_iq, iq_sample_rate=excluded.iq_sample_rate ''', ( profile.norad_id, profile.name, profile.frequency_mhz, profile.decoder_type, json.dumps(normalized_tasks), profile.gain, profile.bandwidth_hz, profile.min_elevation, int(profile.enabled), int(profile.record_iq), profile.iq_sample_rate, profile.created_at, )) return get_profile(profile.norad_id) or profile def delete_profile(norad_id: int) -> bool: """Delete a profile by NORAD ID. Returns True if a row was deleted.""" from utils.database import get_db with get_db() as conn: cur = conn.execute( 'DELETE FROM observation_profiles WHERE norad_id = ?', (norad_id,) ) return cur.rowcount > 0