closed TG-1; git was inited;
This commit is contained in:
@@ -0,0 +1,272 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user