Files
MeshTerm/meshterm/context.py
T
Jean-Pierre MartineauandClaude Opus 5 4e7bcacc37 Tile decode: don't pay for the layers the map never draws
Profiling on the Lyra found vector-tile decode dominating everything else
in the app by about two orders of magnitude: a median cached tile took
1236 ms of pure CPU, the largest 3.4 s, against 16 ms of SD read for a
whole viewport. Decoded tiles are memoised for the session, so this is a
first-pan cost -- but it runs on a GIL-bound worker thread, so it starves
the render loop for those seconds rather than running beside it.

Roughly 40% of that work was thrown away. A planet tile ships building,
housenumber, poi and mountain_peak; _draw_tile looks up ten layer names
and none of them is those. So decode_tile now takes the set of layers the
caller actually wants, reads each layer's identity with a cheap header
walk, and decodes the features only for the ones asked for. Unwanted
layers are still returned, named and empty -- which matters, because the
tile source decides whether a cache entry is real by whether the bytes
parsed into layers, and a tile whose every layer we skip must not read as
blank and get its perfectly good cache entry pruned and re-downloaded
every session. That test moves off "has features" and onto "parsed into
layers", which is sound because the other two ways a tile isn't real --
empty and corrupt -- decode to nothing and raise respectively.

Only the decode narrows; whole tiles are still cached, so drawing more
later costs a re-decode and never a re-download.

Measured on the device across the 46 real cached tiles: 183 s -> 51 s,
3.6x, with the drawn layers byte-identical on every one. The median tile
is 1259 -> 353 ms, the largest 3545 -> 759 ms.

_Reader.skip stopped slicing out the bytes it steps over while it was
here, which is most of what makes the header walk cheap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-08 00:04:59 -04:00

