273 lines
7.3 KiB
Python
273 lines
7.3 KiB
Python
"""Configuration loader: YAML file + environment variable overrides.
|
|
|
|
Env-var convention:
|
|
AIS_HUB_<PATH> 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
|