Files
MeshTerm/meshterm/context.py
T
Jean-Pierre MartineauandClaude Opus 5.5 796ff94dad Our star is white
The star of our node had the hue of our key, set from the self-info at run
time. It is now the white of "you" on each surface: the map, the routes,
the lists, the legends, and the chat. The run-time colour, the device-state
hook, and the style generation of the render cache are removed. A faded
star keeps its grey.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2026-10-14 00:18:59 -04:00

1065 lines
53 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""The application context: a small dependency container that MeshTerm gives to each tool.
``AppContext`` owns the shared singletons that are expensive to make (console, settings,
repository, logger). It manages the device connection lazily. Thus a tool that does not
use the radio never opens a serial port.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
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, SpiWiring
from .core.connection import Device, make_device
from .core.contact_store import ContactStore
from .core.courier_store import CourierStore
from .core.device_store import DeviceStore
from .core.discovery import DiscoveredDevice, discover_devices
from .core.mute_store import MuteStore
from .core.preferences import PREFERENCES_FILENAME, Preferences, report_dropped
from .core.preferences import install as install_preferences
from .core.region_store import RegionStore
from .core.remote_store import RemoteStore
from .core.room_store import RoomStore
from .core.selection import resolve_device
from .core.settings_store import SettingsStore
from .core.watch_store import WatchStore
from .persistence.logging import get_logger
from .persistence.repository import Repository
from .ui.renderers import OutputFormat
if TYPE_CHECKING:
from .services.advert_scheduler import AdvertScheduler
from .services.basemap import BasemapSource
from .services.battery_service import BatteryService
from .services.chat_service import ChatService
from .services.clock_sync import ClockSync
from .services.courier import CourierService
from .services.device_state import DeviceState
from .services.event_hub import EventHub
from .services.monitor_service import MonitorService
from .services.rooms import RoomService
from .services.watchtower import WatchtowerService
from .ui.surface import Ui
@dataclass(slots=True)
class AppContext:
"""The shared services and the state of one run, which MeshTerm gives to tools.
Attributes:
console: The Rich console for all rendering.
settings: The loaded application settings. They say where things are and which
device to talk to (config directory, database, named profiles). How MeshTerm
behaves is in ``preferences``, not here.
preferences: The preferences of MeshTerm itself. They are backed by
``<config_dir>/preferences.toml``, and the user edits them on the Preferences
page. If the caller does not inject them, the context loads them from that file.
The context installs them as the process-wide
:func:`~meshterm.core.preferences.current` set. Thus the render layer has no
context to reach through, and it reads the same values.
repo: The database repository.
profile: The active device profile, if the context resolved one.
device_store: The store for the remembered "last known good" device.
admin_store: The store for the remembered admin passwords of remote nodes.
advert_store: The store for the background-advert schedules of each device (if the
caller does not inject it, the default is ``<config_dir>/adverts.json``).
remote_store: The store for the admin state of remote nodes: cached settings and CLI
history (if the caller does not inject it, the default is
``<config_dir>/remote.json``).
watch_store: The store for the Watchtower: watched nodes, rules, and the alert log
(if the caller does not inject it, the default is
``<config_dir>/watchtower.json``).
courier_store: The store for the outbox of the Courier: queued, delivered, and
given-up messages (the default is ``<config_dir>/courier.json``).
mute_store: The store for muted channel notifications. These are the channels whose
new messages do not raise the unread badge (if the caller does not inject it, the
default is ``<config_dir>/mutes.json``).
region_store: The store for the regions that have a name, and for the send scope of
each channel. MeshTerm resolves a scoped flood against it (if the caller does not
inject it, the default is ``<config_dir>/regions.json``).
channel_store: The store for channels that the user created through MeshTerm. The
context replays them into a device that forgot them (a radio bridge without
firmware). The store uses the public key of the device as its key. If the caller
does not inject it, the default is ``<config_dir>/channels.json``.
settings_store: The store for settings that the user changed through MeshTerm.
MeshTerm offers them again at connection to a device that forgot them (a radio
bridge without firmware). The store uses the public key of the device as its key.
If the caller does not inject it, the default is ``<config_dir>/settings.json``.
contact_store: The store for contacts that MeshTerm read. MeshTerm merges them back
into the contact lists for a device that forgot them (a radio bridge without
firmware). The store uses the public key of the device as its key. If the caller
does not inject it, the default is ``<config_dir>/contacts.json``.
room_store: The store for the rooms that this machine joined: the password of each
room and the access that it granted (if the caller does not inject it, the
default is ``<config_dir>/rooms.json``).
mock: Whether the context uses the simulator device.
port_override: An explicit serial port (from ``--port`` or the interactive picker),
which overrides the profile.
ble_override: An explicit Bluetooth address (from ``--ble`` or the interactive
picker), which selects the BLE transport. It has precedence over
``port_override``.
tcp_override: An explicit network address ``host:port`` (from ``--tcp`` or the
interactive picker), which selects the TCP transport. It has precedence over
``ble_override`` and ``port_override``.
ble_pin: The optional BLE pairing PIN for the chosen Bluetooth device.
output: The format in which a scripted run prints its answer (refer to
:class:`~meshterm.ui.renderers.OutputFormat`). A tool never reads this to decide
what to say. It states its answer as a :class:`~meshterm.ui.report.Report`, and
the CLI boundary picks the renderer. The two streaming commands read it only to
open the correct type of stream.
interactive: Whether this run can stop and ask a person a question. It is false for
each scripted run whose stdin is not a terminal. A tool must have the answer to
this question before it prompts. ``tx-optimize`` used to check ``--json``
instead. A flag about the output format cannot answer it. Without the flag, a
scheduled run on a terminal would wait for a password.
selected_device: The discovered device that was chosen for this session, if it is
known. MeshTerm can remember it after a successful connection.
explicit_selection: Whether the user passed ``--port``, ``--ble``, or ``--profile``
explicitly. This suppresses the interactive picker and auto-discovery.
"""
console: Console
settings: Settings
repo: Repository
device_store: DeviceStore
admin_store: AdminStore
preferences: Preferences | None = None
advert_store: AdvertStore | None = None
remote_store: RemoteStore | None = None
watch_store: WatchStore | None = None
courier_store: CourierStore | None = None
mute_store: MuteStore | None = None
region_store: RegionStore | None = None
channel_store: ChannelStore | None = None
settings_store: SettingsStore | None = None
contact_store: ContactStore | None = None
room_store: RoomStore | None = None
profile: DeviceProfile | None = None
mock: bool = False
port_override: str | None = None
ble_override: str | None = None
tcp_override: str | None = None
spi_override: bool = False
ble_pin: str | None = None
output: OutputFormat = OutputFormat.PLAIN
interactive: bool = True
selected_device: DiscoveredDevice | None = None
explicit_selection: bool = False
_device: Device | None = field(default=None, init=False, repr=False)
_active_port: str | None = field(default=None, init=False, repr=False)
_active_transport: str | None = field(default=None, init=False, repr=False)
_active_address: str | None = field(default=None, init=False, repr=False)
_active_endpoint: str | None = field(default=None, init=False, repr=False)
#: A live ``bleak.BLEDevice`` that the flow gives to :meth:`reconnect`. The flow just heard
#: the companion advertise again. MeshTerm prefers it to the handle of the startup scan
#: when it reopens the link. A device that dropped and came back is a new peripheral to
#: the OS, and the older handle does not name it now. The type is ``object``, so that
#: ``bleak`` stays optional.
_ble_handle: object | None = field(default=None, init=False, repr=False)
unpair_on_exit: bool = field(default=False, init=False, repr=False)
#: :meth:`announce_reboot` sets this after a reboot command has gone out. Then the
#: reconnect dialog shows the drop as a reboot in progress, and not as a surprise unplug.
#: :meth:`take_reboot` clears it in the dialog that uses it (refer to
#: :func:`meshterm.ui.menu._handle_disconnect`).
reboot_in_progress: bool = field(default=False, init=False, repr=False)
#: MeshTerm sets this together with :attr:`reboot_in_progress`. It wakes the disconnect
#: watcher of the session at once. The watcher does not wait for a liveness poll to
#: observe the drop, and the poll can fail to observe it at all, because a board
#: behind a USB-UART bridge keeps its port through a reboot.
_link_going_down: asyncio.Event = field(default_factory=asyncio.Event, init=False, repr=False)
_resume_intent: tuple[bool, bool, bool] | None = field(default=None, init=False, repr=False)
_events: EventHub | None = field(default=None, init=False, repr=False)
_monitor: MonitorService | None = field(default=None, init=False, repr=False)
_chat: ChatService | None = field(default=None, init=False, repr=False)
_rooms: RoomService | None = field(default=None, init=False, repr=False)
_adverts: AdvertScheduler | None = field(default=None, init=False, repr=False)
_watchtower: WatchtowerService | None = field(default=None, init=False, repr=False)
_clock_sync: ClockSync | None = field(default=None, init=False, repr=False)
_courier: CourierService | None = field(default=None, init=False, repr=False)
_battery: BatteryService | None = field(default=None, init=False, repr=False)
_devstate: DeviceState | None = field(default=None, init=False, repr=False)
_basemap_source: BasemapSource | None = field(default=None, init=False, repr=False)
_ui: Ui | None = field(default=None, init=False, repr=False)
def __post_init__(self) -> None:
"""Load the preferences and derive the default locations of the stores.
This applies to each item that the caller did not inject. The method also installs
the preferences as the process-wide set (refer to
:func:`~meshterm.core.preferences.install`). The renderer of the session and the
stores that are built without a context must read them.
"""
if self.preferences is None:
self.preferences = Preferences.load(self.settings.config_dir / PREFERENCES_FILENAME)
report_dropped(self.preferences, self.log)
install_preferences(self.preferences)
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.region_store is None:
self.region_store = RegionStore(self.settings.config_dir / "regions.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")
if self.room_store is None:
self.room_store = RoomStore(self.settings.config_dir / "rooms.json")
@property
def profile_name(self) -> str | None:
"""The name of the active profile, if there is one."""
return self.profile.name if self.profile else None
@property
def is_connected(self) -> bool:
"""Whether a device connection is open now."""
return self._device is not None
@property
def active_port(self) -> str | None:
"""The serial port of the current connection, if there is one (``None`` for --mock/BLE).
MeshTerm sets this when it opens a real serial connection. Then the reconnect flow
knows which OS port to wait for. It is ``None`` for the simulator and for BLE and TCP
connections, which have no serial port. If a connection has not recorded a port yet,
the value is the explicit ``--port`` override.
"""
if self.mock or self.active_transport in ("ble", "tcp", "spi"):
return None
return self._active_port or self.port_override
@property
def active_address(self) -> str | None:
"""The Bluetooth address of the current BLE connection, if there is one.
The value is ``None`` in all other cases. MeshTerm sets this when it opens a real BLE
connection. Then the teardown after the session (for example the unpair step of the
quit dialog) can address the peripheral, also after the handle of the device is
closed. It is ``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) -> str | None:
"""The network ``host:port`` of the current TCP connection, if there is one.
The value is ``None`` in all other cases. MeshTerm sets this when it opens a real TCP
connection. Then the reconnect flow and the header can name the endpoint. It is
``None`` for the simulator and for serial and BLE connections. If a connection has
not recorded an endpoint yet, the value is the explicit ``--tcp`` override.
"""
if self.mock or self.active_transport != "tcp":
return None
return self._active_endpoint or self.tcp_override
@property
def active_transport(self) -> str | None:
"""The transport that the current or selected connection uses.
The value is ``"serial"``, ``"ble"``, ``"tcp"``, or ``"spi"``, or ``None`` for the
simulator. For a real device, it is the transport of the open connection if there is
one. If not, it is the transport that the pending selection means (an explicit
``--tcp`` selects TCP, ``--ble`` selects BLE, and ``--spi`` selects the SPI radio).
The default is serial.
"""
if self.mock:
return None
if self._active_transport is not None:
return self._active_transport
if self.spi_override or (self.profile is not None and self.profile.is_spi):
return "spi"
if self.tcp_override:
return "tcp"
return "ble" if self.ble_override else "serial"
async def link_alive(self) -> bool:
"""Whether the transport link of the current device is still up.
This is a best-effort check, and it does not disturb the link. The method calls
:meth:`~meshterm.core.connection.Device.link_present` of the connected device. For
serial, this is the enumeration of OS ports. For Bluetooth, it is the connection flag
of the BLE client. Thus the liveness watcher of the session does not depend on the
transport. The method returns ``False`` when nothing is connected, because there is no
live link. If the check fails, it returns ``True``. Thus a temporary failure of the
lookup never gives a false disconnect.
"""
device = self._device
if device is None:
return False
try:
return await device.link_present()
except Exception: # noqa: BLE001 - a failure of the liveness check must not fake a disconnect
return True
def announce_reboot(self) -> None:
"""Declare the link down, because MeshTerm just told the companion to reboot.
Call this method after MeshTerm sent the reboot command, and never before. The
watcher that it wakes cancels the screen that sent the command. If the cancel occurs
during the write, the command can be only partly sent. From then on, MeshTerm cannot
talk to the device until it comes back. Thus the disconnect watcher of the session
(:meth:`link_going_down`) fires at once, and the reconnect dialog takes over. The
alternative is to hope that a liveness poll catches the drop. The poll misses a quick
reboot completely where the serial port never goes away.
"""
self.reboot_in_progress = True
self._link_going_down.set()
async def link_going_down(self) -> None:
"""Resolve when :meth:`announce_reboot` has declared the link down."""
await self._link_going_down.wait()
def take_reboot(self) -> bool:
"""Use a pending reboot announcement, and return whether there was one.
The method clears both parts. Thus a later disconnect that is real uses the generic
wording again, and the next watcher waits for a real drop again.
"""
pending = self.reboot_in_progress
self.reboot_in_progress = False
self._link_going_down.clear()
return pending
@property
def ui(self) -> Ui:
"""The active UI surface. The default for the CLI is the plain console surface.
For the session, the interactive menu replaces it with a full-screen TUI surface.
Scripted CLI runs use the plain surface, which MeshTerm creates lazily, and which
prints directly. The property imports the surface module (and prompt_toolkit) lazily,
to keep the 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. The menu uses this to change to the full-screen TUI."""
self._ui = value
@property
def events(self) -> EventHub:
"""Return the always-on event hub of the session, and create it at first use.
The hub owns the single subscription to the device events. It sends the events to
any number of subscribers (the logging of the passive monitor is one of them). The
property creates it idle. The interactive session starts it when 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 passive-monitor service of the session, and create it at first use.
MeshTerm builds the service lazily. Thus the database read for the "total" count of
observations (which is cheap) happens only one time, when the code first refers to
monitoring.
"""
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 chat service of the session, and create it at first use.
The service stores received messages in the history (as a subscriber of the always-on
event hub). It owns the path for sent messages and the unread count of each
conversation. The property creates it idle. The interactive session starts it when a
device is available. If it is not started then, the live chat screen starts it
lazily.
"""
if self._chat is None:
from .services.chat_service import ChatService
self._chat = ChatService(self)
return self._chat
@property
def rooms(self) -> RoomService:
"""Return the room service of the session, and create it at first use.
The service joins room servers. For the session, it remembers what access each room
server gave us, and when MeshTerm last heard it. This decides whether the opening
of a room logs in to it again. The service never transmits on its own.
"""
if self._rooms is None:
from .services.rooms import RoomService
self._rooms = RoomService(self)
return self._rooms
@property
def adverts(self) -> AdvertScheduler:
"""Return the background-advert scheduler of the session, and create it at first use.
The property creates it idle. The interactive session starts it with the other
always-on services. Scripted CLI runs never start it. Thus a one-shot command cannot
start 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 Watchtower sentinel of the session, and create it at first use.
The property creates it idle. The interactive session starts it with the other
always-on services. It only listens (it applies rules to the events of the hub).
Scripted CLI runs never start it.
"""
if self._watchtower is None:
from .services.watchtower import WatchtowerService
self._watchtower = WatchtowerService(self)
return self._watchtower
@property
def clock_sync(self) -> ClockSync:
"""Return the clock setter that runs at connection, and create it at first use.
The property creates it idle. The interactive session starts it with the other
always-on services, and :meth:`device` tells it about each connection when the
connection settles. Scripted CLI runs never start it. Thus a one-shot command that
only reads the radio never writes its clock.
"""
if self._clock_sync is None:
from .services.clock_sync import ClockSync
self._clock_sync = ClockSync(self)
return self._clock_sync
@property
def courier(self) -> CourierService:
"""Return the store-and-forward courier of the session, and create it at first use.
The property creates it idle. The interactive session starts it with the other
always-on services. Scripted CLI runs never start the loop. Thus if a script queues a
message, nothing is transmitted 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 battery poller of the session, and create it at first use.
The property creates it idle. The interactive session starts it with the other
always-on services. It only reads the pack, as a best-effort action. If there is no
device, it skips the read without a message. Scripted CLI runs 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 device-state cache of the session, and create it at first use.
The cache holds the stable facts that screens read from the companion when they open
(contacts, self-info, path hash mode, channel slots). Thus navigation does not read
them from the radio each time. That was the main cause of slow changes of screen over
Bluetooth. The property creates it idle, and each getter reads lazily. A reconnect
clears the cache (refer to :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 shared map tile source of the session. MeshTerm builds it once and reuses it.
The map, the location picker, and the location preview of Node detail all draw the
same OpenStreetMap basemap. Each of them used to build its own :class:`BasemapSource`.
Thus each of them paid the one-time TileJSON resolve at each open. The resolve is a
blocking network round trip, and it occurs when the code first uses
``.max_zoom`` or ``.available``. If the session holds one source, the resolve
happens one time. MeshTerm warms the source behind the menu (refer to
:func:`meshterm.ui.menu._warm_basemap`), and the tile cache on the disk is shared
also. The source does not depend on the radio link. Thus a reconnect does not change
it.
"""
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,
tilejson_url=self.preferences.basemap_tilejson_url,
)
return self._basemap_source
@property
def log(self): # type: ignore[no-untyped-def]
"""The logger of the application."""
return get_logger()
def adopt_device(self, device: Device) -> None:
"""Adopt a device that is already connected as the device of the session.
The startup picker uses this method. It opens the chosen companion and confirms it
during its smoke test. Then it gives that live connection to this method. Thus
:meth:`device` uses it again and does not open the radio a second time. Many boards
reset at each serial open, and this makes a reconnect slow and unreliable.
Args:
device: A connected :class:`Device` that serves as the radio of this session.
"""
self._device = device
# Record the transport and the endpoint that the picker opened directly. This goes
# around the resolution in ``device()``, which normally sets them. Then the liveness
# watcher knows what to poll, and ``reconnect`` can build the same connection again.
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 _open(self, what: str) -> None:
"""Open :attr:`_device`, and report a failure to open as a failure of selection.
The two device exit statuses answer different questions. ``NO_DEVICE`` says that
MeshTerm transmitted nothing, and that the caller must look at what is plugged in.
``DEVICE`` says that MeshTerm reached the radio and the operation failed, so a retry
is reasonable. Each failure on this path is the first type. The connection does not
exist yet, so MeshTerm sent nothing over it. The failure used to return as ``DEVICE``
with the message "the connection to the device was lost". That message describes a
connection that never existed, and ``--port NOSUCHPORT`` is the most common way to
reach this line.
MeshTerm does not keep a device that failed to open. It used to stay as
:attr:`_device`. Then :attr:`is_connected` said yes, the next :meth:`device` returned
it unopened, and each tool after a failed start failed with "not connected" and not
with the reason. Now the method drops the device. Thus the next caller tries the
connection again and gets the real answer.
Args:
what: The thing that MeshTerm opens, for the message (a port, an address, or a
host).
Raises:
DeviceSelectionError: If MeshTerm cannot establish the connection.
"""
from .core.selection import DeviceSelectionError
try:
await self._device.connect()
except Exception as exc: # noqa: BLE001 - reclassified, then raised again
failed, self._device = self._device, None
try:
await failed.disconnect() # the part of it that did open
except Exception: # noqa: BLE001 - a best-effort action. It never connected
pass
raise DeviceSelectionError(f"could not open {what}: {exc}") from exc
async def device(self) -> Device:
"""Return a connected :class:`Device`, and open the connection at first use.
For real hardware, the method resolves which serial port to use (the explicit
``--port``, the profile, the remembered default, or the only attached device). After
a successful connection, it records the device as the new "last known good" default.
Returns:
The shared, connected device for this run.
Raises:
ValueError: If MeshTerm cannot choose a serial port and the simulator is not on.
DeviceSelectionError: If the discovery is ambiguous (a 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._open("the simulator")
return self._device
# A radio on the SPI bus of the host (``--spi``, an SPI profile, or the remembered
# default) has nothing to find. MeshTerm starts the node that answers on it.
spi = self.resolve_spi()
if spi is not None:
from .core.spiradio import state_dir
self._device = make_device(
mock=False,
port=None,
transport="spi",
spi=spi,
state=state_dir(self.settings.config_dir, spi),
)
self._active_transport = "spi"
self._active_endpoint = spi.spidev
self._active_port = None
self._active_address = None
await self._open(f"SPI radio {spi.spidev}")
await self._settle_connection()
return self._device
# MeshTerm opens a network endpoint directly by host:port (an explicit ``--tcp``, a
# TCP profile, a device picked at startup, or the remembered TCP default). TCP
# companions cannot be discovered, so this remembered or 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._open(f"TCP companion {tcp_host}:{tcp_port}")
await self._settle_connection()
return self._device
# MeshTerm opens a Bluetooth endpoint directly by address (an explicit ``--ble``, a
# BLE profile, a device picked at startup, or the remembered BLE default). There is
# no serial resolution, and no new scan is necessary to reconnect to a known address.
ble_address, ble_pin = self._resolve_ble_endpoint()
if ble_address:
# If a scan produced a live BLEDevice for this address, give it to the
# connection. Then bleak opens it directly and does not discover the address
# again. A new internal scan sometimes misses a companion that advertises slowly.
# A remembered or explicit address has no scan result, and it connects by address
# only. A handle from the reconnect watch wins over the handle of the startup
# picker. The watch scanned it seconds ago, after the drop, so it names the
# peripheral that is on the air now.
selected = self.selected_device
ble_device = self._ble_handle or (
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._open(f"BLE companion {ble_address}")
await self._settle_connection()
return self._device
from .core.spiradio import without_radio_ports
resolution = resolve_device(
# Never the GPS port of the radio itself. On a Cardputer Zero with nothing
# remembered, it was the only port, and MeshTerm connects to an only port without
# a question.
without_radio_ports(discover_devices(), self.settings.profiles),
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._open(f"serial port {resolution.port}")
await self._settle_connection()
return self._device
def _resolve_tcp_endpoint(self) -> tuple[str | None, int | None]:
"""Return the ``(host, port)`` to open over TCP, or ``(None, None)`` for other transports.
The method resolves a network endpoint in this order of priority: an explicit
``--tcp``, a TCP :class:`~meshterm.core.config.DeviceProfile`, then the remembered TCP
default. Thus a network companion is accepted in each place where a serial or
Bluetooth companion is accepted. A malformed endpoint gives ``(None, None)``. Then the
session continues with the other transports, and does not crash on a typing error.
"""
from .core.discovery import parse_tcp_endpoint
from .core.selection import DeviceSelectionError
# An explicit ``--tcp`` or a TCP profile is authoritative. A malformed value is a
# typing error of the user. Thus the method shows the parse error as a clean message
# that the user can act on, and does not silently continue with 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 applies only when the user selected nothing else
# explicitly. A corrupt stored value must never crash the startup. In that case,
# continue with the other transports.
explicit_other = (
bool(self.port_override)
or bool(self.ble_override)
or self.spi_override
or (
self.profile is not None
and (bool(self.profile.port) or bool(self.profile.address) or self.profile.is_spi)
)
)
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 discovered in this session
return host, port
return None, None
def resolve_spi(self) -> SpiWiring | None:
"""The wiring of the SPI radio to open, or ``None`` when the user means another transport.
The order of priority is: an SPI profile, the radio picked on the startup splash,
``--spi`` (the one radio that is attached, refer to :meth:`_spi_for_flag`), then the
remembered default when it was an SPI radio and the user named nothing else. If the
user names anything else explicitly (a port, an address, a host, or another type of
profile), this session is not about the SPI radio.
"""
if self.profile is not None and self.profile.is_spi:
return self.profile.spi or SpiWiring()
chosen = self.selected_device
if chosen is not None and chosen.is_spi:
return chosen.spi or self.spi_wiring_for(chosen.port) # type: ignore[return-value]
if self.spi_override:
return self._spi_for_flag()
if self.port_override or self.ble_override or self.tcp_override or self.profile:
return None
remembered = self.device_store.load()
if remembered is None or remembered.transport != "spi" or not remembered.target:
return None
self.selected_device = None # remembered, not discovered in this session
return self.spi_wiring_for(remembered.target)
def _spi_for_flag(self) -> SpiWiring:
"""The radio that a bare ``--spi`` means: the one that is there, never a guess.
Nearly all users who have a radio on the SPI bus have exactly one. For them,
``--spi`` needs nothing more. The method counts the attached radios first. Thus it
uses a single radio, if a profile names it or not. The flag is ambiguous only when two
radios are attached, or (if none is attached to count) when two SPI profiles are
configured. The flag used to take the profile that ``config.toml`` listed first. That
is a coin toss that transmits. If no radio is attached, and there is one profile or
none, the method makes the choice from the configuration. Then the connect attempt
can say what is missing.
Raises:
DeviceSelectionError: When the user can mean more than one radio.
"""
from .core.selection import DeviceSelectionError
from .core.spiradio import spi_radios
profiles = self.settings.profiles
attached = spi_radios(profiles)
if len(attached) == 1:
return attached[0].spi # type: ignore[return-value]
configured = [p for p in profiles.values() if p.is_spi]
if not attached and len(configured) <= 1:
return (configured[0].spi or SpiWiring()) if configured else SpiWiring()
if attached:
rows = [f" {d.name or '(no profile)':<16} {d.port}" for d in attached]
what = "Several radios are attached to the SPI bus"
else:
rows = [f" {p.name:<16} {(p.spi or SpiWiring()).spidev}" for p in configured]
what = "Several SPI profiles are configured"
raise DeviceSelectionError(
f"{what}, and --spi doesn't say which.\n"
+ "\n".join(rows)
+ "\nChoose one with -p <PROFILE>. A radio with no profile needs one first — see "
"'Adding a radio on the SPI bus' in docs/guide/configuration.md."
)
def spi_wiring_for(self, spidev: str) -> SpiWiring:
"""The wiring for the radio on ``spidev``: of a profile, of a shipped board, or of the AIO.
MeshTerm knows a remembered or listed SPI radio only by its device file. Thus its pins
come from the SPI profile that is on that device file. If there is no such profile,
they come from the board that MeshTerm knows for that device file, when this machine
is that board (the Cap of the Cardputer Zero). If neither applies, the defaults are
the only wiring.
"""
from .core.spiradio import builtin_wiring
for profile in self.settings.profiles.values():
if profile.is_spi and (profile.spi or SpiWiring()).spidev == spidev:
return profile.spi or SpiWiring()
return builtin_wiring(spidev) or SpiWiring()
def _resolve_ble_endpoint(self) -> tuple[str | None, str | None]:
"""Return the ``(address, pin)`` to open over Bluetooth, or ``(None, None)`` for serial.
The method resolves a BLE endpoint in this order of priority: an explicit ``--ble``, a
BLE :class:`~meshterm.core.config.DeviceProfile`, then the remembered BLE default.
Thus a Bluetooth companion is accepted in each place where a serial companion is
accepted. It returns ``(None, None)`` when the session must continue with the 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. This is the same as the priority of the serial resolution.
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 discovered in this session
return remembered.target, self.ble_pin
return None, None
async def _remember_connected(self) -> None:
"""Record the device that just connected as the last known good default.
This is a best-effort action. The method learns the mesh node name of the device. A
failure of the probe must not block a good connection. Only devices that MeshTerm
discovered in this session have a :class:`~meshterm.core.discovery.DiscoveredDevice`
to remember. A bare ``--port``, or a reconnect that is remembered by address, 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 _settle_connection(self) -> None:
"""The steps that each new connection gets, in order, when the link is open.
Remember the device as the default, replay its remembered channels, and give it to
the clock setter that runs at connection. The clock setter returns at once and does
its work in the background. Thus the connection is usable when this method returns.
Each step is a best-effort action on its own. No step can make the connection fail.
"""
await self._remember_connected()
await self._reconcile_channels()
if self._device is not None:
self.clock_sync.on_connected(self._device)
async def _reconcile_channels(self) -> None:
"""Replay the remembered channels that the device does not report.
This is a best-effort action. A radio bridge without firmware loses its channels each
time that it restarts. Thus MeshTerm restores the channels that you added through
MeshTerm into free slots at connection (refer to
:func:`~meshterm.core.channel_store.reconcile`). If MeshTerm remembers nothing for the
device, this is a single probe of the identity. Thus a radio with firmware pays almost
nothing. A failure here must never break the connection. MeshTerm logs it and does
not raise it.
"""
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 - the 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 release_link(self) -> None:
"""Take down the dead connection and the services that use it, and keep what to resume.
This is the first half of :meth:`reconnect`. It is a separate method, because it is
also useful to do it before the search for the device. Over Bluetooth, this order is
necessary. A companion advertises only while nothing is connected to it. If MeshTerm
still holds a link to a peripheral, the peripheral stays off the air. This is true
also when MeshTerm believes that the link is dead. Then the scan that waits for the
peripheral to come back can never find it. If MeshTerm releases the link first, the
peripheral is on the air again. This does no harm on the transports that do not need
it.
The method records what was running one time, and keeps the record across retries.
The method stops the services. If a later attempt read the flags then, they would be
idle, and it would restore nothing. MeshTerm clears the intent only after
:meth:`reconnect` succeeds. The method is idempotent. If you call it again when
nothing is connected, it does nothing.
"""
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,
)
# Release the old subscriptions of the hub and the services, and discard the dead
# device. The subscriptions are in the process (to the hub). Thus they survive the
# link drop, and MeshTerm must take them down explicitly before it opens a new
# connection below 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. This is best-effort
pass
self._device = None
# The facts that MeshTerm cached for the connection that just dropped can be old on
# the new link (a reboot can change the identity or the config). Thus MeshTerm reads
# them again at the next use.
if self._devstate is not None:
self._devstate.reset()
async def reconnect(self, *, ble_device: object | None = None) -> None:
"""Drop a lost device connection and build it again, and restore the live services.
Call this method after MeshTerm detects that the companion link is gone (refer to
:func:`~meshterm.core.connection.is_connection_lost`). The method takes down the dead
connection and the services that use it. Then it opens a new connection to the same
device, and starts again what was running before. Thus passive monitoring and chat
recording resume without a visible break after a replug.
Args:
ble_device: The live ``bleak.BLEDevice`` that the caller just scanned for this
address, if it has one (refer to
:func:`~meshterm.core.discovery.find_ble_device`). In each case, the method
drops any handle that it held before. The peripheral that comes back from a
power cycle is a new peripheral to the OS. Thus the scan result that the
session opened with is old and also wrong. If MeshTerm used it again, the
connect would fail on Windows while the device is there and advertises. If you
pass ``None`` (serial, TCP, or a BLE retry with nothing scanned), the method
reconnects by address only.
Raises:
Exception: If MeshTerm cannot open a new connection (for example, the device is
still absent). The caller can show the error and offer a retry.
"""
self._ble_handle = ble_device
await self.release_link()
resume_events, resume_monitor, resume_chat = self._resume_intent
# Open a new connection. If MeshTerm still cannot reach the device, this raises an
# error and leaves the remembered intent in place for the next attempt. Then start
# again what was running before the drop, and clear the intent, because the link is
# 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
# MeshTerm opened the handle. It now names a link that is live, and not a device to
# find. Thus the next drop starts with a clean state and scans again.
self._ble_handle = None
async def _node_name(self) -> str:
"""Return the mesh node name of the connected device, or ``""`` if it is not available."""
try:
info = await self._device.get_self_info()
except Exception: # noqa: BLE001 - the identity probe is best-effort and never fatal
return ""
return str(info.get("adv_name") or info.get("name") or "")
async def _hardware_model(self) -> str:
"""Return the firmware model string of the connected device, or ``""``.
The value comes from the device-query frame, which is the only place that shows the
model. Thus a reconnect keeps the remembered hardware column current. This is a
best-effort action. If the probe fails, or the firmware is older, the method leaves
any model that MeshTerm remembered before as it is.
"""
try:
info = await self._device.get_device_info()
except Exception: # noqa: BLE001 - the model lookup is best-effort and 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._clock_sync is not None:
await self._clock_sync.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 or lost link must not crash the teardown
pass
self._device = None
self.repo.close()