722 lines
34 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.advert_store import AdvertStore
from .core.channel_store import ChannelStore
from .core.config import DeviceProfile, Settings
from .core.contact_store import ContactStore
from .core.courier_store import CourierStore
from .core.mute_store import MuteStore
from .core.remote_store import RemoteStore
from .core.settings_store import SettingsStore
from .core.watch_store import WatchStore
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.advert_scheduler import AdvertScheduler
from .services.chat_service import ChatService
from .services.event_hub import EventHub
from .services.courier import CourierService
from .services.monitor_service import MonitorService
from .services.watchtower import WatchtowerService
from .services.basemap import BasemapSource
from .services.battery_service import BatteryService
from .services.device_state import DeviceState
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.
advert_store: Store for per-device background-advert schedules (defaults to
``<config_dir>/adverts.json`` when not injected).
remote_store: Store for remote-node admin state — cached settings and CLI
history (defaults to ``<config_dir>/remote.json`` when not injected).
watch_store: Store for the Watchtower — watched nodes, rules, and the alert
log (defaults to ``<config_dir>/watchtower.json`` when not injected).
courier_store: Store for the Courier's outbox — queued, delivered, and
given-up messages (defaults to ``<config_dir>/courier.json``).
mute_store: Store for muted channel notifications — the channels whose new
messages don't raise the unread badge (defaults to
``<config_dir>/mutes.json`` when not injected).
channel_store: Store for channels created through MeshTerm, replayed into a device
that forgot them (a firmware-less radio bridge). Keyed by device public key;
defaults to ``<config_dir>/channels.json`` when not injected.
settings_store: Store for settings changed through MeshTerm, offered back on connect to
a device that forgot them (a firmware-less radio bridge). Keyed by device public
key; defaults to ``<config_dir>/settings.json`` when not injected.
contact_store: Store for contacts read through MeshTerm, merged back into the contact
lists for a device that forgot them (a firmware-less radio bridge). Keyed by device
public key; defaults to ``<config_dir>/contacts.json`` when not injected.
mock: Whether the simulator device is in use.
port_override: Explicit serial port (from ``--port`` or the interactive picker),
overriding the profile.
ble_override: Explicit Bluetooth address (from ``--ble`` or the interactive picker),
selecting the BLE transport. Takes precedence over ``port_override``.
tcp_override: Explicit network address ``host:port`` (from ``--tcp`` or the
interactive picker), selecting the TCP transport. Takes precedence over
``ble_override`` and ``port_override``.
ble_pin: Optional BLE pairing PIN for the chosen Bluetooth device.
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``/``--ble``/``--profile`` was passed explicitly
(which suppresses the interactive picker and auto-discovery).
"""
console: Console
settings: Settings
repo: Repository
device_store: DeviceStore
admin_store: AdminStore
advert_store: Optional[AdvertStore] = None
remote_store: Optional[RemoteStore] = None
watch_store: Optional[WatchStore] = None
courier_store: Optional[CourierStore] = None
mute_store: Optional[MuteStore] = None
channel_store: Optional[ChannelStore] = None
settings_store: Optional[SettingsStore] = None
contact_store: Optional[ContactStore] = None
profile: Optional[DeviceProfile] = None
mock: bool = False
port_override: Optional[str] = None
ble_override: Optional[str] = None
tcp_override: Optional[str] = None
ble_pin: 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)
_active_port: Optional[str] = field(default=None, init=False, repr=False)
_active_transport: Optional[str] = field(default=None, init=False, repr=False)
_active_address: Optional[str] = field(default=None, init=False, repr=False)
_active_endpoint: Optional[str] = field(default=None, init=False, repr=False)
unpair_on_exit: bool = field(default=False, init=False, repr=False)
#: Set by the config editor just before it sends a reboot command, so the session's
#: disconnect watcher can label the ensuing (expected) link drop as a reboot in
#: progress rather than a surprise unplug. Cleared by the reconnect dialog that
#: consumes it (see :func:`meshterm.ui.menu._handle_disconnect`).
reboot_in_progress: bool = field(default=False, init=False, repr=False)
_resume_intent: Optional[tuple[bool, bool, bool]] = 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)
_adverts: "Optional[AdvertScheduler]" = field(default=None, init=False, repr=False)
_watchtower: "Optional[WatchtowerService]" = field(default=None, init=False, repr=False)
_courier: "Optional[CourierService]" = field(default=None, init=False, repr=False)
_battery: "Optional[BatteryService]" = field(default=None, init=False, repr=False)
_devstate: "Optional[DeviceState]" = field(default=None, init=False, repr=False)
_basemap_source: "Optional[BasemapSource]" = field(default=None, init=False, repr=False)
_ui: "Optional[Ui]" = field(default=None, init=False, repr=False)
def __post_init__(self) -> None:
"""Derive the JSON stores' default locations when they were not injected."""
if self.advert_store is None:
self.advert_store = AdvertStore(self.settings.config_dir / "adverts.json")
if self.remote_store is None:
self.remote_store = RemoteStore(self.settings.config_dir / "remote.json")
if self.watch_store is None:
self.watch_store = WatchStore(self.settings.config_dir / "watchtower.json")
if self.courier_store is None:
self.courier_store = CourierStore(self.settings.config_dir / "courier.json")
if self.mute_store is None:
self.mute_store = MuteStore(self.settings.config_dir / "mutes.json")
if self.channel_store is None:
self.channel_store = ChannelStore(self.settings.config_dir / "channels.json")
if self.settings_store is None:
self.settings_store = SettingsStore(self.settings.config_dir / "settings.json")
if self.contact_store is None:
self.contact_store = ContactStore(self.settings.config_dir / "contacts.json")
@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 is_connected(self) -> bool:
"""Whether a device connection is currently open."""
return self._device is not None
@property
def active_port(self) -> Optional[str]:
"""The serial port the current connection is open on, if any (``None`` for --mock/BLE).
Set when a real *serial* connection is opened so the reconnect flow knows which OS
port to wait on. ``None`` for the simulator and for BLE/TCP connections (which have no
serial port). Falls back to an explicit ``--port`` override when a connection hasn't
recorded one yet.
"""
if self.mock or self.active_transport in ("ble", "tcp"):
return None
return self._active_port or self.port_override
@property
def active_address(self) -> Optional[str]:
"""The Bluetooth address of the current BLE connection, if any (``None`` otherwise).
Set when a real *BLE* connection is opened, so post-session teardown (e.g. the quit
dialog's unpair step) can address the peripheral even after the device handle is torn
down. ``None`` for the simulator and for serial connections, which have no BLE address.
"""
if self.mock or self.active_transport != "ble":
return None
return self._active_address or self.ble_override
@property
def active_endpoint(self) -> Optional[str]:
"""The network ``host:port`` of the current TCP connection, if any (``None`` otherwise).
Set when a real *TCP* connection is opened, so the reconnect flow and header can name
the endpoint. ``None`` for the simulator and for serial/BLE connections. Falls back to
an explicit ``--tcp`` override when a connection hasn't recorded one yet.
"""
if self.mock or self.active_transport != "tcp":
return None
return self._active_endpoint or self.tcp_override
@property
def active_transport(self) -> Optional[str]:
"""The transport of the current/selected connection: ``"serial"``, ``"ble"``, ``"tcp"``,
or ``None``.
``None`` for the simulator. For a real device it reflects the open connection when
one exists, otherwise the transport implied by the pending selection (an explicit
``--tcp`` selects TCP, ``--ble`` selects BLE), defaulting to serial.
"""
if self.mock:
return None
if self._active_transport is not None:
return self._active_transport
if self.tcp_override:
return "tcp"
return "ble" if self.ble_override else "serial"
async def link_alive(self) -> bool:
"""Whether the current device's transport link is still up (best-effort, non-invasive).
Delegates to the connected device's :meth:`~meshterm.core.connection.Device.link_present`
— OS port enumeration for serial, the BLE client's connection flag for Bluetooth — so
the session's liveness watcher is transport-agnostic. Returns ``False`` when nothing is
connected (there is no live link), and ``True`` on any check hiccup so a transient
lookup failure never fakes a disconnect.
"""
device = self._device
if device is None:
return False
try:
return await device.link_present()
except Exception: # noqa: BLE001 - a liveness-check failure must not fake a disconnect
return True
@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 happens only once, when monitoring is first referenced.
"""
if self._monitor is None:
from .services.monitor_service import MonitorService
self._monitor = MonitorService(self)
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 adverts(self) -> "AdvertScheduler":
"""Return the session's background-advert scheduler, creating it on first use.
Created idle here; the interactive session starts it alongside the other
always-on services. Scripted CLI runs never start it, so a one-shot command
can't fire a background transmission.
"""
if self._adverts is None:
from .services.advert_scheduler import AdvertScheduler
self._adverts = AdvertScheduler(self)
return self._adverts
@property
def watchtower(self) -> "WatchtowerService":
"""Return the session's Watchtower sentinel, creating it on first use.
Created idle here; the interactive session starts it alongside the other
always-on services. It only ever listens (rules over hub events), so scripted
CLI runs simply never start it.
"""
if self._watchtower is None:
from .services.watchtower import WatchtowerService
self._watchtower = WatchtowerService(self)
return self._watchtower
@property
def courier(self) -> "CourierService":
"""Return the session's store-and-forward courier, creating it on first use.
Created idle here; the interactive session starts it alongside the other
always-on services. Scripted CLI runs never start the loop, so queueing a
message from a script transmits nothing until an interactive session runs.
"""
if self._courier is None:
from .services.courier import CourierService
self._courier = CourierService(self)
return self._courier
@property
def battery(self) -> "BatteryService":
"""Return the session's battery poller, creating it on first use.
Created idle here; the interactive session starts it alongside the other
always-on services. It only ever reads the pack (best-effort, skipping quietly
without a device), so scripted CLI runs simply never start the loop.
"""
if self._battery is None:
from .services.battery_service import BatteryService
self._battery = BatteryService(self)
return self._battery
@property
def devstate(self) -> "DeviceState":
"""Return the session's device-state cache, creating it on first use.
Holds the stable facts screens read from the companion on open (contacts, self-info,
path-hash mode, channel slots) so navigation doesn't re-read them from the radio every
time — the chief cause of slow screen transitions over Bluetooth. Created idle here;
each getter fetches lazily. Cleared on a reconnect (see :meth:`reconnect`).
"""
if self._devstate is None:
from .services.device_state import DeviceState
self._devstate = DeviceState(self)
return self._devstate
@property
def basemap_source(self) -> "BasemapSource":
"""Return the session's shared map tile source, built once and reused.
The map, the location picker, and the Node-detail location preview all draw the
same OpenStreetMap basemap; each used to build its own :class:`BasemapSource`, so
each paid the one-off TileJSON resolve — a blocking network round-trip the first
time ``.max_zoom``/``.available`` is touched — on *every* open. Holding one source
for the session means that resolve happens once (warmed behind the menu, see
:func:`meshterm.ui.menu._warm_basemap`), and the on-disk tile cache is shared too.
Independent of the radio link, so it survives reconnects untouched.
"""
if self._basemap_source is None:
from .services.basemap import BasemapSource
from .ui.map_render import DRAWN_LAYERS
self._basemap_source = BasemapSource(
self.settings.config_dir / "tilecache", layers=DRAWN_LAYERS
)
return self._basemap_source
@property
def log(self): # type: ignore[no-untyped-def]
"""The application logger."""
return get_logger()
def adopt_device(self, device: Device) -> None:
"""Adopt an already-connected device as the session's device.
Used by the startup picker: it opens and confirms the chosen companion during its
smoke test, and hands that live connection here so :meth:`device` reuses it instead
of opening the radio a second time (many boards reset on each serial open, making a
reconnect slow and unreliable).
Args:
device: A connected :class:`Device` to serve as this session's radio.
"""
self._device = device
# Record the transport and endpoint the picker opened directly, bypassing the
# resolution in ``device()`` that normally sets them — so the liveness watcher knows
# what to poll and ``reconnect`` can rebuild the same connection.
self._active_transport = getattr(device, "transport", "serial")
self._active_port = getattr(device, "_port", None)
self._active_address = getattr(device, "_address", None)
self._active_endpoint = getattr(device, "endpoint", None) if (
self._active_transport == "tcp"
) else None
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)
self._active_transport = None
self._active_port = None
self._active_address = None
self._active_endpoint = None
await self._device.connect()
return self._device
# A network endpoint (an explicit ``--tcp``, a TCP profile, a device picked at startup,
# or the remembered TCP default) is opened directly by host:port — TCP companions
# aren't discoverable, so this remembered/explicit endpoint is the only way to reach one.
tcp_host, tcp_port = self._resolve_tcp_endpoint()
if tcp_host and tcp_port:
self._device = make_device(
mock=False, port=None, transport="tcp", host=tcp_host, tcp_port=tcp_port
)
self._active_transport = "tcp"
self._active_endpoint = f"{tcp_host}:{tcp_port}"
self._active_port = None
self._active_address = None
await self._device.connect()
await self._remember_connected()
await self._reconcile_channels()
return self._device
# A Bluetooth endpoint (an explicit ``--ble``, a BLE profile, a device picked at
# startup, or the remembered BLE default) is opened directly by address — no serial
# resolution, and no re-scan needed to reconnect to a known address.
ble_address, ble_pin = self._resolve_ble_endpoint()
if ble_address:
# When this session's scan discovered the device, hand its live BLEDevice to the
# connection so bleak opens it directly instead of re-discovering the address (a
# fresh internal scan that intermittently misses a slow-advertising companion).
# A remembered/explicit address has no scan result and connects by address alone.
selected = self.selected_device
ble_device = (
selected.ble_device
if selected is not None and selected.is_ble and selected.address == ble_address
else None
)
self._device = make_device(
mock=False,
port=None,
transport="ble",
address=ble_address,
pin=ble_pin,
ble_device=ble_device,
)
self._active_transport = "ble"
self._active_address = ble_address
self._active_port = None
self._active_endpoint = None
await self._device.connect()
await self._remember_connected()
await self._reconcile_channels()
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)
self._active_transport = "serial"
self._active_port = resolution.port
self._active_address = None
self._active_endpoint = None
await self._device.connect()
await self._remember_connected()
await self._reconcile_channels()
return self._device
def _resolve_tcp_endpoint(self) -> tuple[Optional[str], Optional[int]]:
"""Return the ``(host, port)`` to open over TCP, or ``(None, None)`` for other transports.
Resolves a network endpoint in priority order — an explicit ``--tcp``, a TCP
:class:`~meshterm.core.config.DeviceProfile`, then the remembered TCP default — so a
network companion is honored wherever a serial/Bluetooth one would be. A malformed
endpoint yields ``(None, None)`` (the session then falls through to the other
transports rather than crashing on a typo).
"""
from .core.discovery import parse_tcp_endpoint
from .core.selection import DeviceSelectionError
# An explicit ``--tcp`` or TCP profile is authoritative — a malformed value is the
# user's typo, so surface the parse error as a clean, actionable message rather than
# silently falling through to another transport.
explicit_endpoint = self.tcp_override or (
self.profile.tcp_endpoint
if self.profile is not None and self.profile.is_tcp
else None
)
if explicit_endpoint:
try:
return parse_tcp_endpoint(explicit_endpoint)
except ValueError as exc:
raise DeviceSelectionError(str(exc)) from exc
# A remembered TCP default only applies when nothing else was selected explicitly, and
# a corrupt stored value must never crash startup — fall through to the other transports.
explicit_other = bool(self.port_override) or bool(self.ble_override) or (
self.profile is not None
and (bool(self.profile.port) or bool(self.profile.address))
)
if explicit_other:
return None, None
remembered = self.device_store.load()
if remembered is not None and remembered.is_tcp and remembered.target:
try:
host, port = parse_tcp_endpoint(remembered.target)
except ValueError:
return None, None
self.selected_device = None # remembered, not freshly discovered this session
return host, port
return None, None
def _resolve_ble_endpoint(self) -> tuple[Optional[str], Optional[str]]:
"""Return the ``(address, pin)`` to open over Bluetooth, or ``(None, None)`` for serial.
Resolves a BLE endpoint in priority order — an explicit ``--ble``, a BLE
:class:`~meshterm.core.config.DeviceProfile`, then the remembered BLE default — so a
Bluetooth companion is honored wherever a serial one would be. Returns ``(None, None)``
when the session should fall through to serial resolution.
"""
if self.ble_override:
return self.ble_override, self.ble_pin
# An explicit serial selection (``--port`` or a serial profile with a port) wins over a
# remembered BLE default, mirroring the serial resolution priority.
explicit_serial = bool(self.port_override) or (
self.profile is not None and not self.profile.is_ble and bool(self.profile.port)
)
if self.profile is not None and self.profile.is_ble and self.profile.address:
return self.profile.address, self.ble_pin or self.profile.ble_pin
if explicit_serial:
return None, None
remembered = self.device_store.load()
if remembered is not None and remembered.is_ble and remembered.target:
self.selected_device = None # remembered, not freshly discovered this session
return remembered.target, self.ble_pin
return None, None
async def _remember_connected(self) -> None:
"""Record the just-connected device as the last known good default (best-effort).
Learns the device's mesh node name (a probe failure must not block a good
connection). Only devices discovered this session carry a
:class:`~meshterm.core.discovery.DiscoveredDevice` to remember; a bare ``--port`` or
remembered-by-address reconnect has nothing new to upsert.
"""
if self.selected_device is not None:
self.device_store.remember(
self.selected_device,
node_name=await self._node_name(),
hardware_model=await self._hardware_model(),
)
async def _reconcile_channels(self) -> None:
"""Replay channels remembered for this device that it isn't reporting (best-effort).
A firmware-less radio bridge loses its channels whenever it restarts, so the channels
you added through MeshTerm are restored into free slots on connect (see
:func:`~meshterm.core.channel_store.reconcile`). With nothing remembered for the device
this is a single identity probe, so a firmware radio pays almost nothing. A failure here
must never break connecting — it is logged and swallowed.
"""
if self._device is None or self.channel_store is None:
return
from .core.channel_store import reconcile
try:
restored = await reconcile(self.channel_store, self._device)
except Exception as exc: # noqa: BLE001 - channel replay must not block a connection
self.log.debug("channels: reconcile on connect failed: %s", exc)
return
if restored:
self.log.info("channels: restored %d remembered channel(s) to the device", restored)
async def reconnect(self) -> None:
"""Drop a lost device connection and rebuild it, restoring live services.
Called after the companion link is detected as gone (see
:func:`~meshterm.core.connection.is_connection_lost`). Tears down the dead
connection and the services riding on it, opens a fresh connection to the same
device, then restarts whatever was running before — so passive monitoring and chat
recording resume transparently across a replug.
Raises:
Exception: If a new connection could not be opened (e.g. the device is still
absent); the caller can surface it and offer to retry.
"""
# Remember what was live so it can be restored after the reconnect — but capture it
# only *once*, and hold it across retries. The teardown below stops the services, so
# a second attempt (after the first failed because the device wasn't back yet) would
# otherwise read the now-idle flags and restore nothing. The intent is cleared only
# after a reconnect actually succeeds.
if self._resume_intent is None:
self._resume_intent = (
self._events is not None and self._events.active,
self._monitor is not None and self._monitor.active,
self._chat is not None and self._chat.active,
)
resume_events, resume_monitor, resume_chat = self._resume_intent
# Release the stale hub/service subscriptions and discard the dead device. The
# subscriptions are in-process (to the hub), so they survive the link drop and must
# be torn down explicitly before a fresh connection is opened underneath them.
if self._monitor is not None:
await self._monitor.stop()
if self._chat is not None:
await self._chat.stop()
if self._events is not None:
await self._events.stop()
if self._device is not None:
try:
await self._device.disconnect()
except Exception: # noqa: BLE001 - the link is already gone; best-effort
pass
self._device = None
# Facts cached against the connection that just dropped may be stale on the fresh link
# (a reboot could have changed the identity or config), so re-read them on next use.
if self._devstate is not None:
self._devstate.reset()
# Open a fresh connection (raises if the device still can't be reached, leaving the
# remembered intent in place for the next attempt), then restart whatever was running
# before it dropped and clear the intent now that we're back.
await self.device()
if resume_events:
await self.events.start()
if resume_monitor:
await self.monitor.start()
if resume_chat:
await self.chat.start()
self._resume_intent = None
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 _hardware_model(self) -> str:
"""Return the connected device's firmware model string, or ``""`` if unavailable.
Sourced from the device-query frame — the only place the model is exposed — so a
reconnect keeps the remembered hardware column fresh. Best-effort: a probe failure or
older firmware just leaves any previously-remembered model untouched.
"""
try:
info = await self._device.get_device_info()
except Exception: # noqa: BLE001 - model lookup is best-effort, never fatal
return ""
return str(info.get("model") or "")
async def aclose(self) -> None:
"""Stop monitoring and chat, stop the event hub, disconnect, and close the repo."""
if self._devstate is not None:
await self._devstate.aclose()
if self._courier is not None:
await self._courier.aclose()
if self._battery is not None:
await self._battery.aclose()
if self._watchtower is not None:
await self._watchtower.aclose()
if self._adverts is not None:
await self._adverts.aclose()
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:
try:
await self._device.disconnect()
except Exception: # noqa: BLE001 - a dead/lost link must not crash teardown
pass
self._device = None
self.repo.close()