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>
2085 lines
83 KiB
Python
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"
|