Files
MeshTerm/tests/test_disconnect.py
T
Jean-Pierre MartineauandClaude Opus 5.5 bdbf65312a The serial link check walks the ports off the event loop
Every two seconds the liveness watcher asks whether the companion's serial
port is still there, and that question is a walk over /sys/class/tty: about
30 ms of small reads on an idle PicoCalc. It ran on the event loop. Each read
hands the GIL back and has to wait its turn to take it again, and while a
worker thread is busy (a map frame being drawn, a tile being decoded) that
turn is up to 5 ms away, hundreds of times over. A freeze watchdog on the
device caught the main thread inside this walk in 20 of the 21 stack samples
taken while the map was stalled, for up to 1.2 s at a time.

It now runs in asyncio.to_thread, as the device picker's and the reconnect
dialog's walks already did; the worker pays the wait and the screen doesn't.

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

2085 lines
83 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for graceful handling of a lost device connection.
Covers the exception classifier that distinguishes a dropped serial link from an ordinary
command failure, and :meth:`AppContext.reconnect`, which rebuilds the connection and restores
the services (event hub, passive monitor, chat) that were running before the drop. All run
against the :class:`MockDevice` simulator; no hardware required.
"""
from __future__ import annotations
import asyncio
import errno
import io
import socket
import sys
from pathlib import Path
from types import SimpleNamespace
import pytest
from rich.console import Console
from meshterm import context as context_module
from meshterm.context import AppContext
from meshterm.core import connection, discovery
from meshterm.core.admin_store import AdminStore
from meshterm.core.config import Settings
from meshterm.core.connection import DeviceCommandError, is_connection_lost
from meshterm.core.device_store import DeviceStore
from meshterm.core.discovery import DiscoveredDevice
from meshterm.persistence.repository import Repository
from meshterm.ui import menu
class _SerialException(Exception):
"""A stand-in for ``serial.SerialException`` (matched by type name, not import)."""
class _FakeSerialDevice:
"""A minimal stand-in for a connected serial :class:`Device` in liveness tests.
Its :meth:`link_present` mirrors the real serial device: it reports whether the port is
still enumerated by the OS, reading through the (monkeypatched) module function so tests
can flip presence on and off.
"""
transport = "serial"
def __init__(self, port: str = "COM_TEST") -> None:
self._port = port
async def link_present(self) -> bool:
return connection.serial_port_present(self._port)
def test_is_connection_lost_matches_serial_exception() -> None:
"""A pyserial-style read/write failure is classified as a dropped link."""
assert is_connection_lost(_SerialException("ClearCommError failed (Access is denied.)"))
def test_is_connection_lost_walks_the_exception_chain() -> None:
"""A link error wrapped in a higher-level error is still detected via its cause."""
try:
try:
raise _SerialException("WriteFile failed")
except Exception as cause:
raise RuntimeError("device write failed") from cause
except Exception as exc:
assert is_connection_lost(exc)
def test_is_connection_lost_matches_os_level_drops() -> None:
"""OS-layer connection teardown and telltale I/O messages count as a lost link."""
assert is_connection_lost(ConnectionResetError("reset"))
assert is_connection_lost(OSError("input/output error"))
def test_is_connection_lost_ignores_ordinary_failures() -> None:
"""A transient command timeout or a generic error is not a dropped link."""
assert not is_connection_lost(DeviceCommandError("the companion didn't respond in time"))
assert not is_connection_lost(ValueError("bad value"))
# These stand-ins are named to match bleak's real classes, since the classifier matches by
# type name (not import) — see ``_CONNECTION_LOST_TYPES``.
class BleakError(Exception): # noqa: N818 - mirrors bleak's own (non-Error-suffixed) name
"""A stand-in for ``bleak.exc.BleakError`` (matched by type name, not import)."""
class BleakDeviceNotFoundError(Exception):
"""A stand-in for ``bleak.exc.BleakDeviceNotFoundError``."""
def test_is_connection_lost_matches_ble_errors() -> None:
"""A dropped Bluetooth link is classified as a lost connection, like a serial unplug."""
assert is_connection_lost(BleakError("gatt operation failed")) # matched by type name
assert is_connection_lost(BleakDeviceNotFoundError("AA:BB:CC not found"))
# The meshcore BLE transport reports link loss via a callback reason string.
assert is_connection_lost(RuntimeError("ble_transport_lost"))
class BleakGATTProtocolError(Exception):
"""Stand-in for bleak's GATT auth rejection (classifier matches its message, not import)."""
def test_ble_auth_error_is_recognized_and_is_not_a_lost_link() -> None:
"""A PIN/pairing rejection is its own actionable case — never mistaken for a dropped link."""
exc = BleakGATTProtocolError("(5, 'GATT Protocol Error: Insufficient Authentication')")
assert connection._is_ble_auth_error(exc)
assert not is_connection_lost(exc) # so the session offers a PIN, not a reconnect
def test_ble_auth_error_walks_the_exception_chain() -> None:
"""An auth rejection wrapped by the meshcore transport is still recognized via its cause."""
try:
try:
raise BleakGATTProtocolError("Insufficient Encryption")
except Exception as cause:
raise RuntimeError("connect failed") from cause
except Exception as exc:
assert connection._is_ble_auth_error(exc)
#: Stand-in Bluetooth addresses. Never a real companion's: a test that names one turns a
#: personal device into repository content, and any hardware coupling here would be a lie —
#: nothing in this file touches a radio.
_BONDED_ADDR = "00:11:22:33:44:55"
_OPEN_ADDR = "AA:BB:CC:DD:EE:FF"
#: A stand-in pairing code, for the same reason. What matters is only that one was supplied.
_A_PIN = "123456"
@pytest.fixture(autouse=True)
def _no_bluez_agent(monkeypatch: pytest.MonkeyPatch) -> None:
"""Keep the connect-time BlueZ agent off the system bus when these run on Linux."""
from contextlib import nullcontext
monkeypatch.setattr(connection.MeshCoreDevice, "_ble_pairing_agent", lambda self: nullcontext())
def test_an_unlikely_error_on_the_subscribe_is_a_pairing_refusal() -> None:
"""ATT 0x0E is how a companion given a PIN refuses a subscribe over an unauthenticated bond.
Seen on a uConsole whose bond with a T1000-E predated its PIN (BlueZ Authenticated=0):
encryption succeeded, the subscribe did not, and the raw GATT error was all anyone saw.
"""
exc = BleakGATTProtocolError(
"(<BleakGATTProtocolErrorCode.UNLIKELY_ERROR: 14>, 'GATT Protocol Error: Unlikely Error')"
)
assert connection._is_ble_auth_error(exc)
class _RetryingBleakClient:
"""A bleak client whose connect drops its first link attempts and then succeeds."""
def __init__(self, drops: int, callback) -> None:
self.drops, self.callback = drops, callback
self.is_connected = False
self.disconnects = 0
async def connect(self) -> None:
for _ in range(self.drops):
self.callback(self) # bleak fires the disconnect callback for each lost attempt
self.is_connected = True
async def disconnect(self) -> None:
self.disconnects += 1
self.is_connected = False
class _MeshcoreLikeConnection:
"""Mirrors meshcore's BLEConnection: a disconnect resets ``client`` to what was passed."""
def __init__(self, drops: int) -> None:
self.client = None
self.drops = drops
self.forwarded = 0
def handle_disconnect(self, client) -> None:
self.forwarded += 1
self.client = None # the reset that stranded the live link
async def connect(self):
self.client = _RetryingBleakClient(self.drops, self.handle_disconnect)
await self.client.connect()
if self.client is None: # meshcore's start_notify on None, as "not established"
return None
return "address"
async def test_a_retried_link_attempt_no_longer_strands_the_client() -> None:
"""The retries inside bleak must not make meshcore drop the client that then connects.
The uConsole failure, three runs out of three: each lost link attempt fired meshcore's
disconnect handler, which set ``client`` to ``None``; bleak's next retry connected a
client nobody held, meshcore gave up, and the live link kept the companion from
advertising to the retry.
"""
unguarded = _MeshcoreLikeConnection(drops=3)
assert await unguarded.connect() is None # the bug, reproduced
guarded = _MeshcoreLikeConnection(drops=3)
seen = connection._hold_disconnects_while_connecting(guarded)
assert await guarded.connect() == "address"
seen.connecting = False
assert guarded.forwarded == 0 # held while connecting...
assert connection._held_client(guarded, seen) is guarded.client
guarded.client.callback(guarded.client) # ...and a real drop afterwards still arrives
assert guarded.forwarded == 1
def test_the_held_client_falls_back_to_the_one_meshcore_lost() -> None:
"""If meshcore let go anyway, teardown still gets the live client to close."""
lost = _RetryingBleakClient(0, None)
lost.is_connected = True
seen = connection._ConnectingClients([lost])
assert connection._held_client(SimpleNamespace(client=None), seen) is lost
async def _disable_windows_pairing(dev: connection.MeshCoreDevice) -> None:
"""Stub out the WinRT ProvidePin step so ``_create_ble`` stays hermetic in tests.
On a real Windows or Linux host ``_pair_ble`` would reach the OS Bluetooth stack (and the
physical device); pinning it to a no-op reproduces the non-Windows / no-winrt path so the
auth-translation logic can be exercised without hardware.
"""
async def _never_pairs(*, force: bool) -> bool:
return False
async def _no_bond() -> bool:
return False
dev._pair_ble = _never_pairs # type: ignore[method-assign]
dev._ble_os_bonded = _no_bond # type: ignore[method-assign]
def _fake_ble_stack(monkeypatch: pytest.MonkeyPatch, *outcomes):
"""A stand-in ``MeshCore`` class, scripted one entry per connect attempt.
MeshTerm builds the meshcore client itself (:meth:`~MeshCoreDevice._connect_owned_ble`)
rather than letting ``create_ble`` construct and then orphan it, so the fake here is a
*class* that gets instantiated per attempt — and each instance records whether it was
closed, which is the property that matters: a connect that fails must never leave its
link open, or the peripheral goes on believing it has a peer and stops advertising.
Args:
monkeypatch: Used to stub the lazily-imported ``meshcore`` module, so no real
``BLEConnection`` (and therefore no ``bleak``) is needed.
outcomes: What each successive ``connect()`` does — an exception instance to raise,
or a value to return (``None`` standing for an unanswered handshake).
Returns:
The fake class; its ``built`` list holds the instances it made, in order.
"""
import sys
import types
class _FakeBLEConnection:
def __init__(self, address=None, device=None, pin=None) -> None:
self.address, self.device, self.pin = address, device, pin
class _FakeMeshCore:
built: list = []
script: list = list(outcomes)
def __init__(self, cx, **kwargs) -> None:
self.cx = cx
self.kwargs = kwargs
self.disconnect_calls = 0
type(self).built.append(self)
async def connect(self):
outcome = type(self).script.pop(0)
if isinstance(outcome, BaseException):
raise outcome
return outcome
async def disconnect(self) -> None:
self.disconnect_calls += 1
module = types.ModuleType("meshcore")
module.BLEConnection = _FakeBLEConnection
module.MeshCore = _FakeMeshCore
monkeypatch.setitem(sys.modules, "meshcore", module)
return _FakeMeshCore
async def test_create_ble_translates_auth_error_to_pin_guidance(monkeypatch) -> None:
"""A raw GATT auth rejection becomes a DeviceAuthenticationError that names the PIN fix."""
# Pinned off macOS: there the same rejection starts an OS-run pairing and is retried
# rather than reported, and the advice is the system dialog rather than --ble-pin.
# Without this the test would assert Windows/Linux wording on the macOS CI runner.
monkeypatch.setattr(sys, "platform", "win32")
# No PIN supplied -> tell the user to pass one. The subclass lets the interactive picker
# catch "needs a PIN" specifically, while the CLI still catches it as DeviceCommandError.
fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication"))
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
with pytest.raises(connection.DeviceAuthenticationError) as excinfo:
await dev._create_ble(fake)
assert isinstance(excinfo.value, DeviceCommandError) # so the scripted CLI catches it too
assert "--ble-pin" in str(excinfo.value)
assert fake.built[0].disconnect_calls == 1 # the doomed link was released, not stranded
# PIN supplied but rejected -> say it was wrong, not that none was given.
fake_pin = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication"))
dev_pin = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR, pin=_A_PIN)
await _disable_windows_pairing(dev_pin)
with pytest.raises(connection.DeviceAuthenticationError) as excinfo_pin:
await dev_pin._create_ble(fake_pin)
assert "rejected" in str(excinfo_pin.value).lower()
async def test_create_ble_names_a_stale_windows_bond(monkeypatch) -> None:
"""No PIN, but Windows holds a bond the device refuses: say the bond is stale.
The case reported from the field — a companion Windows showed as paired, refusing the
subscribe after the device's side of the bond was lost. "Requires a PIN" was the wrong
story for someone looking at "Paired" in Settings.
"""
monkeypatch.setattr(sys, "platform", "win32")
fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication"))
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
async def _bonded() -> bool:
return True
dev._ble_os_bonded = _bonded # type: ignore[method-assign]
with pytest.raises(connection.DeviceAuthenticationError) as excinfo:
await dev._create_ble(fake)
assert "Windows has" in str(excinfo.value)
assert "--ble-pin" in str(excinfo.value) # the PIN is still how MeshTerm re-pairs it
assert "saved pairing" in excinfo.value.hint # and the PIN dialog says why it is asking
@pytest.mark.parametrize(
("pairing", "expected", "hint"),
[
(connection._BlePairing("failed", "authentication failure"), "rejected", "PIN"),
(connection._BlePairing("failed", "connection rejected"), "refused to pair", "refused"),
(connection._BlePairing("failed", "hardware failure"), "couldn't pair", "hardware"),
(connection._BlePairing("absent"), "couldn't reach", "reach"),
(connection._BlePairing("paired"), "still refuses", "still refused"),
(None, "rejected the Bluetooth PIN", "PIN was rejected"),
],
)
async def test_create_ble_names_what_the_pairing_found(
monkeypatch, pairing, expected: str, hint: str
) -> None:
"""With a PIN, the refusal names the pairing's own outcome, each with its own fix."""
monkeypatch.setattr(sys, "platform", "win32")
fake = _fake_ble_stack(
monkeypatch,
BleakGATTProtocolError("Insufficient Authentication"),
BleakGATTProtocolError("Insufficient Authentication"),
)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR, pin=_A_PIN)
async def _pair(*, force: bool) -> bool:
dev._ble_pairing = pairing
return pairing is not None and pairing.outcome == "paired"
dev._pair_ble = _pair # type: ignore[method-assign]
with pytest.raises(connection.DeviceAuthenticationError) as excinfo:
await dev._create_ble(fake)
assert expected in str(excinfo.value)
assert hint in excinfo.value.hint
def test_a_refused_pairing_step_is_an_auth_error() -> None:
"""A failing pair() in bleak is a pairing problem, not a device that "didn't answer"."""
assert connection._is_ble_auth_error(Exception("Could not pair with device: 19: FAILED"))
assert connection._is_ble_auth_error(Exception("org.bluez.Error.AuthenticationFailed"))
@pytest.mark.parametrize(
("stage", "expected"), [("connect", "took longer"), ("identity", "identity")]
)
async def test_ble_probe_timeout_names_its_stage(monkeypatch, stage: str, expected: str) -> None:
"""A BLE probe that runs out of time says which stage stalled, instead of returning None."""
dev = connection.MeshCoreDevice(transport="ble", address=_OPEN_ADDR)
async def _connect() -> None:
if stage == "connect":
raise asyncio.TimeoutError
async def _self_info() -> dict:
raise asyncio.TimeoutError
dev.connect = _connect # type: ignore[method-assign]
dev.get_self_info = _self_info # type: ignore[method-assign]
with pytest.raises(DeviceCommandError) as excinfo:
await connection._probe(dev, 1.0)
assert expected in str(excinfo.value)
async def test_create_ble_repairs_stale_bond_and_retries_once(monkeypatch) -> None:
"""A first auth failure triggers one unpair-and-re-pair, then the retried connect succeeds.
Models the Windows upgrade case: a leftover unauthenticated "Just Works" bond makes the
first connect fail even with the right PIN, so ``_pair_ble(force=True)`` clears it
and the second connect goes through.
"""
fake = _fake_ble_stack(
monkeypatch, BleakGATTProtocolError("Insufficient Authentication"), "handshake-ok"
)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR, pin=_A_PIN)
repairs: list[bool] = []
async def _pair(*, force: bool) -> bool:
repairs.append(force)
return force # the pre-connect pass (force=False) no-ops; the repair (force=True) works
dev._pair_ble = _pair # type: ignore[method-assign]
result = await dev._create_ble(fake)
assert result is fake.built[1] # the retry's client is what the caller gets
assert len(fake.built) == 2 # failed once, retried once
assert repairs == [False, True] # pre-connect attempt, then the healing re-pair
assert fake.built[0].disconnect_calls == 1 # ...and the failed one was closed first
assert fake.built[1].disconnect_calls == 0 # the live one is handed over open
async def test_create_ble_gives_up_after_one_repair(monkeypatch) -> None:
"""A wrong PIN that never bonds fails cleanly rather than looping on the repair retry."""
fake = _fake_ble_stack(
monkeypatch,
BleakGATTProtocolError("Insufficient Authentication"),
BleakGATTProtocolError("Insufficient Authentication"),
)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR, pin=_A_PIN)
calls: list[bool] = []
async def _pair(*, force: bool) -> bool:
calls.append(force)
return force # even the repair "succeeds" so we prove the retry runs exactly once
dev._pair_ble = _pair # type: ignore[method-assign]
with pytest.raises(connection.DeviceAuthenticationError):
await dev._create_ble(fake)
# force=False (pre-connect), then force=True (repair). The repair's retry passes
# allow_repair=False, so there is no third pairing attempt even though it keeps failing.
assert calls == [False, True]
assert all(client.disconnect_calls == 1 for client in fake.built)
async def test_create_ble_retries_a_transient_link_failure(monkeypatch) -> None:
"""A transport-level ConnectionError is retried once, and the scanned BLEDevice rides along.
Models the common Windows flake: the first link open misses the (slow-advertising)
peripheral and the meshcore client raises a bare ``ConnectionError``; users learned to
work around it by re-selecting the device — a manual retry — so the connect retries
itself before surfacing the failure.
"""
scanned_device = object() # the BLEDevice the discovery scan produced
fake = _fake_ble_stack(
monkeypatch, ConnectionError("Failed to connect to device"), "handshake-ok"
)
monkeypatch.setattr(connection, "_BLE_CONNECT_RETRY_DELAY_S", 0.0)
dev = connection.MeshCoreDevice(transport="ble", address=_OPEN_ADDR, ble_device=scanned_device)
await _disable_windows_pairing(dev)
assert await dev._create_ble(fake) is fake.built[1]
assert len(fake.built) == 2 # failed once, retried once
# Every attempt connects through the already-discovered BLEDevice, never a bare address.
assert all(client.cx.device is scanned_device for client in fake.built)
async def test_create_ble_gives_up_after_the_retry(monkeypatch) -> None:
"""A link that never opens says so after the bounded retries — not "not a companion"."""
fake = _fake_ble_stack(
monkeypatch,
ConnectionError("Failed to connect to device"),
ConnectionError("Failed to connect to device"),
)
monkeypatch.setattr(connection, "_BLE_CONNECT_RETRY_DELAY_S", 0.0)
dev = connection.MeshCoreDevice(transport="ble", address=_OPEN_ADDR)
await _disable_windows_pairing(dev)
with pytest.raises(DeviceCommandError) as excinfo:
await dev._create_ble(fake)
assert not isinstance(excinfo.value, connection.DeviceAuthenticationError)
assert "couldn't open a Bluetooth link" in str(excinfo.value)
assert isinstance(excinfo.value.__cause__, ConnectionError) # the original rides along
assert len(fake.built) == connection._BLE_CONNECT_ATTEMPTS
assert all(client.disconnect_calls == 1 for client in fake.built)
async def test_create_ble_never_retries_an_auth_rejection(monkeypatch) -> None:
"""A PIN/bond rejection is translated on the first attempt — never looped by the retry."""
# Off macOS, where a rejection is the *start* of an OS-run pairing and is waited on.
monkeypatch.setattr(sys, "platform", "win32")
fake = _fake_ble_stack(monkeypatch, BleakGATTProtocolError("Insufficient Authentication"))
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
with pytest.raises(connection.DeviceAuthenticationError):
await dev._create_ble(fake)
assert len(fake.built) == 1
async def test_create_ble_waits_for_macos_to_finish_pairing(monkeypatch) -> None:
"""On macOS the first auth rejection is the pairing *starting*, so the connect waits.
Touching the companion's authenticated characteristic is the only way to make
CoreBluetooth pair at all, so the rejection and the Passkey dialog are the same event.
Giving up there reported an error for a pairing that was succeeding, and the device
connected only when the user selected it a second time.
"""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(connection, "_BLE_MACOS_PAIRING_DELAY_S", 0)
# Refused twice while the dialog is up, then the bond lands and the subscribe works.
fake = _fake_ble_stack(
monkeypatch,
BleakGATTProtocolError("Insufficient Authentication"),
BleakGATTProtocolError("Insufficient Authentication"),
"handshake-ok",
)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
result = await dev._create_ble(fake)
assert result is fake.built[2] # the attempt made after the bond is what the caller gets
assert len(fake.built) == 3
assert fake.built[0].disconnect_calls == 1 # each refused link was released, not stranded
assert fake.built[1].disconnect_calls == 1
assert fake.built[2].disconnect_calls == 0 # the live one is handed over open
async def test_create_ble_gives_up_when_macos_pairing_is_dismissed(monkeypatch) -> None:
"""A dialog nobody answers still fails in the end, and says where the code is asked for."""
monkeypatch.setattr(sys, "platform", "darwin")
monkeypatch.setattr(connection, "_BLE_MACOS_PAIRING_DELAY_S", 0)
refusals = [BleakGATTProtocolError("Insufficient Authentication")] * (
connection._BLE_MACOS_PAIRING_ATTEMPTS + 1
)
fake = _fake_ble_stack(monkeypatch, *refusals)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
await _disable_windows_pairing(dev)
with pytest.raises(connection.DeviceAuthenticationError) as excinfo:
await dev._create_ble(fake)
assert len(fake.built) == connection._BLE_MACOS_PAIRING_ATTEMPTS + 1 # bounded, not endless
# --ble-pin cannot help on macOS: the OS collects the code, so don't send the reader there.
assert "--ble-pin" not in str(excinfo.value)
assert "System Settings" in str(excinfo.value)
async def test_connect_reports_an_unanswered_handshake_and_closes_it(monkeypatch) -> None:
"""Transport up but no identity reply: a clean "not a companion", link still released."""
fake = _fake_ble_stack(monkeypatch, None)
dev = connection.MeshCoreDevice(transport="ble", address=_OPEN_ADDR)
await _disable_windows_pairing(dev)
with pytest.raises(DeviceCommandError):
await dev.connect()
assert fake.built[0].disconnect_calls == 1
async def test_pair_ble_windows_noops_without_pin() -> None:
"""Pairing is skipped (no WinRT touched) when no PIN is set — the fast, hermetic path."""
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
assert await dev._pair_ble_windows(force=False) is False
@pytest.mark.parametrize(
("platform", "ceremony"),
[("win32", "_pair_ble_windows"), ("linux", "_pair_ble_bluez"), ("darwin", None)],
)
async def test_pair_ble_runs_this_platform_s_ceremony(monkeypatch, platform, ceremony) -> None:
"""Windows pairs through WinRT, Linux through a BlueZ agent, macOS not at all."""
monkeypatch.setattr(sys, "platform", platform)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR, pin=_A_PIN)
ran: list[str] = []
for name in ("_pair_ble_windows", "_pair_ble_bluez"):
async def _ceremony(*, force: bool, name=name) -> bool:
ran.append(name)
return True
setattr(dev, name, _ceremony)
assert await dev._pair_ble(force=False) is (ceremony is not None)
assert ran == ([ceremony] if ceremony else [])
async def test_linux_pairing_records_what_bluez_found(monkeypatch) -> None:
"""The BlueZ outcome lands in ``_ble_pairing``, where the refusal message reads it."""
from meshterm.core import bluez
async def _pair(address, pin, *, force):
return "failed", "authentication failed"
monkeypatch.setattr(bluez, "pair", _pair)
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR, pin=_A_PIN)
assert await dev._pair_ble_bluez(force=False) is False
assert dev._ble_pairing == connection._BlePairing("failed", "authentication failed")
@pytest.mark.parametrize(
("pairing", "expected"),
[
(connection._BlePairing("failed", "authentication failed"), "rejected the Bluetooth PIN"),
(connection._BlePairing("failed", "connection attempt failed"), "refused to pair"),
(connection._BlePairing("failed", "failed: le-connection-abort"), "Linux couldn't pair"),
(connection._BlePairing("absent"), "Linux couldn't reach"),
(connection._BlePairing("paired"), "bluetoothctl remove"),
],
)
def test_linux_refusals_speak_of_linux_and_bluetoothctl(monkeypatch, pairing, expected) -> None:
"""On Linux the remedies name Linux and ``bluetoothctl``, never Windows' Settings."""
monkeypatch.setattr(sys, "platform", "linux")
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR, pin=_A_PIN)
dev._ble_pairing = pairing
message, _hint = dev._ble_auth_diagnosis(_BONDED_ADDR, False)
assert expected in message
assert "Windows" not in message and "Settings" not in message
def test_a_stale_linux_bond_is_named_as_such(monkeypatch) -> None:
"""No PIN, BlueZ holds a bond, the device refuses it: stale, with the Linux way out."""
monkeypatch.setattr(sys, "platform", "linux")
dev = connection.MeshCoreDevice(transport="ble", address=_BONDED_ADDR)
message, hint = dev._ble_auth_diagnosis(_BONDED_ADDR, True)
assert message.startswith("Linux has") and "bluetoothctl remove" in message
assert "Linux's saved pairing" in hint
# And with no bond, the PIN request carries no Windows-only advice.
message, _ = dev._ble_auth_diagnosis(_BONDED_ADDR, False)
assert "--ble-pin" in message and "Windows" not in message
class SerialException(OSError):
"""Stand-in for pyserial's exception (an ``OSError`` whose ``errno`` is usually unset)."""
@pytest.mark.parametrize(
("platform", "exc", "expected"),
[
(
"linux",
SerialException(
"[Errno 13] could not open port /dev/ttyACM0: [Errno 13] "
"Permission denied: '/dev/ttyACM0'"
),
"dialout",
),
(
"linux",
SerialException(
"Could not exclusively lock port /dev/ttyACM0: [Errno 11] "
"Resource temporarily unavailable"
),
"ModemManager",
),
("linux", SerialException("[Errno 16] Device or resource busy"), "in use"),
("linux", SerialException("[Errno 2] No such file or directory"), "isn't there"),
(
"win32",
SerialException(
"could not open port 'COM5': PermissionError(13, 'Access is denied.', None, 5)"
),
"in use by another program",
),
(
"win32",
SerialException(
"could not open port 'COM9': FileNotFoundError(2, 'The system "
"cannot find the file specified.', None, 2)"
),
"isn't there",
),
],
)
def test_serial_open_failures_are_named(monkeypatch, platform, exc, expected) -> None:
"""A port that won't open says why, instead of "didn't answer as a MeshCore device"."""
monkeypatch.setattr(sys, "platform", platform)
message = connection._serial_open_message("PORT", exc)
assert message is not None and expected in message
if platform == "win32":
assert "dialout" not in message # Windows' "Access is denied" is a held port
def test_an_unrecognised_serial_failure_passes_through() -> None:
"""Anything that isn't an open failure keeps its own error."""
assert connection._serial_open_message("PORT", ValueError("bad baud rate")) is None
async def test_serial_connect_raises_the_named_failure(monkeypatch) -> None:
"""``connect`` turns a refused open into a DeviceCommandError the picker shows verbatim."""
import types
class _MeshCore:
@staticmethod
async def create_serial(*_args, **_kwargs):
raise SerialException("[Errno 13] could not open port: Permission denied")
module = types.ModuleType("meshcore")
module.MeshCore = _MeshCore
monkeypatch.setitem(sys.modules, "meshcore", module)
monkeypatch.setattr(sys, "platform", "linux")
dev = connection.MeshCoreDevice(port="/dev/ttyACM0")
with pytest.raises(DeviceCommandError) as excinfo:
await dev.connect()
assert "dialout" in str(excinfo.value)
assert isinstance(excinfo.value.__cause__, SerialException)
def _fake_meshcore(monkeypatch, **factories) -> None: # noqa: ANN003
"""Stand a ``meshcore`` module in whose ``create_*`` factories are the ones given."""
import types
module = types.ModuleType("meshcore")
module.MeshCore = type("MeshCore", (), {k: staticmethod(v) for k, v in factories.items()})
monkeypatch.setitem(sys.modules, "meshcore", module)
@pytest.mark.parametrize(
("exc", "expected"),
[
(socket.gaierror(11001, "getaddrinfo failed"), "couldn't find a host named meshbox"),
(ConnectionRefusedError(111, "Connection refused"), "nothing is accepting connections"),
(ConnectionResetError(104, "Connection reset by peer"), "one client at a time"),
(OSError(errno.EHOSTUNREACH, "No route to host"), "no route to meshbox"),
(OSError(10065, "A socket operation was attempted to an unreachable host"), "no route"),
(TimeoutError(110, "Connection timed out"), "no answer from meshbox on port 5000"),
(asyncio.TimeoutError(), "no answer from meshbox"),
],
)
def test_tcp_open_failures_are_named(exc: BaseException, expected: str) -> None:
"""Each way a socket refuses has its own sentence, not "no response from a companion"."""
message = connection._tcp_open_message("meshbox", 5000, exc)
assert message is not None and expected in message
def test_an_unrecognised_tcp_failure_is_left_to_the_caller() -> None:
"""A failure that isn't one of the socket's own is not given a name it hasn't earned."""
assert connection._tcp_open_message("meshbox", 5000, ValueError("odd")) is None
async def test_tcp_connect_raises_the_named_failure(monkeypatch) -> None:
"""``connect`` says a refused port is a refused port, keeping the socket error as cause."""
async def create_tcp(*_args, **_kwargs):
raise ConnectionRefusedError(111, "Connection refused")
_fake_meshcore(monkeypatch, create_tcp=create_tcp)
dev = connection.MeshCoreDevice(transport="tcp", host="10.0.0.9", tcp_port=5000)
with pytest.raises(DeviceCommandError) as excinfo:
await dev.connect()
assert "port 5000 at 10.0.0.9" in str(excinfo.value)
assert isinstance(excinfo.value.__cause__, ConnectionRefusedError)
async def test_a_tcp_companion_that_never_answers_is_told_from_one_never_reached(
monkeypatch,
) -> None:
"""A socket that opened onto silence names the listener, not the address or the network."""
async def create_tcp(*_args, **_kwargs):
return None # meshcore's answer when the handshake goes unanswered
_fake_meshcore(monkeypatch, create_tcp=create_tcp)
dev = connection.MeshCoreDevice(transport="tcp", host="10.0.0.9", tcp_port=5000)
with pytest.raises(DeviceCommandError) as excinfo:
await dev.connect()
assert "opened, but nothing answered" in str(excinfo.value)
async def test_an_unrecognised_connect_failure_is_said_in_its_own_words(
monkeypatch, caplog
) -> None:
"""A failure with no name is still a sentence, and its traceback is logged — once."""
monkeypatch.setattr(connection, "_TRACED", set())
async def create_serial(*_args, **_kwargs):
raise ValueError("unsupported baud rate")
_fake_meshcore(monkeypatch, create_serial=create_serial)
dev = connection.MeshCoreDevice(port="/dev/ttyACM0")
caplog.set_level("WARNING", logger="meshterm.core.connection")
for _ in range(2): # a reconnect retrying against the same fault
with pytest.raises(connection.UnrecognisedConnectError) as excinfo:
await dev.connect()
assert str(excinfo.value) == (
"couldn't connect to the device on /dev/ttyACM0: unsupported baud rate"
)
assert isinstance(excinfo.value.__cause__, ValueError)
records = [r for r in caplog.records if "unsupported baud rate" in r.getMessage()]
assert len(records) == 2
assert records[0].exc_info is not None # the traceback, the first time
assert records[1].exc_info is None # and only the line after that
async def test_a_ble_connect_timeout_is_named_as_a_stall(monkeypatch) -> None:
"""A Bluetooth stack's own timeout says what stalls, rather than a bare ``TimeoutError``."""
dev = connection.MeshCoreDevice(transport="ble", address=_OPEN_ADDR)
async def _create_ble(_mesh_core):
raise TimeoutError
dev._create_ble = _create_ble # type: ignore[method-assign]
_fake_meshcore(monkeypatch)
with pytest.raises(DeviceCommandError) as excinfo:
await dev.connect()
assert "timed out" in str(excinfo.value) and "Move closer" in str(excinfo.value)
async def test_a_tcp_probe_that_runs_out_of_time_says_so() -> None:
"""The probe's own window runs out before the OS gives up on a silent host: named too."""
dev = connection.MeshCoreDevice(transport="tcp", host="10.0.0.9", tcp_port=5000)
async def _connect() -> None:
raise asyncio.TimeoutError
dev.connect = _connect # type: ignore[method-assign]
with pytest.raises(DeviceCommandError) as excinfo:
await connection._probe(dev, 1.0)
assert "no answer from 10.0.0.9 on port 5000" in str(excinfo.value)
async def test_the_probe_names_a_failure_it_does_not_recognise(monkeypatch) -> None:
"""An odd failure is said in its own words, not called "not a MeshCore device"."""
monkeypatch.setattr(connection, "_TRACED", set())
dev = connection.MeshCoreDevice(port="COM5")
async def _connect() -> None:
pass
async def _self_info() -> dict:
raise RuntimeError("frame decode failed")
dev.connect = _connect # type: ignore[method-assign]
dev.get_self_info = _self_info # type: ignore[method-assign]
with pytest.raises(connection.UnrecognisedConnectError) as excinfo:
await connection._probe(dev, 1.0)
assert str(excinfo.value) == (
"connected to COM5, but reading its identity failed: frame decode failed"
)
def test_ble_address_int_parses_macs_and_rejects_others() -> None:
"""A colon/dash MAC becomes a 48-bit int; a non-MAC (e.g. a macOS UUID) yields None."""
parse = connection.MeshCoreDevice._ble_address_int
assert parse(_BONDED_ADDR) == 0x001122334455
assert parse("da-c5-21-6b-7c-7c") == 0xDAC5216B7C7C
assert parse("not-a-mac") is None
assert parse("550e8400-e29b-41d4-a716-446655440000") is None # CoreBluetooth UUID
async def test_ble_pairing_helpers_noop_for_non_mac_address() -> None:
"""The bond query and unpair short-circuit (never touching WinRT) for a non-MAC address."""
# A non-MAC address fails the parse before any winrt import, so these stay hermetic on any
# platform — no real Bluetooth stack is consulted.
assert await connection.MeshCoreDevice.is_ble_paired("not-a-mac") is False
assert await connection.MeshCoreDevice.unpair_ble("not-a-mac") is False
assert await connection.MeshCoreDevice.is_ble_paired("") is False
async def test_can_unpair_only_for_bonded_ble(monkeypatch: pytest.MonkeyPatch) -> None:
"""The quit dialog offers unpair only on a BLE link that Windows actually holds a bond for."""
async def _is_paired(address: str) -> bool:
return address == _BONDED_ADDR
monkeypatch.setattr(connection.MeshCoreDevice, "is_ble_paired", staticmethod(_is_paired))
# Serial: never offered, whatever the address.
serial_ctx = SimpleNamespace(active_transport="serial", active_address=None)
assert await menu._can_unpair(serial_ctx) is False
# BLE but no address to act on: not offered.
ble_no_addr = SimpleNamespace(active_transport="ble", active_address=None)
assert await menu._can_unpair(ble_no_addr) is False
# BLE with a live bond: offered.
bonded = SimpleNamespace(active_transport="ble", active_address=_BONDED_ADDR)
assert await menu._can_unpair(bonded) is True
# BLE but open (no bond, e.g. the PIN-less companion): not offered.
open_ble = SimpleNamespace(active_transport="ble", active_address=_OPEN_ADDR)
assert await menu._can_unpair(open_ble) is False
async def test_unpair_on_exit_disconnects_before_unpairing(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Teardown drops the live link first, then forgets the OS bond (order matters)."""
order: list[str] = []
class _Dev:
async def disconnect(self) -> None:
order.append("disconnect")
async def _unpair(address: str) -> bool:
order.append(f"unpair:{address}")
return True
monkeypatch.setattr(connection.MeshCoreDevice, "unpair_ble", staticmethod(_unpair))
ctx = SimpleNamespace(active_address=_BONDED_ADDR, _device=_Dev())
await menu._unpair_on_exit(ctx)
# Disconnect precedes unpair (a bond can't be dropped while in use), and the device handle
# is released. The device_store is never touched — the remembered record survives.
assert order == ["disconnect", f"unpair:{_BONDED_ADDR}"]
assert ctx._device is None
def _make_ctx(tmp_path: Path) -> AppContext:
"""Build a real, mock-backed application context for reconnect tests."""
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "disc.db")
return AppContext(
console=Console(file=io.StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
mock=True,
)
async def test_reconnect_rebuilds_device_and_restores_services(tmp_path: Path) -> None:
"""Reconnect opens a fresh connection and resumes the hub, monitor, and chat."""
ctx = _make_ctx(tmp_path)
try:
await ctx.events.start()
await ctx.monitor.start() # start recording on the already-running hub
await ctx.chat.start()
original = await ctx.device()
assert ctx.events.active and ctx.monitor.active and ctx.chat.active
await ctx.reconnect()
# A brand-new connection replaced the dead one, and everything that was running
# before the drop is running again.
rebuilt = await ctx.device()
assert rebuilt is not original
assert ctx.events.active
assert ctx.monitor.active
assert ctx.chat.active
finally:
await ctx.aclose()
async def test_ble_profile_opens_bluetooth_transport(tmp_path: Path, monkeypatch) -> None:
"""A BLE profile makes ``device()`` build a Bluetooth connection by address."""
from meshterm.core import connection as conn
from meshterm.core.config import DeviceProfile
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "ble.db")
ctx = AppContext(
console=Console(file=io.StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
profile=DeviceProfile(name="handheld", transport="ble", address="AA:BB:CC:DD:EE:FF"),
)
built: dict = {}
class _FakeBle:
transport = "ble"
def __init__(self, **kw):
built.update(kw)
self._port = None
self._address = kw.get("address")
async def connect(self):
pass
async def get_self_info(self):
return {"name": "Handheld"}
def fake_make_device(**kw):
assert kw["transport"] == "ble"
return _FakeBle(**kw)
monkeypatch.setattr(conn, "make_device", fake_make_device)
# context imported make_device by name, so patch the reference it actually calls.
import meshterm.context as context_mod
monkeypatch.setattr(context_mod, "make_device", fake_make_device)
try:
device = await ctx.device()
assert device.transport == "ble"
assert built["address"] == "AA:BB:CC:DD:EE:FF"
assert ctx.active_transport == "ble"
assert ctx.active_port is None # BLE has no serial port to watch
finally:
ctx.repo.close()
async def test_tcp_profile_opens_network_transport(tmp_path: Path, monkeypatch) -> None:
"""A TCP profile makes ``device()`` build a network connection by host:port."""
from meshterm.core import connection as conn
from meshterm.core.config import DeviceProfile
settings = Settings(config_dir=tmp_path, db_path=tmp_path / "tcp.db")
ctx = AppContext(
console=Console(file=io.StringIO()),
settings=settings,
repo=Repository(settings.db_path),
device_store=DeviceStore(tmp_path / "devices.json"),
admin_store=AdminStore(tmp_path / "admin.json"),
profile=DeviceProfile(name="wifi", transport="tcp", host="192.168.1.50", tcp_port=5000),
)
built: dict = {}
class _FakeTcp:
transport = "tcp"
def __init__(self, **kw):
built.update(kw)
self._port = None
self._address = None
self.endpoint = f"{kw.get('host')}:{kw.get('tcp_port')}"
async def connect(self):
pass
async def get_self_info(self):
return {"name": "WifiNode"}
def fake_make_device(**kw):
assert kw["transport"] == "tcp"
return _FakeTcp(**kw)
monkeypatch.setattr(conn, "make_device", fake_make_device)
import meshterm.context as context_mod
monkeypatch.setattr(context_mod, "make_device", fake_make_device)
try:
device = await ctx.device()
assert device.transport == "tcp"
assert built["host"] == "192.168.1.50" and built["tcp_port"] == 5000
assert ctx.active_transport == "tcp"
assert ctx.active_endpoint == "192.168.1.50:5000"
assert ctx.active_port is None and ctx.active_address is None
finally:
ctx.repo.close()
async def test_reconnect_leaves_idle_services_idle(tmp_path: Path) -> None:
"""Reconnect only restores what was live: idle services stay idle afterward."""
ctx = _make_ctx(tmp_path)
try:
original = await ctx.device() # a tool opened the radio lazily; nothing subscribed
await ctx.reconnect()
rebuilt = await ctx.device()
assert rebuilt is not original
assert not ctx.events.active
assert not ctx.monitor.active
assert not ctx.chat.active
finally:
await ctx.aclose()
async def test_reconnect_restores_services_after_failed_attempts(
tmp_path: Path, monkeypatch
) -> None:
"""Resume intent survives retries: services restore even if early reconnects fail.
Reproduces the real-hardware bug where the first (failed) reconnect attempt tore the
services down, so later attempts read the now-idle flags and restored nothing. The
mock never fails ``device()``, so we force the first two ``device()`` calls to raise —
as an absent port does — before letting the third (and the restore calls) succeed.
"""
ctx = _make_ctx(tmp_path)
try:
await ctx.events.start()
await ctx.monitor.start()
await ctx.chat.start()
await ctx.device()
assert ctx.events.active and ctx.monitor.active and ctx.chat.active
real_device = AppContext.device
attempts = {"n": 0}
async def flaky_device(self: AppContext):
attempts["n"] += 1
if attempts["n"] <= 2: # the device isn't back yet on the first two tries
raise _SerialException("could not open port 'COM_TEST'")
return await real_device(self)
monkeypatch.setattr(AppContext, "device", flaky_device)
# Two failed reconnects (device still absent), then a success — like a slow replug.
for _ in range(2):
try:
await ctx.reconnect()
except _SerialException:
pass
await ctx.reconnect() # the third device() call succeeds; services restore
assert ctx.events.active
assert ctx.monitor.active
assert ctx.chat.active
finally:
monkeypatch.undo()
await ctx.aclose()
def test_serial_port_present_reflects_os_enumeration(monkeypatch) -> None:
"""A port is 'present' iff it appears in the OS enumeration; the primary unplug signal."""
pytest.importorskip("serial")
from serial.tools import list_ports
monkeypatch.setattr(list_ports, "comports", lambda: [SimpleNamespace(device="COM11")])
assert connection.serial_port_present("COM11")
assert not connection.serial_port_present("COM99")
def test_serial_port_present_assumes_up_on_enumeration_error(monkeypatch) -> None:
"""A failed port query must never fake a disconnect — it reports 'present'."""
pytest.importorskip("serial")
from serial.tools import list_ports
def boom():
raise OSError("enumeration failed")
monkeypatch.setattr(list_ports, "comports", boom)
assert connection.serial_port_present("COM11")
def test_serial_port_present_platform_uart_by_path(monkeypatch) -> None:
"""A platform UART counts as present by its ``/dev`` node, not by a scan.
A soldered UART (``/dev/ttyS1`` on the Luckfox Lyra) is invisible to pyserial's
``comports()``, but its char-device node persists — so an existing character
device reads as present, while a vanished node (a real USB unplug of
``/dev/ttyUSB*``) still reads as absent.
"""
pytest.importorskip("serial")
import os as _os
import stat as _stat
from serial.tools import list_ports
monkeypatch.setattr(list_ports, "comports", list) # platform UARTs aren't enumerated -> []
monkeypatch.setattr(_os.path, "exists", lambda p: p == "/dev/ttyS1")
monkeypatch.setattr(_os, "stat", lambda p: SimpleNamespace(st_mode=_stat.S_IFCHR))
assert connection.serial_port_present("/dev/ttyS1") # existing char device -> present
assert not connection.serial_port_present("/dev/ttyUSB9") # node gone -> absent (unplug)
async def test_serial_link_check_walks_the_ports_off_the_event_loop(monkeypatch) -> None:
"""The liveness poll never runs the port walk on the loop the screen paints from.
The walk is hundreds of small sysfs reads, each one waiting its turn for the GIL; with
a map frame being drawn on a worker thread, running it on the loop froze the PicoCalc's
screen for up to 1.2 s every two seconds.
"""
import threading
device = connection.MeshCoreDevice(port="/dev/ttyS1")
device._mc = SimpleNamespace(is_connected=True)
walked_on: list[threading.Thread] = []
def walk(port: str) -> bool:
walked_on.append(threading.current_thread())
return True
monkeypatch.setattr(connection, "serial_port_present", walk)
assert await device.link_present()
assert walked_on and walked_on[0] is not threading.main_thread()
async def test_wait_for_disconnect_fires_when_port_vanishes(tmp_path: Path, monkeypatch) -> None:
"""The liveness watcher resolves once the connected device's port leaves enumeration."""
ctx = _make_ctx(tmp_path)
# Pose as a live real-hardware session on COM_TEST (the mock can't be unplugged).
ctx.mock = False
ctx._device = _FakeSerialDevice("COM_TEST") # connected device; is_connected -> True
ctx._active_transport = "serial"
ctx._active_port = "COM_TEST"
checks = {"n": 0}
def fake_present(port: str) -> bool:
checks["n"] += 1
return checks["n"] < 2 # present on the first poll, gone thereafter
monkeypatch.setattr(connection, "serial_port_present", fake_present)
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
monkeypatch.setattr(menu, "_LIVENESS_CONFIRM_S", 0.0)
try:
await asyncio.wait_for(menu._wait_for_disconnect(ctx), timeout=2.0)
finally:
ctx.repo.close()
async def test_wait_for_disconnect_ignores_the_simulator(tmp_path: Path) -> None:
"""The watcher never fires for --mock: the simulator has no port to lose."""
ctx = _make_ctx(tmp_path) # mock=True
try:
with pytest.raises(asyncio.TimeoutError):
await asyncio.wait_for(menu._wait_for_disconnect(ctx), timeout=0.2)
finally:
ctx.repo.close()
async def test_wait_for_disconnect_fires_on_an_announced_reboot(
tmp_path: Path, monkeypatch
) -> None:
"""A reboot we sent fires the watcher at once, though the port never goes away.
A board behind a USB-UART bridge keeps its port through a reboot, so the poll alone
would never see the drop; the announcement is the evidence.
"""
ctx = _make_ctx(tmp_path)
ctx.mock = False
ctx._device = _FakeSerialDevice("COM_TEST")
ctx._active_transport = "serial"
ctx._active_port = "COM_TEST"
monkeypatch.setattr(connection, "serial_port_present", lambda port: True) # never leaves
try:
watcher = asyncio.ensure_future(menu._wait_for_disconnect(ctx))
await asyncio.sleep(0)
assert not watcher.done()
ctx.announce_reboot()
await asyncio.wait_for(watcher, timeout=0.5) # well inside one liveness poll
assert ctx.take_reboot() is True
assert ctx.take_reboot() is False # consumed: the next drop reads as an unplug
with pytest.raises(asyncio.TimeoutError): # and the next watcher waits for one
await asyncio.wait_for(menu._wait_for_disconnect(ctx), timeout=0.2)
finally:
ctx.repo.close()
async def test_reboot_does_not_wait_for_an_acknowledgement_that_never_comes() -> None:
"""A reboot write the board never acknowledges returns at once, not at link loss.
Over Bluetooth the write is a write-with-response, and a board that restarts on the
command can drop the link before acknowledging it; waiting that out held the reboot
dialog back by the supervision timeout.
"""
ended = asyncio.Event()
async def never_acknowledged() -> None:
try:
await asyncio.Event().wait()
finally:
ended.set()
device = connection.MeshCoreDevice(port="mock")
device._mc = SimpleNamespace(commands=SimpleNamespace(reboot=never_acknowledged))
await asyncio.wait_for(device.reboot(), timeout=1.0)
assert len(connection._REBOOT_WRITES) == 1 # still pending, and still referenced
for write in list(connection._REBOOT_WRITES):
write.cancel() # the teardown that follows a reboot
await asyncio.wait_for(ended.wait(), timeout=1.0)
await asyncio.sleep(0)
assert not connection._REBOOT_WRITES
async def test_reboot_still_raises_a_write_that_fails_outright() -> None:
"""Returning early is for a write left hanging; one that fails at once still raises."""
async def refused() -> None:
raise RuntimeError("not connected")
device = connection.MeshCoreDevice(port="mock")
device._mc = SimpleNamespace(commands=SimpleNamespace(reboot=refused))
with pytest.raises(RuntimeError):
await device.reboot()
class _FakeBleDevice:
"""A minimal stand-in for a connected BLE :class:`Device` in liveness tests.
Its :meth:`link_present` reads an ``is_connected`` flag, mirroring how the real BLE
device reads the meshcore client's connection state.
"""
transport = "ble"
def __init__(self) -> None:
self.is_connected = True
async def link_present(self) -> bool:
return self.is_connected
async def test_wait_for_disconnect_fires_when_ble_link_drops(tmp_path: Path, monkeypatch) -> None:
"""The same watcher fires for BLE once the peripheral's connection flag flips false."""
ctx = _make_ctx(tmp_path)
ctx.mock = False
device = _FakeBleDevice()
ctx._device = device
ctx._active_transport = "ble"
ctx._active_address = "AA:BB:CC:DD:EE:FF"
async def drop_soon() -> None:
device.is_connected = False # the peripheral goes out of range
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
monkeypatch.setattr(menu, "_LIVENESS_CONFIRM_S", 0.0)
try:
await drop_soon()
await asyncio.wait_for(menu._wait_for_disconnect(ctx), timeout=2.0)
finally:
ctx.repo.close()
class _FakeSession:
"""A minimal stand-in for :class:`TuiSession` recording pushes/pops for dialog tests."""
def __init__(self) -> None:
self.stack: list = []
self.root = None # no menu ever declared itself: the dialog gets a blank base
def push(self, screen) -> None:
self.stack.append(screen)
def pop(self, screen=None) -> None:
if self.stack:
self.stack.pop()
def invalidate(self) -> None:
pass
async def test_handle_disconnect_auto_reconnects_when_port_returns(
tmp_path: Path, monkeypatch
) -> None:
"""The popup dismisses itself (no keypress) once the device's port re-appears."""
ctx = _make_ctx(tmp_path)
ctx.mock = False
ctx._device = object() # a connected stand-in
ctx._active_port = "COM_TEST"
polls = {"n": 0}
def fake_present(port: str) -> bool:
polls["n"] += 1
return polls["n"] >= 2 # absent on the first poll, back thereafter
async def fake_reconnect(self: AppContext, *, ble_device: object | None = None) -> None:
self._device = object() # a fresh connection
monkeypatch.setattr(connection, "serial_port_present", fake_present)
monkeypatch.setattr(AppContext, "reconnect", fake_reconnect)
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
monkeypatch.setattr(menu, "spinner_interval", lambda: 0.0)
session = _FakeSession()
try:
quit_chosen = await asyncio.wait_for(menu._handle_disconnect(ctx, session), timeout=2.0)
assert quit_chosen is False # reconnected, not quit
assert session.stack == [] # the popup was cleaned up
finally:
ctx.repo.close()
async def test_handle_disconnect_quits_when_user_presses_quit(tmp_path: Path, monkeypatch) -> None:
"""Pressing Enter (the Abort button) leaves, even while the device is still gone."""
ctx = _make_ctx(tmp_path)
ctx.mock = False
ctx._device = object()
ctx._active_port = "COM_TEST"
# The port never comes back, so the only way out is the Quit button.
monkeypatch.setattr(connection, "serial_port_present", lambda port: False)
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
monkeypatch.setattr(menu, "spinner_interval", lambda: 0.0)
session = _FakeSession()
async def press_quit() -> None:
# Once the dialog is on the stack, deliver Enter to its Abort button.
while not session.stack:
await asyncio.sleep(0)
session.stack[-1].handle("enter")
try:
_, quit_chosen = await asyncio.wait_for(
asyncio.gather(press_quit(), menu._handle_disconnect(ctx, session)),
timeout=2.0,
)
assert quit_chosen is True
assert session.stack == []
finally:
ctx.repo.close()
async def test_reconnect_dialog_aborts_on_enter_and_ignores_escape() -> None:
"""Enter aborts (resolves ``"quit"``); Esc is inert so a stray keypress can't drop us."""
from meshterm.ui.tui import ReconnectDialog
dialog = ReconnectDialog("Waiting…")
dialog.future = asyncio.get_running_loop().create_future()
dialog.handle("escape")
assert not dialog.future.done() # Esc does nothing
dialog.handle("enter")
assert dialog.future.result() == "quit"
async def _never() -> None:
"""An awaitable that blocks forever (a stand-in for an idle worker/watcher)."""
await asyncio.Event().wait()
async def test_session_loop_quits_without_touching_the_disconnect_path(monkeypatch) -> None:
"""When the menu loop returns (user quit), the watcher is stopped and no dialog shows."""
handled = {"n": 0}
async def fake_menu(ctx, session):
return None # user quit at the menu
async def fake_handle(ctx, session):
handled["n"] += 1
return True
monkeypatch.setattr(menu, "_menu_loop", fake_menu)
monkeypatch.setattr(menu, "_wait_for_disconnect", lambda ctx: _never())
monkeypatch.setattr(menu, "_handle_disconnect", fake_handle)
session = SimpleNamespace(reset=lambda: None)
await asyncio.wait_for(menu._session_loop(object(), session), timeout=2)
assert handled["n"] == 0 # the disconnect path was never entered
async def test_session_loop_cancels_worker_and_prompts_on_disconnect(monkeypatch) -> None:
"""A disconnect while the menu is busy cancels the worker, clears the stack, and prompts."""
resets = {"n": 0}
cancelled = {"seen": False}
async def busy_menu(ctx, session):
try:
await asyncio.Event().wait() # a tool is mid-flight; it must be cancelled
except asyncio.CancelledError:
cancelled["seen"] = True
raise
async def fires_now(ctx):
return None # the port vanished
async def quit_at_dialog(ctx, session):
return True
monkeypatch.setattr(menu, "_menu_loop", busy_menu)
monkeypatch.setattr(menu, "_wait_for_disconnect", fires_now)
monkeypatch.setattr(menu, "_handle_disconnect", quit_at_dialog)
session = SimpleNamespace(reset=lambda: resets.__setitem__("n", resets["n"] + 1))
await asyncio.wait_for(menu._session_loop(object(), session), timeout=2)
assert cancelled["seen"] # the in-flight worker was cancelled
assert resets["n"] == 1 # the stack was unwound before the dialog
async def test_session_loop_resumes_a_fresh_menu_after_reconnect(monkeypatch) -> None:
"""After a reconnect the loop starts a new menu (and re-arms the watcher)."""
state = {"menu": 0, "watch": 0, "handle": 0}
async def flaky_menu(ctx, session):
state["menu"] += 1
if state["menu"] == 1:
await asyncio.Event().wait() # first pass: interrupted by the disconnect
return None # second pass: user quits
async def watch(ctx):
state["watch"] += 1
if state["watch"] == 1:
return None # fire once
await asyncio.Event().wait() # never fire again
async def reconnected(ctx, session):
state["handle"] += 1
return False # the device came back
monkeypatch.setattr(menu, "_menu_loop", flaky_menu)
monkeypatch.setattr(menu, "_wait_for_disconnect", watch)
monkeypatch.setattr(menu, "_handle_disconnect", reconnected)
session = SimpleNamespace(reset=lambda: None)
await asyncio.wait_for(menu._session_loop(object(), session), timeout=2)
assert state["handle"] == 1 # one disconnect handled
assert state["menu"] == 2 # a fresh menu ran after reconnect
class _WedgedMeshCore:
"""A fake meshcore client whose graceful ``disconnect()`` never returns.
Reproduces the library's dispatcher-stop deadlock (``queue.join()`` with events still
queued after the processor task exited), which used to hang MeshTerm's exit until the
watchdog force-killed the process. Records whether the forced path ran.
"""
def __init__(self) -> None:
self.force_stopped = False
self.connection_manager = SimpleNamespace(connection=self)
self.raw_closed = False
async def disconnect(self) -> None:
# Called both as the graceful teardown (via the manager-less attribute lookup on
# the client) and as the raw transport close. The graceful call wedges; the raw
# close is distinguished by the force-stop having run first.
if not self.force_stopped:
await asyncio.Event().wait() # the dispatcher deadlock: never returns
self.raw_closed = True
def stop(self) -> None:
self.force_stopped = True
async def test_disconnect_bounds_a_wedged_client_teardown(monkeypatch) -> None:
"""A deadlocked graceful teardown is abandoned and the transport force-closed instead."""
monkeypatch.setattr(connection, "_DISCONNECT_TIMEOUT_S", 0.05)
monkeypatch.setattr(connection, "_FORCE_DISCONNECT_TIMEOUT_S", 0.5)
dev = connection.MeshCoreDevice(port="COM_TEST")
wedged = _WedgedMeshCore()
dev._mc = wedged
await asyncio.wait_for(dev.disconnect(), timeout=2.0) # must not hang
assert wedged.force_stopped # the dispatcher task was cancelled synchronously
assert wedged.raw_closed # the port/link was still released
assert dev._mc is None # idempotent: a second disconnect is a no-op
async def test_disconnect_graceful_path_needs_no_force(monkeypatch) -> None:
"""A healthy teardown completes gracefully; the forced path is never entered."""
class _HealthyMeshCore:
def __init__(self) -> None:
self.disconnected = False
self.force_stopped = False
self.connection_manager = SimpleNamespace(connection=self)
async def disconnect(self) -> None:
self.disconnected = True
def stop(self) -> None:
self.force_stopped = True
dev = connection.MeshCoreDevice(port="COM_TEST")
healthy = _HealthyMeshCore()
dev._mc = healthy
await dev.disconnect()
assert healthy.disconnected
assert not healthy.force_stopped
assert dev._mc is None
class _FakeBleDevice:
"""A stand-in for a connected BLE :class:`Device` whose link has already dropped."""
transport = "ble"
def __init__(self) -> None:
self.disconnected = False
async def link_present(self) -> bool:
return False
async def connect(self) -> None:
return None
async def disconnect(self) -> None:
self.disconnected = True
async def get_self_info(self) -> dict:
return {}
async def get_device_info(self) -> dict:
return {}
def _ble_ctx(tmp_path: Path) -> AppContext:
"""A context standing in for a live BLE session whose companion has just dropped."""
ctx = _make_ctx(tmp_path)
ctx.mock = False
ctx._device = _FakeBleDevice()
ctx._active_transport = "ble"
ctx._active_address = "AA:BB:00:00:00:01"
return ctx
async def test_ble_reconnect_waits_for_the_advertisement_and_hands_over_the_fresh_handle(
tmp_path: Path, monkeypatch
) -> None:
"""BLE reconnects on hearing the companion, opening it with the handle just scanned."""
ctx = _ble_ctx(tmp_path)
fresh = object() # the live BLEDevice the scan produced
scans: list[str] = []
async def fake_find(address: str, timeout: float = 0.0) -> object | None:
scans.append(address)
return fresh if len(scans) >= 2 else None # silent on the first round, then heard
opened: list[object | None] = []
async def fake_reconnect(self: AppContext, *, ble_device: object | None = None) -> None:
opened.append(ble_device)
self._device = _FakeBleDevice()
monkeypatch.setattr(discovery, "find_ble_device", fake_find)
monkeypatch.setattr(AppContext, "reconnect", fake_reconnect)
monkeypatch.setattr(menu, "_BLE_RESCAN_PAUSE_S", 0.0)
monkeypatch.setattr(menu, "spinner_interval", lambda: 0.0)
session = _FakeSession()
try:
quit_chosen = await asyncio.wait_for(menu._handle_disconnect(ctx, session), timeout=2.0)
assert quit_chosen is False # reconnected, not quit
# It waited for the device to be heard rather than blind-retrying the connect...
assert scans == [ctx._active_address, ctx._active_address]
assert len(opened) == 1
# ...and reopened it with the handle that scan produced, not a stale one.
assert opened == [fresh]
finally:
ctx.repo.close()
async def test_ble_reconnect_releases_the_dead_link_before_scanning(
tmp_path: Path, monkeypatch
) -> None:
"""The old link is put down first, so the peripheral is free to advertise again."""
ctx = _ble_ctx(tmp_path)
dead = ctx._device
held_at_scan: list[bool] = []
async def fake_find(address: str, timeout: float = 0.0) -> object | None:
held_at_scan.append(ctx._device is not None)
return object()
async def fake_reconnect(self: AppContext, *, ble_device: object | None = None) -> None:
self._device = _FakeBleDevice()
monkeypatch.setattr(discovery, "find_ble_device", fake_find)
monkeypatch.setattr(AppContext, "reconnect", fake_reconnect)
monkeypatch.setattr(menu, "spinner_interval", lambda: 0.0)
session = _FakeSession()
try:
await asyncio.wait_for(menu._handle_disconnect(ctx, session), timeout=2.0)
assert dead.disconnected # the dead companion was let go...
assert held_at_scan == [False] # ...before we ever listened for it
finally:
ctx.repo.close()
async def test_reconnect_prefers_the_freshly_scanned_ble_handle(
tmp_path: Path, monkeypatch
) -> None:
"""A handle passed to reconnect opens the link; the startup scan's stale one is not reused."""
address = "AA:BB:00:00:00:01"
ctx = _make_ctx(tmp_path)
ctx.mock = False
ctx.ble_override = address
stale = object() # what the picker's scan produced when the session opened
fresh = object() # what the reconnect flow just heard advertising
ctx.selected_device = DiscoveredDevice(
transport="ble", address=address, name="MeshCore-Homestead", ble_device=stale
)
handles: list[object | None] = []
def fake_make_device(**kwargs: object): # noqa: ANN202 - a stub stands in for the radio
handles.append(kwargs.get("ble_device"))
return _FakeBleDevice()
monkeypatch.setattr(context_module, "make_device", fake_make_device)
try:
await ctx.reconnect(ble_device=fresh)
assert handles == [fresh] # the freshly-scanned peripheral, never the stale handle
assert ctx._ble_handle is None # consumed by the connection it opened
# With nothing scanned, the picker's handle is still the best available guess.
await ctx.reconnect()
assert handles == [fresh, stale]
finally:
ctx.repo.close()
async def test_release_link_is_idempotent_and_holds_the_resume_intent(tmp_path: Path) -> None:
"""Releasing twice is harmless, and what was running is still restored on reconnect."""
ctx = _make_ctx(tmp_path)
try:
await ctx.events.start()
await ctx.monitor.start()
await ctx.release_link()
assert not ctx.monitor.active # the services rode the link down with it
await ctx.release_link() # a second release finds nothing to do and says nothing
await ctx.reconnect()
assert ctx.events.active and ctx.monitor.active # the held intent survived both
finally:
await ctx.aclose()
class _AbandonedBleakClient:
"""A ``bleak`` client whose peripheral vanished: already down, and never handed back.
Mirrors what the meshcore transport leaves behind on a dropped link — ``is_connected`` is
already ``False``, so every guarded teardown in the library declines to touch it.
"""
def __init__(self) -> None:
self.is_connected = False
self.disconnect_calls = 0
async def disconnect(self) -> None:
self.disconnect_calls += 1
class _DroppedBleConnection:
"""meshcore's ``BLEConnection`` after its dropped-link callback ran: client reference gone."""
def __init__(self) -> None:
self.client = None # handle_disconnect restored this to the constructor's value
self.disconnect_calls = 0
async def disconnect(self) -> None:
self.disconnect_calls += 1 # guards on self.client — reaches nothing
class _DroppedMeshCore:
"""A meshcore client whose connection manager also believes it is already disconnected."""
def __init__(self, connection: _DroppedBleConnection) -> None:
self.connection_manager = SimpleNamespace(connection=connection)
self.disconnected = False
async def disconnect(self) -> None:
self.disconnected = True # guards on _is_connected — reaches nothing
def stop(self) -> None:
pass
async def test_a_vanished_peripheral_still_gets_its_bleak_client_closed() -> None:
"""The client is released even though every library guard says there is nothing to close."""
dev = connection.MeshCoreDevice(transport="ble", address="AA:BB:00:00:00:01")
dropped = _DroppedBleConnection()
dev._mc = _DroppedMeshCore(dropped)
abandoned = _AbandonedBleakClient()
dev._ble_client = abandoned # what we kept hold of at connect
await dev.disconnect()
# The library's own teardown reached nothing — the connection had already let the client go.
assert dropped.client is None
# Ours closed it anyway, which is what frees the WinRT handles the next connect needs.
assert abandoned.disconnect_calls == 1
assert dev._ble_client is None # and it is not closed twice
assert dev._mc is None
async def test_releasing_the_bleak_client_is_idempotent_and_never_raises() -> None:
"""A second disconnect finds nothing to do, and a failing close is swallowed."""
dev = connection.MeshCoreDevice(transport="ble", address="AA:BB:00:00:00:01")
await dev.disconnect() # nothing connected at all
assert dev._ble_client is None
class _RefusesToClose:
is_connected = False
async def disconnect(self) -> None:
raise OSError("the handle is invalid")
dev._ble_client = _RefusesToClose()
await dev.disconnect() # must not raise: teardown is best-effort
assert dev._ble_client is None
async def test_a_healthy_bluetooth_teardown_still_releases_the_client() -> None:
"""The graceful path runs as before, and the client is released after it."""
dev = connection.MeshCoreDevice(transport="ble", address="AA:BB:00:00:00:01")
class _HealthyMeshCore:
def __init__(self) -> None:
self.disconnected = False
self.force_stopped = False
self.connection_manager = SimpleNamespace(connection=self)
async def disconnect(self) -> None:
self.disconnected = True
def stop(self) -> None:
self.force_stopped = True
healthy = _HealthyMeshCore()
dev._mc = healthy
live = _AbandonedBleakClient()
live.is_connected = True
dev._ble_client = live
await dev.disconnect()
assert healthy.disconnected
assert not healthy.force_stopped # the graceful path was enough, as it always was
assert live.disconnect_calls == 1 # and the client is released regardless
# --- What the reader is told when a connection won't open --------------------------------------
def _refusal() -> Exception:
"""A connect failure as the context raises it: its own wrapper round the named sentence."""
from meshterm.core.selection import DeviceSelectionError
try:
try:
raise DeviceCommandError(
"COM_TEST is in use by another program — the MeshCore app, a flasher, or a "
"serial monitor. Close it and try again."
)
except DeviceCommandError as cause:
raise DeviceSelectionError(f"could not open serial port COM_TEST: {cause}") from cause
except DeviceSelectionError as exc:
return exc
async def test_a_device_that_failed_to_open_is_not_kept(tmp_path: Path, monkeypatch) -> None:
"""After a failed open, nothing claims to be connected, and the next caller tries again.
The device used to stay behind unopened: ``is_connected`` said yes, and every tool after
a failed start was handed it and failed with "not connected" instead of the reason.
"""
from meshterm.core.selection import DeviceSelectionError
ctx = _make_ctx(tmp_path)
ctx.mock = False
ctx.tcp_override = "10.0.0.9:5000"
built: list = []
class _Refusing:
transport = "tcp"
disconnects = 0
async def connect(self) -> None:
raise DeviceCommandError("nothing is accepting connections on port 5000 at 10.0.0.9")
async def disconnect(self) -> None:
self.disconnects += 1
def fake_make_device(**_kw):
built.append(_Refusing())
return built[-1]
monkeypatch.setattr(context_module, "make_device", fake_make_device)
try:
for attempt in (1, 2):
with pytest.raises(DeviceSelectionError, match="port 5000"):
await ctx.device()
assert not ctx.is_connected
assert len(built) == attempt # a fresh attempt each time, not the dead one back
assert built[0].disconnects == 1 # whatever half of it opened was put down
finally:
ctx.repo.close()
def test_a_failure_reads_as_the_connection_s_own_sentence() -> None:
"""The dialog shows the named sentence, not the context's wrapper repeating the endpoint."""
from meshterm.ui.device_picker import connect_failure_text
text = connect_failure_text(_refusal())
assert text.plain.startswith("COM_TEST is in use by another program")
assert "could not open serial port" not in text.plain
assert "The full error" not in text.plain # a named failure: the log has nothing more
@pytest.mark.parametrize(
("sentence", "shown"),
[
("couldn't open a Bluetooth link to X", "Couldn't open a Bluetooth link to X"),
("no permission to open COM5.", "No permission to open COM5."),
("meshbox.local:5000 closed the connection", "meshbox.local:5000 closed the connection"),
("/dev/ttyACM0 isn't there", "/dev/ttyACM0 isn't there"),
("c0:ff:ee:00:00:01 rejected the PIN", "c0:ff:ee:00:00:01 rejected the PIN"),
],
)
def test_a_dialog_capitalises_a_plain_word_and_never_a_name(sentence: str, shown: str) -> None:
"""A dialog capitalises the command line's lowercase sentence, but never a name.
A sentence that starts with a port, a host, or an address keeps that spelling, since it
is the reader's to match against their own.
"""
from meshterm.ui.device_picker import connect_failure_text
assert connect_failure_text(DeviceCommandError(sentence)).plain == shown
def test_an_unrecognised_failure_says_where_the_log_is(monkeypatch, tmp_path: Path) -> None:
"""Only a failure MeshTerm couldn't name points at the log, and only if the log took it."""
from meshterm.ui import device_picker
log = tmp_path / "meshterm.log"
odd = connection.UnrecognisedConnectError("couldn't connect to COM5: [WinError 31] odd")
monkeypatch.setattr(device_picker, "log_file_for", lambda level: log)
text = device_picker.connect_failure_text(odd)
assert text.plain == f"Couldn't connect to COM5: [WinError 31] odd\nThe full error is in {log}"
monkeypatch.setattr(device_picker, "log_file_for", lambda level: None) # log level ERROR
assert "The full error" not in device_picker.connect_failure_text(odd).plain
async def test_reconnect_says_why_once_the_failure_persists(tmp_path: Path, monkeypatch) -> None:
"""A device that is back but refuses gets its reason under the spinner, not a silent wait.
The first failure is let go (a board can refuse once while it boots), so the reason
appears from the second.
"""
from rich.text import Text
from meshterm.ui.tui import ReconnectDialog
ctx = _make_ctx(tmp_path)
ctx.mock = False
ctx._device = object()
ctx._active_port = "COM_TEST"
dialog = ReconnectDialog("Waiting for your device — reconnect it to resume.")
dialog.future = asyncio.get_running_loop().create_future()
seen: list[str] = []
def body() -> str:
return Text.from_ansi("\n".join(dialog.render_body(68))).plain
async def fake_reconnect(self: AppContext, *, ble_device: object | None = None) -> None:
seen.append(body())
if len(seen) <= 2:
raise _refusal()
async def fake_release(self: AppContext) -> None:
pass
monkeypatch.setattr(connection, "serial_port_present", lambda port: True)
monkeypatch.setattr(AppContext, "reconnect", fake_reconnect)
monkeypatch.setattr(AppContext, "release_link", fake_release)
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
try:
await asyncio.wait_for(menu._auto_reconnect(ctx, dialog), timeout=2.0)
assert dialog.future.result() == "reconnected"
assert "in use by another program" not in seen[1] # one failure: just retried
assert "in use by another program" in seen[2] # two: said
assert "Still trying to reconnect" in seen[2]
finally:
ctx.repo.close()
async def test_reconnect_forgets_the_reason_when_the_device_goes_again(
tmp_path: Path, monkeypatch
) -> None:
"""Unplugged after it refused, the device is waited for again, not still called refusing."""
from rich.text import Text
from meshterm.ui.tui import ReconnectDialog
ctx = _make_ctx(tmp_path)
ctx.mock = False
ctx._device = object()
ctx._active_port = "COM_TEST"
dialog = ReconnectDialog("Waiting for your device — reconnect it to resume.")
dialog.future = asyncio.get_running_loop().create_future()
present = iter([True, True, False, True]) # refuses twice, is pulled, comes back
bodies: list[str] = []
def port_present(_port: str) -> bool:
bodies.append(Text.from_ansi("\n".join(dialog.render_body(68))).plain)
return next(present)
attempts = {"n": 0}
async def fake_reconnect(self: AppContext, *, ble_device: object | None = None) -> None:
attempts["n"] += 1
if attempts["n"] <= 2:
raise _refusal()
async def fake_release(self: AppContext) -> None:
pass
monkeypatch.setattr(connection, "serial_port_present", port_present)
monkeypatch.setattr(AppContext, "reconnect", fake_reconnect)
monkeypatch.setattr(AppContext, "release_link", fake_release)
monkeypatch.setattr(menu, "_LIVENESS_POLL_S", 0.0)
try:
await asyncio.wait_for(menu._auto_reconnect(ctx, dialog), timeout=2.0)
assert "in use by another program" in bodies[2] # said, while it was refusing
assert "in use by another program" not in bodies[3] # gone again: waited for
assert "Waiting for your device" in bodies[3]
finally:
ctx.repo.close()
async def test_a_radio_that_will_not_start_is_reported_before_the_menu(
tmp_path: Path, monkeypatch
) -> None:
"""With the device named on the command line there is no picker, so startup says why."""
ctx = _make_ctx(tmp_path)
notes: list = []
async def failing_start() -> None:
raise _refusal()
async def notify_startup(renderable, *, title="", banner=None, footnote=None):
notes.append((title, renderable.plain))
monkeypatch.setattr(ctx.events, "start", failing_start)
monkeypatch.setattr(menu, "_warm_basemap", lambda _ctx: None)
monkeypatch.setattr(ctx, "ui", SimpleNamespace(notify_startup=notify_startup))
try:
failure = await menu._resume_monitor(ctx)
assert failure is not None
assert not ctx.chat.active # not a second try at the same refusal
await menu._report_startup_failure(ctx, failure)
[(title, plain)] = notes
assert title == "Can't connect yet"
assert plain.startswith("COM_TEST is in use by another program")
assert "The menu opens without a radio" in plain
finally:
await ctx.aclose()
class _ToolUi:
"""Collects what a tool run leaves for its result dialog."""
def __init__(self) -> None:
self.buffer: list = []
self.presented = False
def note(self, markup: str) -> None:
from rich.text import Text
self.buffer.append(Text.from_markup(markup))
def show(self, *renderables) -> None: # noqa: ANN002
self.buffer.extend(renderables)
def discard(self) -> None:
self.buffer = []
async def present(self, *, title: str = "") -> None:
self.presented = True
async def _run_failing_tool(tmp_path: Path, monkeypatch, exc: Exception) -> _ToolUi:
"""Run a menu tool that raises ``exc``, and return what it left to show."""
from meshterm import tools
class _Tool:
name = title = "Contacts"
async def prompt_params(self, _ctx):
raise exc
ctx = _make_ctx(tmp_path)
ui = _ToolUi()
monkeypatch.setattr(tools, "get_tool", lambda name: _Tool())
monkeypatch.setattr(ctx, "ui", ui)
try:
await menu._run_selection(ctx, "contacts")
finally:
ctx.repo.close()
return ui
async def test_a_tool_whose_radio_never_opened_says_why(tmp_path: Path, monkeypatch) -> None:
"""A connect that failed reads as the connection's sentence, even if it looks like a lost link.
The watcher has nothing connected to notice, so nothing else would say it.
"""
from meshterm.core.selection import DeviceSelectionError
try:
raise DeviceSelectionError("could not open BLE companion X") from OSError(
"input/output error"
)
except DeviceSelectionError as raised:
exc = raised
assert is_connection_lost(exc)
ui = await _run_failing_tool(tmp_path, monkeypatch, exc)
assert ui.presented
assert [t.plain for t in ui.buffer] == ["⚠ Could not open BLE companion X"]
async def test_a_tool_failure_shows_the_error_s_brackets(tmp_path: Path, monkeypatch) -> None:
"""A library's bracketed words are shown, not swallowed as a markup tag."""
exc = RuntimeError("[org.bluez.Error.Failed] le-connection-abort-by-local")
ui = await _run_failing_tool(tmp_path, monkeypatch, exc)
[text] = ui.buffer
assert text.plain == "⚠ Contacts failed: [org.bluez.Error.Failed] le-connection-abort-by-local"