"""Configuration loader: YAML file + environment variable overrides. Env-var convention: AIS_HUB_ where path components are joined by "__". Example: AIS_HUB_INGEST__AIS_UDP__PORT=4011 -> cfg.ingest.ais_udp.port = 4011 AIS_HUB_LOGGING__LEVEL=DEBUG -> cfg.logging.level = "DEBUG" """ from __future__ import annotations import os from dataclasses import dataclass, field, fields, is_dataclass from pathlib import Path from typing import Any import yaml ENV_PREFIX = "AIS_HUB_" ENV_SEP = "__" # --------------------------------------------------------------------------- # Dataclasses mirroring config.example.yaml structure # --------------------------------------------------------------------------- @dataclass class AisUdpCfg: host: str = "0.0.0.0" port: int = 4001 @dataclass class GpsUartCfg: device: str = "/dev/ttyS1" baud: int = 38400 enabled: bool = True @dataclass class RadioUdpCfg: host: str = "0.0.0.0" port: int = 4010 @dataclass class AisCatcherUdpCfg: """Four binary telemetry streams from AIS-catcher Mini. Ports mirror ``ais-mini.conf``: * ``slot_port`` — 315-byte slot bitmap per minute * ``slot_detail_port`` — per-slot levels per minute * ``rssi_port`` — 8-byte RSSI pair at ~10 Hz * ``event_port`` — decode events (ts, slot, mmsi, level) """ enabled: bool = False host: str = "127.0.0.1" slot_port: int = 10111 slot_detail_port: int = 10112 rssi_port: int = 10113 event_port: int = 10114 @dataclass class IngestCfg: ais_udp: AisUdpCfg = field(default_factory=AisUdpCfg) gps_uart: GpsUartCfg = field(default_factory=GpsUartCfg) radio_udp: RadioUdpCfg = field(default_factory=RadioUdpCfg) aiscatcher_udp: AisCatcherUdpCfg = field(default_factory=AisCatcherUdpCfg) @dataclass class HttpCfg: host: str = "0.0.0.0" port: int = 8080 @dataclass class UdpEventsCfg: host: str = "127.0.0.1" port: int = 7001 max_datagram_bytes: int = 1400 @dataclass class UdpNmeaCfg: host: str = "127.0.0.1" port: int = 6007 @dataclass class UdpTxOutboxCfg: host: str = "127.0.0.1" port: int = 6010 @dataclass class NmeaTailCfg: in_memory_ring: int = 500 @dataclass class PublishCfg: http: HttpCfg = field(default_factory=HttpCfg) udp_events: UdpEventsCfg = field(default_factory=UdpEventsCfg) udp_nmea: UdpNmeaCfg = field(default_factory=UdpNmeaCfg) udp_tx_outbox: UdpTxOutboxCfg = field(default_factory=UdpTxOutboxCfg) nmea_tail: NmeaTailCfg = field(default_factory=NmeaTailCfg) @dataclass class RetentionCfg: ais_dynamic_days: int = 7 gps_fix_days: int = 7 raw_nmea_days: int = 2 radio_telemetry_days: int = 7 @dataclass class WriterCfg: batch_size: int = 200 flush_interval_ms: int = 500 @dataclass class StorageCfg: path: str = "/var/lib/ais_hub/ais_hub.db" store_raw_nmea: bool = False retention: RetentionCfg = field(default_factory=RetentionCfg) writer: WriterCfg = field(default_factory=WriterCfg) retention_interval_sec: int = 3600 @dataclass class QueuesCfg: parser_in: int = 2048 ws_client: int = 512 udp_events: int = 1024 storage_in: int = 4096 tx_outbox: int = 512 @dataclass class LoggingCfg: level: str = "INFO" file: str | None = "/var/log/ais_hub/ais_hub.log" max_bytes: int = 10 * 1024 * 1024 backup_count: int = 5 json: bool = True @dataclass class AisParserCfg: """AIS parser tolerance knobs. Defaults are strict (drop checksumless/invalid and multipart without seq_id). Some real-world NMEA forwarders omit these fields; enabling tolerances may increase decoded target count at the cost of potential false merges when streams are heavily interleaved. """ # Ignore checksum completely (even if present and invalid). # If enabled, this overrides allow_checksumless. ignore_checksum: bool = False allow_checksumless: bool = False allow_multipart_without_seq_id: bool = False multipart_ttl_sec: float = 60.0 @dataclass class ParserCfg: ais: AisParserCfg = field(default_factory=AisParserCfg) @dataclass class Config: ingest: IngestCfg = field(default_factory=IngestCfg) publish: PublishCfg = field(default_factory=PublishCfg) storage: StorageCfg = field(default_factory=StorageCfg) queues: QueuesCfg = field(default_factory=QueuesCfg) parser: ParserCfg = field(default_factory=ParserCfg) logging: LoggingCfg = field(default_factory=LoggingCfg) # --------------------------------------------------------------------------- # Loading helpers # --------------------------------------------------------------------------- def _coerce(value: str, hint: Any) -> Any: """Coerce a string (from env var) into a field type. ``hint`` may be a concrete type (``int``, ``str`` …), a string annotation (e.g. ``"int"``, ``"int | None"``) when ``from __future__ import annotations`` is active, or ``None`` to trigger best-effort auto-detection based on ``value``. """ if isinstance(hint, str): h = hint.lower().replace(" ", "") if "bool" in h: hint = bool elif "int" in h: hint = int elif "float" in h: hint = float else: hint = str if hint is bool: v = value.strip().lower() if v in ("1", "true", "yes", "on"): return True if v in ("0", "false", "no", "off"): return False raise ValueError(f"invalid bool value: {value!r}") if hint is int: return int(value) if hint is float: return float(value) # Fallback: keep as string. return value def _apply_dict(dc: Any, data: dict[str, Any]) -> None: """Recursively apply a YAML-derived dict onto a dataclass instance.""" for f in fields(dc): if f.name not in data: continue incoming = data[f.name] current = getattr(dc, f.name) if is_dataclass(current) and isinstance(incoming, dict): _apply_dict(current, incoming) else: setattr(dc, f.name, incoming) def _apply_env(dc: Any, path: tuple[str, ...] = ()) -> None: """Walk dataclass tree and apply AIS_HUB_* env overrides.""" for f in fields(dc): current = getattr(dc, f.name) sub_path = path + (f.name,) if is_dataclass(current): _apply_env(current, sub_path) continue env_name = ENV_PREFIX + ENV_SEP.join(sub_path).upper() if env_name in os.environ: raw = os.environ[env_name] try: setattr(dc, f.name, _coerce(raw, f.type)) except Exception as exc: raise ValueError(f"env {env_name}: {exc}") from exc def load_config(path: Path | str | None) -> Config: """Load configuration from a YAML file (optional) and apply env overrides.""" cfg = Config() if path is not None: p = Path(path) if not p.exists(): raise FileNotFoundError(f"config file not found: {p}") with p.open("r", encoding="utf-8") as fh: raw = yaml.safe_load(fh) or {} if not isinstance(raw, dict): raise ValueError(f"config file must be a YAML mapping, got {type(raw).__name__}") _apply_dict(cfg, raw) _apply_env(cfg) return cfg