Files
AISHub/src/ais_hub/config.py
T
2026-05-04 08:13:38 +03:00

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