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>
205 lines
8.1 KiB
Python
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()
|