Files
MeshTerm/meshterm/core/config.py
T
Jean-Pierre MartineauandClaude Opus 4.8 1edd4a3bf4 Remove Plotly and the TX-optimize HTML chart
The viz package existed solely to render one interactive HTML chart for
tx-optimize sweeps, pulling in Plotly — a heavyweight dependency serving a
single artifact while every other timeline renders in-terminal via
braillechart. Drop it with no replacement: delete meshterm/viz, the
--viz/--no-viz flag and its render call, the now-unused output_dir setting
(config field, default, loader, example), the Plotly test, and the README
and pyproject references.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-17 19:53:08 -04:00

219 lines
9.5 KiB
Python

"""Configuration and named device profiles.
Settings are read from a TOML file (default ``~/.meshterm/config.toml``) and may
be overridden per-invocation by CLI flags. Profiles let you alias your hardware
(``yagi`` repeater, ``local`` repeater, ``observer`` bot, ``s3`` serial companion) to a
serial port and defaults so commands can target them by name.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
if sys.version_info >= (3, 11):
import tomllib
else: # pragma: no cover - exercised only on 3.10
import tomli as tomllib
#: Hard ceiling on a direct message's *soft retries* — the automatic re-sends that follow the
#: initial transmission when it goes unacknowledged. Capped at 2, so a message gets at most one
#: send plus two soft retries (three tries total) and is never re-broadcast more than twice on
#: the shared mesh before being called a failure. :data:`Settings.direct_message_soft_retries`
#: is clamped to ``0..DIRECT_MESSAGE_MAX_SOFT_RETRIES``.
DIRECT_MESSAGE_MAX_SOFT_RETRIES = 2
def default_config_dir() -> Path:
"""Return the directory MeshTerm uses for config and data.
Resolves to ``.meshterm`` under the OS-defined home directory (``%USERPROFILE%``
on Windows, ``$HOME`` on Unix), as reported by :meth:`Path.home`.
Returns:
The resolved configuration directory path (not guaranteed to exist).
"""
return Path.home() / ".meshterm"
@dataclass(slots=True)
class DeviceProfile:
"""Connection defaults for one physical companion device.
A profile addresses a serial companion (via ``port``), a Bluetooth one (via
``transport = "ble"`` and ``address``), or a network one (via ``transport = "tcp"``,
``host``, and ``tcp_port``). ``transport`` defaults to serial, so existing port-only
profiles are unchanged.
Attributes:
name: The profile alias (e.g. ``"yagi"``).
port: Serial port path (e.g. ``COM5`` or ``/dev/ttyUSB0``); serial profiles.
baudrate: Serial baud rate.
default_tx_power: TX power to assume/restore for this device, if known.
description: Free-text note about the hardware.
transport: ``"serial"`` (default), ``"ble"``, or ``"tcp"``.
address: Bluetooth address (e.g. ``AA:BB:CC:DD:EE:FF``); BLE profiles.
ble_pin: Optional BLE pairing PIN, if the Bluetooth companion requires one.
host: Hostname or IP of a network companion (e.g. ``"192.168.1.50"``); TCP profiles.
tcp_port: TCP port the network companion listens on; TCP profiles (defaults to
:data:`~meshterm.core.discovery.DEFAULT_TCP_PORT` when a host is given without one).
"""
name: str
port: Optional[str] = None
baudrate: int = 115200
default_tx_power: Optional[int] = None
description: str = ""
transport: str = "serial"
address: Optional[str] = None
ble_pin: Optional[str] = None
host: Optional[str] = None
tcp_port: Optional[int] = None
@property
def is_ble(self) -> bool:
"""Whether this profile addresses a Bluetooth LE companion."""
return self.transport == "ble"
@property
def is_tcp(self) -> bool:
"""Whether this profile addresses a network (TCP) companion."""
return self.transport == "tcp"
@property
def tcp_endpoint(self) -> Optional[str]:
"""The ``host:port`` string for a TCP profile, or ``None`` if it isn't one / has no host."""
if not self.is_tcp or not self.host:
return None
from .discovery import DEFAULT_TCP_PORT
return f"{self.host}:{self.tcp_port or DEFAULT_TCP_PORT}"
@dataclass(slots=True)
class Settings:
"""Top-level application settings.
Attributes:
config_dir: Directory holding the config file and database.
db_path: SQLite database location.
default_profile: Profile used when ``--profile`` is omitted.
profiles: Mapping of profile name to :class:`DeviceProfile`.
trace_cooldown_s: Minimum delay between transmit bursts (duty-cycle safety).
tx_opt_min: Lowest TX power explored by the remote-admin optimizer (dBm).
tx_opt_max: Highest TX power explored by the remote-admin optimizer (dBm).
direct_message_soft_retries: How many times a direct message is automatically
re-sent after its initial transmission goes unacknowledged, before it is reported
as failed. Each soft retry waits a full delivery-ack window (see
:meth:`Device.send_direct_message`) before firing, so a message only resends after
genuinely going unanswered rather than hammering the radio. Capped at 0, 1, or 2 —
clamped to ``0..DIRECT_MESSAGE_MAX_SOFT_RETRIES`` on load — so the send is tried at
most three times total. ``0`` disables soft retries (one shot); the default of
``2`` matches the mesh convention of a few tries before giving up. The manual
Ctrl-R resend in the chat screen is a further, user-driven retry layered on top of
these automatic ones, not a substitute for them.
connect_on_start: Whether the interactive session opens the companion connection
(and starts always-on background listening) immediately at launch. When
``False`` the connection is opened lazily — only once monitoring is turned on
or a tool first needs the radio — so launching the menu touches no serial port.
history_days: How many days of overheard-packet history the recorder retains.
Observations older than this are pruned once per session start (the
housekeeping sweep), keeping the database bounded while the dashboard and
Time Machine draw on everything inside the window. ``0`` disables pruning
entirely — history grows forever.
"""
config_dir: Path = field(default_factory=default_config_dir)
db_path: Optional[Path] = None
default_profile: Optional[str] = None
profiles: dict[str, DeviceProfile] = field(default_factory=dict)
trace_cooldown_s: float = 1.0
tx_opt_min: int = 12
tx_opt_max: int = 28
direct_message_soft_retries: int = 2
connect_on_start: bool = True
history_days: int = 365
def __post_init__(self) -> None:
"""Derive dependent paths that were not explicitly provided."""
if self.db_path is None:
self.db_path = self.config_dir / "meshterm.db"
def resolve_profile(self, name: Optional[str]) -> Optional[DeviceProfile]:
"""Look up a profile by name, falling back to the default profile.
Args:
name: Requested profile name, or ``None`` to use the default.
Returns:
The matching :class:`DeviceProfile`, or ``None`` if neither the requested
nor the default profile is defined.
"""
key = name or self.default_profile
if key is None:
return None
return self.profiles.get(key)
@classmethod
def load(cls, config_path: Optional[Path] = None) -> "Settings":
"""Load settings from a TOML file, returning defaults if it is absent.
Args:
config_path: Explicit path to a config file. Defaults to
``<config_dir>/config.toml``.
Returns:
A populated :class:`Settings` instance.
"""
config_dir = default_config_dir()
path = config_path or (config_dir / "config.toml")
data: dict[str, Any] = {}
if path.exists():
with path.open("rb") as fh:
data = tomllib.load(fh)
profiles: dict[str, DeviceProfile] = {}
for pname, pdata in (data.get("profiles") or {}).items():
# Infer the transport from which endpoint the profile carries when it isn't stated
# outright: a ``host`` means TCP, an ``address`` means BLE, otherwise serial. This
# keeps port-only profiles working untouched while a bare ``host``/``address`` is
# enough to declare a network/Bluetooth one.
transport = pdata.get("transport") or (
"tcp" if pdata.get("host") else "ble" if pdata.get("address") else "serial"
)
tcp_port = pdata.get("tcp_port")
profiles[pname] = DeviceProfile(
name=pname,
port=pdata.get("port"),
baudrate=pdata.get("baudrate", 115200),
default_tx_power=pdata.get("default_tx_power"),
description=pdata.get("description", ""),
transport=transport,
address=pdata.get("address"),
ble_pin=pdata.get("ble_pin"),
host=pdata.get("host"),
tcp_port=int(tcp_port) if tcp_port is not None else None,
)
return cls(
config_dir=config_dir,
db_path=Path(data["db_path"]) if data.get("db_path") else None,
default_profile=data.get("default_profile"),
profiles=profiles,
trace_cooldown_s=float(data.get("trace_cooldown_s", 1.0)),
tx_opt_min=int(data.get("tx_opt_min", 12)),
tx_opt_max=int(data.get("tx_opt_max", 28)),
direct_message_soft_retries=max(
0,
min(
DIRECT_MESSAGE_MAX_SOFT_RETRIES,
int(data.get("direct_message_soft_retries", DIRECT_MESSAGE_MAX_SOFT_RETRIES)),
),
),
connect_on_start=bool(data.get("connect_on_start", True)),
history_days=max(0, int(data.get("history_days", 365))),
)