Files
MeshTerm/meshtools/context.py
T
Jean-Pierre MartineauandClaude Opus 4.8 b289565d96 Sharpen device discovery: node names, tiered confidence, no double port
Improve the device picker and inventory in three ways:

- Remember a device's mesh node name. On a successful connect, learn the
  radio's own name (adv_name/name, best-effort) and persist it with the
  remembered device; the picker leads its row with it and the devices table
  shows it. A failed probe never blocks the connection and preserves any
  name already on file.
- Qualify devices in tiers instead of one over-generous "likely LoRa" flag.
  A native-USB dev board is a strong "LoRa device"; a bare USB-UART bridge
  (CP210x/CH340/FTDI) is only a "serial adapter" / "maybe", since the same
  chip shows up on plenty of non-LoRa hardware. Sorting and the table follow
  the board > bridge > unknown ranking; JSON gains a `confidence` field.
- Stop repeating the COM port. Windows descriptions often already end with
  "(COM11)", so the label no longer renders "… (COM11) (COM11)".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 19:15:33 -04:00

205 lines
8.1 KiB
Python

"""The application context: a small dependency container passed to every tool.
``AppContext`` owns the shared, expensive singletons (console, settings, repository,
logger) and lazily manages the device connection so tools that don't touch the radio
never open a serial port.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Optional
from rich.console import Console
from .core.admin_store import AdminStore
from .core.config import DeviceProfile, Settings
from .core.connection import Device, make_device
from .core.device_store import DeviceStore
from .core.discovery import DiscoveredDevice, discover_devices
from .core.selection import resolve_device
from .persistence.logging import get_logger
from .persistence.repository import Repository
if TYPE_CHECKING:
from .services.chat_service import ChatService
from .services.event_hub import EventHub
from .services.monitor_service import MonitorService
from .ui.surface import Ui
@dataclass(slots=True)
class AppContext:
"""Shared services and per-invocation state handed to tools.
Attributes:
console: Rich console for all rendering.
settings: Loaded application settings.
repo: Database repository.
profile: Active device profile, if one was resolved.
device_store: Store for the remembered "last known good" device.
admin_store: Store for remembered remote-node admin passwords.
mock: Whether the simulator device is in use.
port_override: Explicit serial port (from ``--port`` or the interactive picker),
overriding the profile.
json_output: Whether tools should emit machine-readable output.
selected_device: The discovered device chosen for this session, when known, so it
can be remembered after a successful connection.
explicit_selection: Whether ``--port``/``--profile`` was passed explicitly (which
suppresses the interactive picker and auto-discovery).
"""
console: Console
settings: Settings
repo: Repository
device_store: DeviceStore
admin_store: AdminStore
profile: Optional[DeviceProfile] = None
mock: bool = False
port_override: Optional[str] = None
json_output: bool = False
selected_device: Optional[DiscoveredDevice] = None
explicit_selection: bool = False
_device: Optional[Device] = field(default=None, init=False, repr=False)
_events: "Optional[EventHub]" = field(default=None, init=False, repr=False)
_monitor: "Optional[MonitorService]" = field(default=None, init=False, repr=False)
_chat: "Optional[ChatService]" = field(default=None, init=False, repr=False)
_ui: "Optional[Ui]" = field(default=None, init=False, repr=False)
@property
def profile_name(self) -> Optional[str]:
"""Name of the active profile, if any."""
return self.profile.name if self.profile else None
@property
def ui(self) -> "Ui":
"""The active UI surface, defaulting to the plain console surface for the CLI.
The interactive menu replaces this with a full-screen TUI surface for the session;
scripted CLI runs use the lazily-created plain surface, which prints directly. The
surface module (and prompt_toolkit) is imported lazily here to keep startup fast.
"""
if self._ui is None:
from .ui.surface import PlainUi
self._ui = PlainUi(self.console)
return self._ui
@ui.setter
def ui(self, value: "Ui") -> None:
"""Install a UI surface (used by the menu to switch to the full-screen TUI)."""
self._ui = value
@property
def events(self) -> "EventHub":
"""Return the session's always-on event hub, creating it on first use.
The hub owns the single device event subscription and fans events out to any
number of subscribers (the passive monitor's logging is one of them). It is
created idle here; the interactive session starts it once a device is available.
"""
if self._events is None:
from .services.event_hub import EventHub
self._events = EventHub(self)
return self._events
@property
def monitor(self) -> "MonitorService":
"""Return the session's passive-monitor service, creating it on first use.
The service is built lazily so the (cheap) database read for the "total"
observation count and the on/off preference load happen only once, when
monitoring is first referenced.
"""
if self._monitor is None:
from .core.monitor_store import MonitorStore
from .services.monitor_service import MonitorService
self._monitor = MonitorService(
self, MonitorStore(self.settings.config_dir / "monitor.json")
)
return self._monitor
@property
def chat(self) -> "ChatService":
"""Return the session's chat service, creating it on first use.
The service records inbound messages to history (as a subscriber of the always-on
event hub) and owns the outbound send path and the per-conversation unread counts.
It is created idle here; the interactive session starts it once a device is
available (and the live chat screen starts it lazily otherwise).
"""
if self._chat is None:
from .services.chat_service import ChatService
self._chat = ChatService(self)
return self._chat
@property
def log(self): # type: ignore[no-untyped-def]
"""The application logger."""
return get_logger()
async def device(self) -> Device:
"""Return a connected :class:`Device`, opening the connection on first use.
For real hardware this resolves which serial port to use (explicit ``--port`` /
profile / remembered default / sole attached device) and, on a successful
connection, records the device as the new "last known good" default.
Returns:
The shared, connected device for this invocation.
Raises:
ValueError: If no serial port can be chosen and the simulator is not enabled.
DeviceSelectionError: If discovery is ambiguous (subclass of ``ValueError``).
"""
if self._device is not None:
return self._device
if self.mock:
self._device = make_device(mock=True, port=None)
await self._device.connect()
return self._device
resolution = resolve_device(
discover_devices(),
self.device_store.load(),
explicit_port=self.port_override,
profile=self.profile,
)
if resolution.device is not None:
self.selected_device = resolution.device
baudrate = self.profile.baudrate if self.profile else 115200
self._device = make_device(mock=False, port=resolution.port, baudrate=baudrate)
await self._device.connect()
# Connection succeeded: this is now the last known good device. Learn its mesh node
# name (best-effort — a probe failure must not block a good connection).
if self.selected_device is not None:
self.device_store.remember(
self.selected_device, node_name=await self._node_name()
)
return self._device
async def _node_name(self) -> str:
"""Return the connected device's own mesh node name, or ``""`` if unavailable."""
try:
info = await self._device.get_self_info()
except Exception: # noqa: BLE001 - identity probe is best-effort, never fatal
return ""
return str(info.get("adv_name") or info.get("name") or "")
async def aclose(self) -> None:
"""Stop monitoring and chat, stop the event hub, disconnect, and close the repo."""
if self._monitor is not None:
await self._monitor.aclose()
if self._chat is not None:
await self._chat.aclose()
if self._events is not None:
await self._events.aclose()
if self._device is not None:
await self._device.disconnect()
self._device = None
self.repo.close()