Each test docstring states the behaviour that the test proves, in the simple present. No assertion message, test id, or other string literal changed. Four statements now agree with the code: the Trace highlight stays on the last row, a menu count, and two others. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
2171 lines
86 KiB
Python
2171 lines
86 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""Tests for the graceful handling of a lost device connection.
|
|
|
|
The tests cover two things. The first is the exception classifier, which finds the
|
|
difference between a dropped serial link and an ordinary command failure. The second is
|
|
:meth:`AppContext.reconnect`. It builds the connection again and restores the services
|
|
(event hub, passive monitor, chat) that ran before the drop. All the tests run against the
|
|
:class:`MockDevice` simulator, so no hardware is necessary.
|
|
"""
|
|
|
|
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``.
|
|
|
|
The classifier matches the type name, not the import.
|
|
"""
|
|
|
|
|
|
class _FakeSerialDevice:
|
|
"""A minimal stand-in for a connected serial :class:`Device` in the liveness tests.
|
|
|
|
Its :meth:`link_present` is the same as in the real serial device. It reports if the
|
|
operating system still lists the port. It reads through the module function that the
|
|
tests patch, so the tests can switch the 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:
|
|
"""The classifier finds a pyserial read or write failure to be 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 in a higher-level error is still found through 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:
|
|
"""A teardown by the operating system, or an I/O message that shows a drop, is 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 short 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 have the names of the real bleak classes, because the classifier matches
|
|
# the type name (not the import). Refer to ``_CONNECTION_LOST_TYPES``.
|
|
class BleakError(Exception): # noqa: N818 - the same name as bleak's own, with no Error suffix
|
|
"""A stand-in for ``bleak.exc.BleakError``.
|
|
|
|
The classifier matches the type name, not the 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 a lost connection, the same as 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 a lost link with a reason string in a callback.
|
|
assert is_connection_lost(RuntimeError("ble_transport_lost"))
|
|
|
|
|
|
class BleakGATTProtocolError(Exception):
|
|
"""A stand-in for the GATT authentication rejection of bleak.
|
|
|
|
The classifier matches its message, not the import.
|
|
"""
|
|
|
|
|
|
def test_ble_auth_error_is_recognized_and_is_not_a_lost_link() -> None:
|
|
"""A PIN or pairing rejection is a separate case. It is not a dropped link.
|
|
|
|
The user can act on this case, so the classifier must not confuse it with a drop.
|
|
"""
|
|
exc = BleakGATTProtocolError("(5, 'GATT Protocol Error: Insufficient Authentication')")
|
|
assert connection._is_ble_auth_error(exc)
|
|
assert not is_connection_lost(exc) # Thus the session offers a PIN, not a reconnect.
|
|
|
|
|
|
def test_ble_auth_error_walks_the_exception_chain() -> None:
|
|
"""An authentication rejection in a meshcore transport error is found through 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. They are never the address of a real companion. A test
|
|
#: that names a real address puts a personal device into the repository. Also, a link to
|
|
#: hardware here is false, because no test 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. The tests check only that a code was supplied.
|
|
_A_PIN = "123456"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _no_bluez_agent(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Keep the BlueZ agent of the connect step off the system bus when these tests 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 that has a PIN refuses a subscribe over an unauthenticated bond.
|
|
|
|
This happened on a uConsole. Its bond with a T1000-E was older than the PIN of the
|
|
T1000-E (BlueZ Authenticated=0). The encryption succeeded, but the subscribe did not.
|
|
The user saw only the raw GATT error.
|
|
"""
|
|
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 step 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 calls 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:
|
|
"""The same as the BLEConnection of meshcore.
|
|
|
|
A disconnect sets ``client`` to the value that 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 # This reset left the live link with no owner.
|
|
|
|
async def connect(self):
|
|
self.client = _RetryingBleakClient(self.drops, self.handle_disconnect)
|
|
await self.client.connect()
|
|
# meshcore calls start_notify on None, and reports "not established".
|
|
if self.client is None:
|
|
return None
|
|
return "address"
|
|
|
|
|
|
async def test_a_retried_link_attempt_no_longer_strands_the_client() -> None:
|
|
"""The retries in bleak must not make meshcore drop the client that connects after them.
|
|
|
|
This failure happened on the uConsole, in three runs out of three. Each lost link
|
|
attempt called the disconnect handler of meshcore, and the handler set ``client`` to
|
|
``None``. The next retry of bleak connected a client that nobody held. Then meshcore
|
|
gave up. The live link stopped the companion from advertising to the retry.
|
|
"""
|
|
unguarded = _MeshcoreLikeConnection(drops=3)
|
|
assert await unguarded.connect() is None # This is 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 # The disconnects are held while the connect step runs.
|
|
assert connection._held_client(guarded, seen) is guarded.client
|
|
guarded.client.callback(guarded.client) # A real drop after that still arrives.
|
|
assert guarded.forwarded == 1
|
|
|
|
|
|
def test_the_held_client_falls_back_to_the_one_meshcore_lost() -> None:
|
|
"""If meshcore lets go of the client, the 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:
|
|
"""Replace the WinRT ProvidePin step with a stub, so ``_create_ble`` stays isolated in tests.
|
|
|
|
On a real Windows or Linux host, ``_pair_ble`` reaches the Bluetooth stack of the
|
|
operating system and the physical device. A no-op in its place gives the same path as a
|
|
host that is not Windows, or that has no winrt. Thus the tests can examine the logic
|
|
that translates an authentication error 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, with one scripted entry for each connect attempt.
|
|
|
|
MeshTerm builds the meshcore client itself (:meth:`~MeshCoreDevice._connect_owned_ble`).
|
|
It does not let ``create_ble`` build the client and then leave it with no owner. Thus
|
|
the fake here is a class, and the test makes one instance for each attempt. Each
|
|
instance records if it was closed. This is the important property: a connect that
|
|
fails must never leave its link open. If it does, the peripheral believes that it has a
|
|
peer, and it stops advertising.
|
|
|
|
Args:
|
|
monkeypatch: Used to replace the ``meshcore`` module, which MeshTerm imports late,
|
|
with a stub. Thus no real ``BLEConnection`` (and no ``bleak``) is necessary.
|
|
outcomes: What each next ``connect()`` does. An exception instance is raised. Any
|
|
other value is returned (``None`` is a handshake that got no answer).
|
|
|
|
Returns:
|
|
The fake class. Its ``built`` list holds the instances that 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 authentication rejection becomes an error that names the PIN solution.
|
|
|
|
The error is a DeviceAuthenticationError.
|
|
"""
|
|
# The test sets the platform to not macOS. On macOS, the same rejection starts a pairing
|
|
# that the operating system runs, and MeshTerm retries it and does not report it. There,
|
|
# the advice is the system dialog and not --ble-pin. Without this line, the test checks
|
|
# the Windows and Linux words on the macOS CI runner, and it fails.
|
|
monkeypatch.setattr(sys, "platform", "win32")
|
|
# No PIN was supplied, so the message tells the user to pass one. The subclass lets the
|
|
# interactive picker catch "needs a PIN" alone, and the CLI still catches it as a
|
|
# 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) # Thus the scripted CLI catches it too.
|
|
assert "--ble-pin" in str(excinfo.value)
|
|
# MeshTerm released the failed link and did not leave it open.
|
|
assert fake.built[0].disconnect_calls == 1
|
|
|
|
# A PIN was supplied but the device rejected it. The message says the PIN was wrong. It
|
|
# does not say that no PIN 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:
|
|
"""There is no PIN, but Windows holds a bond that the device refuses.
|
|
|
|
The message says that the bond is stale. A user reported this case. Windows showed the
|
|
companion as paired, but the companion refused the subscribe, because the device side
|
|
of the bond was lost. The message "Requires a PIN" was wrong for a user who saw
|
|
"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 pairs it again.
|
|
assert "saved pairing" in excinfo.value.hint # Also, the PIN dialog says why it asks.
|
|
|
|
|
|
@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 outcome of the pairing. Each outcome has a solution."""
|
|
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 pair() call that fails in bleak is a pairing problem, not a device that did not 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 stopped. It does not return 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 authentication failure causes one unpair and pair again, then a connect that works.
|
|
|
|
The test models the Windows upgrade case. An old unauthenticated "Just Works" bond
|
|
makes the first connect fail, also with the correct PIN. Thus ``_pair_ble(force=True)``
|
|
clears the bond, and the second connect succeeds.
|
|
"""
|
|
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)
|
|
# The pass before the connect (force=False) does nothing. The repair (force=True) works.
|
|
return force
|
|
|
|
dev._pair_ble = _pair # type: ignore[method-assign]
|
|
result = await dev._create_ble(fake)
|
|
assert result is fake.built[1] # The caller gets the client of the retry.
|
|
assert len(fake.built) == 2 # It failed one time and MeshTerm retried one time.
|
|
# The attempt before the connect, then the pairing again that repairs the bond.
|
|
assert repairs == [False, True]
|
|
assert fake.built[0].disconnect_calls == 1 # Also, MeshTerm closed the failed client first.
|
|
assert fake.built[1].disconnect_calls == 0 # MeshTerm returns the live client open.
|
|
|
|
|
|
async def test_create_ble_gives_up_after_one_repair(monkeypatch) -> None:
|
|
"""A wrong PIN that never bonds fails cleanly. It does not loop 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 # Also the repair "succeeds", so the test proves that the retry runs one time.
|
|
|
|
dev._pair_ble = _pair # type: ignore[method-assign]
|
|
with pytest.raises(connection.DeviceAuthenticationError):
|
|
await dev._create_ble(fake)
|
|
# First force=False (before the connect), then force=True (the repair). The retry of the
|
|
# repair passes allow_repair=False. Thus there is no third pairing attempt, also if the
|
|
# connect 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 ConnectionError at the transport level is retried one time, with the scanned BLEDevice.
|
|
|
|
The test models a common failure on Windows. The first attempt to open the link misses
|
|
the peripheral, which advertises slowly, and the meshcore client raises a bare
|
|
``ConnectionError``. Users learned to select the device again, which is a manual
|
|
retry. Thus the connect step retries by itself before it reports the failure.
|
|
"""
|
|
scanned_device = object() # The BLEDevice that 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 # It failed one time and MeshTerm retried one time.
|
|
# Each attempt connects through the BLEDevice that the scan found. It never uses 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 limited 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 error is the cause.
|
|
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 or bond rejection is translated on the first attempt. The retry never loops on it."""
|
|
# The platform is not macOS. On macOS, a rejection is the start of a pairing that the
|
|
# operating system runs, and MeshTerm waits for it.
|
|
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 authentication rejection starts the pairing, so the connect waits.
|
|
|
|
The only way to make CoreBluetooth pair is to touch the authenticated characteristic of
|
|
the companion. Thus the rejection and the Passkey dialog are one event. MeshTerm once
|
|
gave up at this point. It reported an error for a pairing that was successful, 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)
|
|
# The companion refuses two times while the dialog is open. Then the bond is made 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 caller gets the attempt that MeshTerm made after the bond.
|
|
assert len(fake.built) == 3
|
|
# MeshTerm released each refused link and did not leave it open.
|
|
assert fake.built[0].disconnect_calls == 1
|
|
assert fake.built[1].disconnect_calls == 1
|
|
assert fake.built[2].disconnect_calls == 0 # MeshTerm returns the live client open.
|
|
|
|
|
|
async def test_create_ble_gives_up_when_macos_pairing_is_dismissed(monkeypatch) -> None:
|
|
"""A dialog that nobody answers fails in the end. The error 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 # The number is limited.
|
|
# --ble-pin cannot help on macOS, because the operating system collects the code. Thus
|
|
# the message must not send the user to --ble-pin.
|
|
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:
|
|
"""The transport is up but no identity reply comes. The error is a clean "not a companion".
|
|
|
|
MeshTerm also releases the link.
|
|
"""
|
|
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:
|
|
"""MeshTerm skips the pairing when no PIN is set, and it does not touch WinRT on this 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 and Linux pairs through a BlueZ agent. macOS does not pair."""
|
|
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 goes into ``_ble_pairing``, and the refusal message reads it there."""
|
|
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 solutions name Linux and ``bluetoothctl``, never the Settings of Windows."""
|
|
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:
|
|
"""If there is no PIN, BlueZ holds a bond, and the device refuses it, the bond is stale.
|
|
|
|
The message gives the solution for Linux.
|
|
"""
|
|
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
|
|
# If there is no bond, the PIN request has no advice that is only for Windows.
|
|
message, _ = dev._ble_auth_diagnosis(_BONDED_ADDR, False)
|
|
assert "--ble-pin" in message and "Windows" not in message
|
|
|
|
|
|
class SerialException(OSError):
|
|
"""A stand-in for the exception of pyserial (an ``OSError`` that usually has no ``errno``)."""
|
|
|
|
|
|
@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 does not open says why. It does not say "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 # On Windows, "Access is denied" is a port that is in use.
|
|
|
|
|
|
def test_an_unrecognised_serial_failure_passes_through() -> None:
|
|
"""A failure that is not 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 that the picker shows as it is."""
|
|
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
|
|
"""Put a ``meshcore`` module in place. Its ``create_*`` factories are the factories 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 in which a socket refuses has its own sentence.
|
|
|
|
The sentence is 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 is not a socket failure gets no name, and the caller handles it."""
|
|
assert connection._tcp_open_message("meshbox", 5000, ValueError("odd")) is None
|
|
|
|
|
|
async def test_tcp_connect_raises_the_named_failure(monkeypatch) -> None:
|
|
"""``connect`` says that a refused port is a refused port, with 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 and then got silence names the listener.
|
|
|
|
The error does not name the address or the network.
|
|
"""
|
|
|
|
async def create_tcp(*_args, **_kwargs):
|
|
return None # This is the answer of meshcore when the handshake gets no answer.
|
|
|
|
_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 still has a sentence, and MeshTerm logs its traceback one time."""
|
|
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 that retries 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 first time, the log has the traceback.
|
|
assert records[1].exc_info is None # After that, the log has only the line.
|
|
|
|
|
|
async def test_a_ble_connect_timeout_is_named_as_a_stall(monkeypatch) -> None:
|
|
"""A timeout of the Bluetooth stack says what stopped. It is not 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 time limit of the probe ends before the operating system gives up on a silent host.
|
|
|
|
The error names this case 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 unusual failure gets its own words. It is 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 MAC with colons or dashes becomes a 48-bit int.
|
|
|
|
A non-MAC (for example a macOS UUID) gives 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 the unpair stop early for a non-MAC address. They do not touch WinRT."""
|
|
# A non-MAC address fails the parse before any winrt import. Thus these tests stay isolated
|
|
# on each platform, and 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 for which Windows holds a bond."""
|
|
|
|
async def _is_paired(address: str) -> bool:
|
|
return address == _BONDED_ADDR
|
|
|
|
monkeypatch.setattr(connection.MeshCoreDevice, "is_ble_paired", staticmethod(_is_paired))
|
|
# Serial: the dialog never offers it, for any address.
|
|
serial_ctx = SimpleNamespace(active_transport="serial", active_address=None)
|
|
assert await menu._can_unpair(serial_ctx) is False
|
|
# BLE with no address to act on: the dialog does not offer it.
|
|
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: the dialog offers it.
|
|
bonded = SimpleNamespace(active_transport="ble", active_address=_BONDED_ADDR)
|
|
assert await menu._can_unpair(bonded) is True
|
|
# BLE that is open (no bond, for example a companion that has no PIN): the dialog does
|
|
# not offer it.
|
|
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:
|
|
"""The teardown drops the live link first, then removes the bond of the operating system.
|
|
|
|
The order is important.
|
|
"""
|
|
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)
|
|
# The disconnect comes before the unpair, because a bond that is in use cannot be dropped.
|
|
# Also, MeshTerm releases the device handle. It never touches the device_store, so the
|
|
# stored record stays.
|
|
assert order == ["disconnect", f"unpair:{_BONDED_ADDR}"]
|
|
assert ctx._device is None
|
|
|
|
|
|
def _make_ctx(tmp_path: Path) -> AppContext:
|
|
"""Build a real application context, with the simulator as the device, 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 new connection and resumes the hub, the monitor, and the chat."""
|
|
ctx = _make_ctx(tmp_path)
|
|
try:
|
|
await ctx.events.start()
|
|
await ctx.monitor.start() # Start to record on the hub that already runs.
|
|
await ctx.chat.start()
|
|
original = await ctx.device()
|
|
assert ctx.events.active and ctx.monitor.active and ctx.chat.active
|
|
|
|
await ctx.reconnect()
|
|
|
|
# A new connection replaced the dead connection. Each service that ran before the
|
|
# drop runs 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 with the 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)
|
|
# The context module imported make_device by name. Thus the test patches the reference
|
|
# that the context module 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 with the host and 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 restores only what was live. Services that were idle stay idle."""
|
|
ctx = _make_ctx(tmp_path)
|
|
try:
|
|
# A tool opened the device when it needed it. No service subscribed.
|
|
original = await ctx.device()
|
|
|
|
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:
|
|
"""The intent to resume survives retries. Services restore also if early reconnects fail.
|
|
|
|
The test reproduces a bug that happened on real hardware. The first reconnect attempt
|
|
failed and stopped the services. Then later attempts read the flags, which were now
|
|
idle, and restored nothing. The simulator never makes ``device()`` fail. Thus the test
|
|
makes the first two ``device()`` calls raise, as an absent port does. Then the third
|
|
call, and the calls that restore the services, 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: # On the first two tries, the device is not back yet.
|
|
raise _SerialException("could not open port 'COM_TEST'")
|
|
return await real_device(self)
|
|
|
|
monkeypatch.setattr(AppContext, "device", flaky_device)
|
|
|
|
# Two reconnects fail (the device is still absent), then one succeeds. This is the
|
|
# same as a slow replug.
|
|
for _ in range(2):
|
|
try:
|
|
await ctx.reconnect()
|
|
except _SerialException:
|
|
pass
|
|
await ctx.reconnect() # The third device() call succeeds, and the 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' if and only if it is in the list of the operating system.
|
|
|
|
This list is the main signal for an unplug.
|
|
"""
|
|
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 port query that fails must never cause a false 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 is present if its ``/dev`` node exists. A scan does not decide this.
|
|
|
|
``comports()`` of pyserial does not list a soldered UART (``/dev/ttyS1`` on the Luckfox
|
|
Lyra), but its character-device node stays. Thus a character device that exists is
|
|
present. A node that is gone (a real USB unplug of ``/dev/ttyUSB*``) is absent.
|
|
"""
|
|
pytest.importorskip("serial")
|
|
import os as _os
|
|
import stat as _stat
|
|
|
|
from serial.tools import list_ports
|
|
|
|
# The scan does not list platform UARTs, so it gives [].
|
|
monkeypatch.setattr(list_ports, "comports", list)
|
|
monkeypatch.setattr(_os.path, "exists", lambda p: p == "/dev/ttyS1")
|
|
monkeypatch.setattr(_os, "stat", lambda p: SimpleNamespace(st_mode=_stat.S_IFCHR))
|
|
|
|
# A character device that exists is present. A node that is gone is absent (an unplug).
|
|
assert connection.serial_port_present("/dev/ttyS1")
|
|
assert not connection.serial_port_present("/dev/ttyUSB9")
|
|
|
|
|
|
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 from which the screen paints.
|
|
|
|
The walk is hundreds of small sysfs reads, and each read waits for its turn at the GIL.
|
|
A worker thread draws a map frame at the same time. When the walk ran on the loop, the
|
|
screen of the PicoCalc froze for up to 1.2 s in each 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 when the port of the connected device leaves the list."""
|
|
ctx = _make_ctx(tmp_path)
|
|
# The test acts as a live session with real hardware on COM_TEST. Nobody can unplug the
|
|
# simulator.
|
|
ctx.mock = False
|
|
ctx._device = _FakeSerialDevice("COM_TEST") # The device is connected, so is_connected is 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 # The port is present on the first poll, and gone after that.
|
|
|
|
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, because 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 that MeshTerm sent fires the watcher at once, but the port does not go away.
|
|
|
|
A board behind a USB-UART bridge keeps its port during a reboot. Thus the poll alone
|
|
does not 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"
|
|
# The port never leaves.
|
|
monkeypatch.setattr(connection, "serial_port_present", lambda port: True)
|
|
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) # This is less than one liveness poll.
|
|
assert ctx.take_reboot() is True
|
|
# The first call consumed the reboot, so the next drop is an unplug.
|
|
assert ctx.take_reboot() is False
|
|
with pytest.raises(asyncio.TimeoutError): # Also, 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 that the board never acknowledges returns at once, not at the link loss.
|
|
|
|
Over Bluetooth, the write is a write with a response. A board that restarts on the
|
|
command can drop the link before it acknowledges the write. MeshTerm once waited for
|
|
this, and the reboot dialog stayed open until 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 # The write is still pending, and a set holds it.
|
|
for write in list(connection._REBOOT_WRITES):
|
|
write.cancel() # This is 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:
|
|
"""The early return is for a write that hangs. A write 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 the liveness tests.
|
|
|
|
Its :meth:`link_present` reads an ``is_connected`` flag. The real BLE device reads the
|
|
connection state of the meshcore client in the same way.
|
|
"""
|
|
|
|
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 when the connection flag of the peripheral becomes 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` that records pushes and pops for dialog tests."""
|
|
|
|
def __init__(self) -> None:
|
|
self.stack: list = []
|
|
self.root = None # No menu declared itself as the root, so 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 dialog closes itself, with no key press, when the port of the device appears again."""
|
|
ctx = _make_ctx(tmp_path)
|
|
ctx.mock = False
|
|
ctx._device = object() # A stand-in for a connected device.
|
|
ctx._active_port = "COM_TEST"
|
|
|
|
polls = {"n": 0}
|
|
|
|
def fake_present(port: str) -> bool:
|
|
polls["n"] += 1
|
|
return polls["n"] >= 2 # The port is absent on the first poll, and back after that.
|
|
|
|
async def fake_reconnect(self: AppContext, *, ble_device: object | None = None) -> None:
|
|
self._device = object() # A new 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 # The app reconnected. The user did not quit.
|
|
assert session.stack == [] # The dialog was removed.
|
|
finally:
|
|
ctx.repo.close()
|
|
|
|
|
|
async def test_handle_disconnect_quits_when_user_presses_quit(tmp_path: Path, monkeypatch) -> None:
|
|
"""Enter (the Abort button) quits, also 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 Quit button is the only way out.
|
|
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:
|
|
# When the dialog is on the stack, send 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 (it resolves ``"quit"``). Esc does nothing, so a wrong key press cannot quit."""
|
|
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 for ever (a stand-in for an idle worker or watcher)."""
|
|
await asyncio.Event().wait()
|
|
|
|
|
|
async def test_session_loop_quits_without_touching_the_disconnect_path(monkeypatch) -> None:
|
|
"""When the menu loop returns (the user quit), the watcher stops and no dialog shows."""
|
|
handled = {"n": 0}
|
|
|
|
async def fake_menu(ctx, session):
|
|
return None # The 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 code never entered the disconnect path.
|
|
|
|
|
|
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 asks."""
|
|
resets = {"n": 0}
|
|
cancelled = {"seen": False}
|
|
|
|
async def busy_menu(ctx, session):
|
|
try:
|
|
await asyncio.Event().wait() # A tool is in progress. The loop must cancel it.
|
|
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 loop cancelled the worker that was in progress.
|
|
assert resets["n"] == 1 # The loop unwound the stack 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 arms the watcher again."""
|
|
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: the disconnect interrupts it.
|
|
return None # Second pass: the user quits.
|
|
|
|
async def watch(ctx):
|
|
state["watch"] += 1
|
|
if state["watch"] == 1:
|
|
return None # Fire one time.
|
|
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 # The loop handled one disconnect.
|
|
assert state["menu"] == 2 # A new menu ran after the reconnect.
|
|
|
|
|
|
class _WedgedMeshCore:
|
|
"""A fake meshcore client whose graceful ``disconnect()`` never returns.
|
|
|
|
It reproduces a deadlock of the library when the dispatcher stops. In this deadlock,
|
|
``queue.join()`` waits for events that are still in the queue after the processor task
|
|
exited. The deadlock once held the exit of MeshTerm until the watchdog killed the
|
|
process. The class records if 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:
|
|
# The code calls this method as the graceful teardown (through the attribute lookup on
|
|
# the client, with no manager) and as the raw transport close. The graceful call
|
|
# hangs. The raw close is the call that comes after the forced stop.
|
|
if not self.force_stopped:
|
|
# This is the deadlock of the dispatcher. It never returns.
|
|
await asyncio.Event().wait()
|
|
self.raw_closed = True
|
|
|
|
def stop(self) -> None:
|
|
self.force_stopped = True
|
|
|
|
|
|
async def test_disconnect_bounds_a_wedged_client_teardown(monkeypatch) -> None:
|
|
"""MeshTerm abandons a graceful teardown that deadlocks, and closes the transport by force."""
|
|
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) # It must not hang.
|
|
|
|
assert wedged.force_stopped # MeshTerm cancelled the dispatcher task at once.
|
|
assert wedged.raw_closed # MeshTerm still released the port or link.
|
|
assert dev._mc is None # A second disconnect does nothing.
|
|
|
|
|
|
async def test_disconnect_graceful_path_needs_no_force(monkeypatch) -> None:
|
|
"""A healthy teardown completes gracefully, and the code never enters the forced path."""
|
|
|
|
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 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 that stands in for a live BLE session whose companion 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 when it hears the companion, with the handle that the scan found."""
|
|
ctx = _ble_ctx(tmp_path)
|
|
fresh = object() # The live BLEDevice that the scan produced.
|
|
scans: list[str] = []
|
|
|
|
async def fake_find(address: str, timeout: float = 0.0) -> object | None:
|
|
scans.append(address)
|
|
# The companion is silent on the first round, then the scan hears it.
|
|
return fresh if len(scans) >= 2 else None
|
|
|
|
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 # The app reconnected. The user did not quit.
|
|
# MeshTerm waited until it heard the device. It did not retry the connect blindly.
|
|
assert scans == [ctx._active_address, ctx._active_address]
|
|
assert len(opened) == 1
|
|
# Then MeshTerm opened the device again with the handle that the scan produced. It
|
|
# did not use an old handle.
|
|
assert opened == [fresh]
|
|
finally:
|
|
ctx.repo.close()
|
|
|
|
|
|
async def test_ble_reconnect_releases_the_dead_link_before_scanning(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""MeshTerm closes the old link first, so the peripheral can 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 # MeshTerm released the dead companion...
|
|
assert held_at_scan == [False] # ...before it listened for the companion.
|
|
finally:
|
|
ctx.repo.close()
|
|
|
|
|
|
async def test_reconnect_prefers_the_freshly_scanned_ble_handle(
|
|
tmp_path: Path, monkeypatch
|
|
) -> None:
|
|
"""A handle that the caller passes to reconnect opens the link.
|
|
|
|
MeshTerm does not use the old handle of the startup scan.
|
|
"""
|
|
address = "AA:BB:00:00:00:01"
|
|
ctx = _make_ctx(tmp_path)
|
|
ctx.mock = False
|
|
ctx.ble_override = address
|
|
stale = object() # The handle that the scan of the picker produced when the session opened.
|
|
fresh = object() # The handle of the peripheral that 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 that 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 handle of the new scan, never the old handle.
|
|
assert ctx._ble_handle is None # The connection that it opened consumed the handle.
|
|
|
|
# If the reconnect has no new scan, the handle of the picker is still the best handle.
|
|
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:
|
|
"""Two releases are harmless, and reconnect still restores what was running."""
|
|
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 stopped when the link went down.
|
|
await ctx.release_link() # A second release has nothing to do, and it says nothing.
|
|
|
|
await ctx.reconnect()
|
|
|
|
# The intent to resume survived both releases.
|
|
assert ctx.events.active and ctx.monitor.active
|
|
finally:
|
|
await ctx.aclose()
|
|
|
|
|
|
class _AbandonedBleakClient:
|
|
"""A ``bleak`` client whose peripheral vanished. It is already down, and nobody returns it.
|
|
|
|
The meshcore transport leaves this client when a link drops. ``is_connected`` is
|
|
already ``False``. Thus each guarded teardown in the library does not touch the client.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.is_connected = False
|
|
self.disconnect_calls = 0
|
|
|
|
async def disconnect(self) -> None:
|
|
self.disconnect_calls += 1
|
|
|
|
|
|
class _DroppedBleConnection:
|
|
"""The ``BLEConnection`` of meshcore after its callback for a dropped link ran.
|
|
|
|
The reference to the client is gone.
|
|
"""
|
|
|
|
def __init__(self) -> None:
|
|
self.client = None # handle_disconnect set this to the value that the constructor had.
|
|
self.disconnect_calls = 0
|
|
|
|
async def disconnect(self) -> None:
|
|
self.disconnect_calls += 1 # This guards on self.client, so it reaches nothing.
|
|
|
|
|
|
class _DroppedMeshCore:
|
|
"""A meshcore client whose connection manager also believes that 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 # This guards on _is_connected, so it reaches nothing.
|
|
|
|
def stop(self) -> None:
|
|
pass
|
|
|
|
|
|
async def test_a_vanished_peripheral_still_gets_its_bleak_client_closed() -> None:
|
|
"""MeshTerm releases the client, also if each guard of the library says nothing is open."""
|
|
dev = connection.MeshCoreDevice(transport="ble", address="AA:BB:00:00:00:01")
|
|
dropped = _DroppedBleConnection()
|
|
dev._mc = _DroppedMeshCore(dropped)
|
|
abandoned = _AbandonedBleakClient()
|
|
dev._ble_client = abandoned # The client that MeshTerm kept at the connect step.
|
|
|
|
await dev.disconnect()
|
|
|
|
# The teardown of the library reached nothing, because the connection already let the
|
|
# client go.
|
|
assert dropped.client is None
|
|
# The teardown of MeshTerm closed the client in spite of this. This releases the WinRT
|
|
# handles that the next connect needs.
|
|
assert abandoned.disconnect_calls == 1
|
|
assert dev._ble_client is None # MeshTerm does not close the client two times.
|
|
assert dev._mc is None
|
|
|
|
|
|
async def test_releasing_the_bleak_client_is_idempotent_and_never_raises() -> None:
|
|
"""A second disconnect has nothing to do, and MeshTerm ignores a close that fails."""
|
|
dev = connection.MeshCoreDevice(transport="ble", address="AA:BB:00:00:00:01")
|
|
|
|
await dev.disconnect() # Nothing is connected.
|
|
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() # It must not raise, because the teardown is a 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 MeshTerm releases the client 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 # Also, MeshTerm released the client in each case.
|
|
|
|
|
|
# --- What the user sees when a connection does not open ----------------------------------------
|
|
|
|
|
|
def _refusal() -> Exception:
|
|
"""A connect failure as the context raises it: its own wrapper around 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 an open that failed, nothing claims to be connected, and the next caller tries again.
|
|
|
|
The device once stayed in the context without an open connection. ``is_connected`` said
|
|
yes. Each tool that ran after the failed start got this device, and it failed with "not
|
|
connected" and not with 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 # Each time is a new attempt. It is not the dead device.
|
|
assert built[0].disconnects == 1 # MeshTerm closed the part of it that opened.
|
|
finally:
|
|
ctx.repo.close()
|
|
|
|
|
|
def test_a_failure_reads_as_the_connection_s_own_sentence() -> None:
|
|
"""The dialog shows the named sentence, not the wrapper that repeats 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 # The failure has a name, so the log has no 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 capitalizes the lowercase sentence of the command line, but never a name.
|
|
|
|
A sentence that starts with a port, a host, or an address keeps that spelling, because
|
|
the user must match it with their own spelling.
|
|
"""
|
|
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 that MeshTerm cannot name points to the log, and only if the log has 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}"
|
|
|
|
# The log level is ERROR.
|
|
monkeypatch.setattr(device_picker, "log_file_for", lambda level: None)
|
|
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.
|
|
|
|
MeshTerm ignores the first failure, because a board can refuse one time while it boots.
|
|
Thus the reason appears from the second failure.
|
|
"""
|
|
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] # After one failure, it only retried.
|
|
assert "in use by another program" in seen[2] # After two failures, the dialog says why.
|
|
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:
|
|
"""If the user unplugs the device after it refused, MeshTerm waits for it again."""
|
|
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()
|
|
# The device refuses two times, the user pulls it, and it comes back.
|
|
present = iter([True, True, False, True])
|
|
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] # The dialog says why, while it refuses.
|
|
# The device is gone again, so MeshTerm waits.
|
|
assert "in use by another program" not in bodies[3]
|
|
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:
|
|
"""If the command line names the device, there is no picker, so the 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 # There is no 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 run of a tool 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 the tool 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 shows the sentence of the connection, also for a lost link.
|
|
|
|
The watcher has no connected device to notice, so no other code says the reason.
|
|
"""
|
|
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:
|
|
"""The bracketed words of a library are shown. The markup does not take them as a 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"
|