-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathconfig.py
More file actions
149 lines (115 loc) · 4.41 KB
/
config.py
File metadata and controls
149 lines (115 loc) · 4.41 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
"""Configuration loading and validation for the CoreNet bridge daemon.
Config is YAML or TOML; Pydantic validates structure and applies defaults.
Example (YAML):
router:
name: bridge.lax.example.net
short_tag: LAX
local_callsign: BRIDGE-LAX
zone_description: Los Angeles metro
radio:
type: serial
port: /dev/tty.usbmodem1101
baudrate: 115200
reticulum:
config_dir: ~/.config/corenet/reticulum
identity_path: ~/.config/corenet/identity
storage_path: ~/.config/corenet/lxmf
bridges:
- name: corenet-wa
secret: "aabbccdd...32hex"
channel_idx: 3
peers:
- router_name: bridge.sea.example.net
identity_hash: "deadbeef..."
"""
from __future__ import annotations
from pathlib import Path
from typing import Literal
from pydantic import BaseModel, Field, field_validator, model_validator
class RouterConfig(BaseModel):
name: str = Field(..., description="Fully-qualified router name (DNS-like)")
short_tag: str | None = None
local_callsign: str = "BRIDGE"
zone_description: str = ""
class RadioConfig(BaseModel):
type: Literal["serial", "mock"] = "serial"
port: str | None = None
baudrate: int = 115200
@model_validator(mode="after")
def _port_required_for_serial(self) -> "RadioConfig":
if self.type == "serial" and not self.port:
raise ValueError("radio.port is required when radio.type is 'serial'")
return self
class ReticulumConfig(BaseModel):
type: Literal["reticulum", "mock"] = "reticulum"
config_dir: Path | None = None
identity_path: Path | None = None
storage_path: Path | None = None
display_name: str = "CoreNet Bridge"
class NailedUpBridgeConfig(BaseModel):
name: str
secret: str = Field(..., description="Hex-encoded channel secret (16 bytes)")
channel_idx: int | None = None
@field_validator("secret")
@classmethod
def secret_is_hex(cls, v: str) -> str:
try:
raw = bytes.fromhex(v)
except ValueError as e:
raise ValueError(f"channel secret must be hex: {e}") from None
if len(raw) != 16:
raise ValueError(f"channel secret must be 16 bytes, got {len(raw)}")
return v
def secret_bytes(self) -> bytes:
return bytes.fromhex(self.secret)
class PeerConfig(BaseModel):
router_name: str
identity_hash: str = Field(..., description="Hex-encoded identity hash")
@field_validator("identity_hash")
@classmethod
def hash_is_hex(cls, v: str) -> str:
try:
bytes.fromhex(v)
except ValueError as e:
raise ValueError(f"identity_hash must be hex: {e}") from None
return v
def hash_bytes(self) -> bytes:
return bytes.fromhex(self.identity_hash)
class BridgeConfig(BaseModel):
"""Top-level bridge daemon configuration."""
router: RouterConfig
radio: RadioConfig = Field(default_factory=RadioConfig)
reticulum: ReticulumConfig = Field(default_factory=ReticulumConfig)
bridges: list[NailedUpBridgeConfig] = Field(default_factory=list)
peers: list[PeerConfig] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Loaders
# ---------------------------------------------------------------------------
def load_config(path: str | Path) -> BridgeConfig:
"""Load a config from a YAML or TOML file.
Dispatches on file extension. Raises FileNotFoundError if missing,
pydantic.ValidationError for invalid content.
"""
p = Path(path).expanduser()
if not p.exists():
raise FileNotFoundError(f"config file not found: {p}")
text = p.read_text()
suffix = p.suffix.lower()
if suffix in (".yaml", ".yml"):
try:
import yaml
except ImportError as e:
raise ImportError("PyYAML is required for YAML configs") from e
data = yaml.safe_load(text) or {}
elif suffix == ".toml":
try:
import tomllib # 3.11+
except ImportError: # pragma: no cover
import tomli as tomllib # type: ignore[no-redef]
data = tomllib.loads(text)
else:
raise ValueError(f"unsupported config extension: {suffix}")
return BridgeConfig.model_validate(data)
def load_config_from_dict(data: dict) -> BridgeConfig:
"""Load from an already-parsed dict (test convenience)."""
return BridgeConfig.model_validate(data)