A returning companion is heard, not guessed at
Bluetooth notices a drop cleanly — the client's connection flag flips the
moment the peripheral goes — but the way back was two guesses stacked on each
other, and neither held (JP, 2026-09-04).
The reconnect flow gave serial a real question to ask ("has the port
re-enumerated?") and gave Bluetooth nothing: it simply attempted the connect
every couple of seconds. Each of those blind attempts drove bleak's own
internal address lookup into a device that wasn't advertising, which is a slow
failure, so the attempts sprawled across and past the window in which the
companion actually came back. And when one finally did land, it landed holding
the `BLEDevice` the startup scan produced — a handle that names a peripheral
the OS no longer has, because a device that power-cycles comes back as a new
one. The session opened over BLE and then, having dropped, could not find its
way back to a radio sitting a foot away with its light on.
BLE now gets the same shape serial has, asked of the only registry Bluetooth
keeps: `find_ble_device` watches for that one address's advertisement and
returns the instant it is heard. The answer arrives with a live handle, so the
listening and the fixing are one step — the reconnect opens the peripheral
that is on the air now, and `reconnect(ble_device=...)` prefers it over
anything the picker scanned. A miss costs a scan window rather than a failed
connect, and nothing is attempted against a device that isn't there.
The ordering is load-bearing, so the teardown splits out as `release_link` and
runs before the first scan: a companion we are still holding a link to does
not advertise, and the old flow's first act was a connect attempt that dropped
the dead client on its way past. Waiting first would have waited out a device
that was powered on and silent purely because we hadn't let go of it. The
resume intent is captured there and held across retries exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
99bfdade64
commit
c090db829c
4 files changed
+300
-33
No files matched your search
+50
-18
@@ -116,6 +116,11 @@ class AppContext:
|
||||
_active_transport: Optional[str] = field(default=None, init=False, repr=False)
|
||||
_active_address: Optional[str] = field(default=None, init=False, repr=False)
|
||||
_active_endpoint: Optional[str] = field(default=None, init=False, repr=False)
|
||||
#: A live ``bleak.BLEDevice`` handed to :meth:`reconnect` by the flow that just heard the
|
||||
#: companion advertise again. Preferred over the startup scan's handle when reopening the
|
||||
#: link, because a device that dropped and came back is a *new* peripheral to the OS and
|
||||
#: the older handle no longer names it. Typed ``object`` so ``bleak`` stays optional.
|
||||
_ble_handle: Optional[object] = field(default=None, init=False, repr=False)
|
||||
unpair_on_exit: bool = field(default=False, init=False, repr=False)
|
||||
#: Set by the config editor just before it sends a reboot command, so the session's
|
||||
#: disconnect watcher can label the ensuing (expected) link drop as a reboot in
|
||||
@@ -464,12 +469,14 @@ class AppContext:
|
||||
# resolution, and no re-scan needed to reconnect to a known address.
|
||||
ble_address, ble_pin = self._resolve_ble_endpoint()
|
||||
if ble_address:
|
||||
# When this session's scan discovered the device, hand its live BLEDevice to the
|
||||
# When a scan has produced a live BLEDevice for this address, hand it to the
|
||||
# connection so bleak opens it directly instead of re-discovering the address (a
|
||||
# fresh internal scan that intermittently misses a slow-advertising companion).
|
||||
# A remembered/explicit address has no scan result and connects by address alone.
|
||||
# A handle from the reconnect watch wins over the startup picker's: it was scanned
|
||||
# seconds ago, after the drop, so it names the peripheral that is on the air *now*.
|
||||
selected = self.selected_device
|
||||
ble_device = (
|
||||
ble_device = self._ble_handle or (
|
||||
selected.ble_device
|
||||
if selected is not None and selected.is_ble and selected.address == ble_address
|
||||
else None
|
||||
@@ -614,31 +621,27 @@ class AppContext:
|
||||
if restored:
|
||||
self.log.info("channels: restored %d remembered channel(s) to the device", restored)
|
||||
|
||||
async def reconnect(self) -> None:
|
||||
"""Drop a lost device connection and rebuild it, restoring live services.
|
||||
async def release_link(self) -> None:
|
||||
"""Tear down the dead connection and the services riding on it, keeping what to resume.
|
||||
|
||||
Called after the companion link is detected as gone (see
|
||||
:func:`~meshterm.core.connection.is_connection_lost`). Tears down the dead
|
||||
connection and the services riding on it, opens a fresh connection to the same
|
||||
device, then restarts whatever was running before — so passive monitoring and chat
|
||||
recording resume transparently across a replug.
|
||||
The first half of :meth:`reconnect`, split out because it is also worth doing *before*
|
||||
going looking for the device. Over Bluetooth that ordering is load-bearing: a companion
|
||||
only advertises while nothing is connected to it, so a peripheral we are still holding
|
||||
a link to — however dead we believe that link to be — stays off the air and can never
|
||||
be found by the scan waiting for it to come back. Releasing first puts it back on the
|
||||
air, and is harmless on the transports that don't care.
|
||||
|
||||
Raises:
|
||||
Exception: If a new connection could not be opened (e.g. the device is still
|
||||
absent); the caller can surface it and offer to retry.
|
||||
What was running is captured *once* and held across retries: this stops the services,
|
||||
so a later attempt would otherwise read the now-idle flags and restore nothing. The
|
||||
intent is cleared only after :meth:`reconnect` actually succeeds. Idempotent — calling
|
||||
it again with nothing connected is a no-op.
|
||||
"""
|
||||
# Remember what was live so it can be restored after the reconnect — but capture it
|
||||
# only *once*, and hold it across retries. The teardown below stops the services, so
|
||||
# a second attempt (after the first failed because the device wasn't back yet) would
|
||||
# otherwise read the now-idle flags and restore nothing. The intent is cleared only
|
||||
# after a reconnect actually succeeds.
|
||||
if self._resume_intent is None:
|
||||
self._resume_intent = (
|
||||
self._events is not None and self._events.active,
|
||||
self._monitor is not None and self._monitor.active,
|
||||
self._chat is not None and self._chat.active,
|
||||
)
|
||||
resume_events, resume_monitor, resume_chat = self._resume_intent
|
||||
|
||||
# Release the stale hub/service subscriptions and discard the dead device. The
|
||||
# subscriptions are in-process (to the hub), so they survive the link drop and must
|
||||
@@ -661,6 +664,32 @@ class AppContext:
|
||||
if self._devstate is not None:
|
||||
self._devstate.reset()
|
||||
|
||||
async def reconnect(self, *, ble_device: Optional[object] = None) -> None:
|
||||
"""Drop a lost device connection and rebuild it, restoring live services.
|
||||
|
||||
Called after the companion link is detected as gone (see
|
||||
:func:`~meshterm.core.connection.is_connection_lost`). Tears down the dead
|
||||
connection and the services riding on it, opens a fresh connection to the same
|
||||
device, then restarts whatever was running before — so passive monitoring and chat
|
||||
recording resume transparently across a replug.
|
||||
|
||||
Args:
|
||||
ble_device: The live ``bleak.BLEDevice`` the caller just scanned for this address,
|
||||
when it has one (see :func:`~meshterm.core.discovery.find_ble_device`). Any
|
||||
previously-held handle is dropped either way: the peripheral that comes back
|
||||
from a power-cycle is a new one to the OS, so the scan result the session
|
||||
opened with is not merely stale but wrong, and reusing it fails the connect
|
||||
on Windows while the device sits there advertising. Passing ``None`` (serial,
|
||||
TCP, or a BLE retry with nothing scanned) reconnects by address alone.
|
||||
|
||||
Raises:
|
||||
Exception: If a new connection could not be opened (e.g. the device is still
|
||||
absent); the caller can surface it and offer to retry.
|
||||
"""
|
||||
self._ble_handle = ble_device
|
||||
await self.release_link()
|
||||
resume_events, resume_monitor, resume_chat = self._resume_intent
|
||||
|
||||
# Open a fresh connection (raises if the device still can't be reached, leaving the
|
||||
# remembered intent in place for the next attempt), then restart whatever was running
|
||||
# before it dropped and clear the intent now that we're back.
|
||||
@@ -672,6 +701,9 @@ class AppContext:
|
||||
if resume_chat:
|
||||
await self.chat.start()
|
||||
self._resume_intent = None
|
||||
# The handle has been opened; it names a link that is now live rather than a device to
|
||||
# go find, so the next drop starts from a clean slate and scans afresh.
|
||||
self._ble_handle = None
|
||||
|
||||
async def _node_name(self) -> str:
|
||||
"""Return the connected device's own mesh node name, or ``""`` if unavailable."""
|
||||
|
||||
@@ -49,6 +49,12 @@ BLE_NAME_PREFIX = "MeshCore"
|
||||
#: advertisement, short enough not to stall the startup splash noticeably.
|
||||
BLE_SCAN_TIMEOUT_S = 5.0
|
||||
|
||||
#: Default seconds to watch for one *known* companion's advertisement while waiting for it to
|
||||
#: come back (see :func:`find_ble_device`). Deliberately longer than the startup scan: nothing
|
||||
#: is waiting on it, the watch ends the instant the device is heard, and a companion that just
|
||||
#: powered on can take several advertising intervals to be picked up by the OS.
|
||||
BLE_PRESENCE_TIMEOUT_S = 8.0
|
||||
|
||||
# USB vendor IDs frequently seen on MeshCore/Meshtastic companion hardware and the
|
||||
# USB-UART bridges they ship with. Used only to name, flag, and sort likely devices.
|
||||
KNOWN_LORA_VIDS: dict[int, str] = {
|
||||
@@ -406,6 +412,49 @@ async def discover_ble_devices(timeout: float = BLE_SCAN_TIMEOUT_S) -> list[Disc
|
||||
return devices
|
||||
|
||||
|
||||
async def find_ble_device(
|
||||
address: str, timeout: float = BLE_PRESENCE_TIMEOUT_S
|
||||
) -> Optional[object]:
|
||||
"""Watch for one known companion's advertisement, returning its live ``BLEDevice``.
|
||||
|
||||
The Bluetooth counterpart to :func:`~meshterm.core.connection.serial_port_present`, and it
|
||||
answers the same question the reconnect flow asks of a serial port: *is the device back
|
||||
yet?* Unlike a serial port there is no OS registry to consult — a peripheral exists only
|
||||
while it is advertising — so the probe is a scan. ``find_device_by_address`` returns the
|
||||
**moment** the address is heard rather than at the end of the window, so a generous
|
||||
``timeout`` costs nothing when the device is present and simply keeps listening when it
|
||||
isn't.
|
||||
|
||||
The returned handle matters as much as the answer. A companion that has been power-cycled
|
||||
is a *new* peripheral to the OS: the ``BLEDevice`` captured by an earlier scan names a
|
||||
connection endpoint that no longer resolves, and on Windows handing bleak that stale object
|
||||
fails the connect outright. Answering the presence question with a freshly-scanned handle
|
||||
means the reconnect that follows opens the device that is actually there (see
|
||||
:meth:`~meshterm.context.AppContext.reconnect`).
|
||||
|
||||
Never raises: a missing ``bleak``, a disabled adapter, or a driver hiccup reads as "not
|
||||
advertising", which is the truthful answer for a device we cannot currently reach.
|
||||
|
||||
Args:
|
||||
address: The Bluetooth address to listen for (as reported by discovery).
|
||||
timeout: Seconds to keep listening before giving up on this round.
|
||||
|
||||
Returns:
|
||||
The live ``bleak.BLEDevice`` if the address advertised within ``timeout``, else
|
||||
``None``. Typed ``object`` so ``bleak`` stays an optional dependency.
|
||||
"""
|
||||
try:
|
||||
from bleak import BleakScanner
|
||||
except ImportError: # bleak not installed → BLE simply unavailable
|
||||
return None
|
||||
|
||||
try:
|
||||
return await BleakScanner.find_device_by_address(address, timeout=timeout)
|
||||
except Exception as exc: # noqa: BLE001 - no adapter / OS Bluetooth off / driver hiccup
|
||||
_log.debug("BLE presence scan for %s unavailable: %s", address, exc)
|
||||
return None
|
||||
|
||||
|
||||
async def discover_all(
|
||||
*, ble: bool = True, ble_timeout: float = BLE_SCAN_TIMEOUT_S
|
||||
) -> list[DiscoveredDevice]:
|
||||
|
||||
+48
-13
@@ -63,6 +63,16 @@ _LIVENESS_POLL_S = 2.0
|
||||
#: can't fire a false "disconnected" prompt (seconds).
|
||||
_LIVENESS_CONFIRM_S = 0.4
|
||||
|
||||
#: How long each round of the reconnect flow listens for a dropped Bluetooth companion's
|
||||
#: advertisement (seconds). The scan ends the instant the address is heard, so this is a
|
||||
#: ceiling on a *miss*, not a cost paid on success — long enough to cover several advertising
|
||||
#: intervals of a companion that has just powered on.
|
||||
_BLE_PRESENCE_SCAN_S = 8.0
|
||||
|
||||
#: Pause between BLE presence scans (seconds). The scan window is the real wait; this only
|
||||
#: stops the loop spinning when scanning fails instantly (no adapter, Bluetooth switched off).
|
||||
_BLE_RESCAN_PAUSE_S = 1.0
|
||||
|
||||
|
||||
def _arm_exit_watchdog(seconds: float = _EXIT_WATCHDOG_S) -> None:
|
||||
"""Guarantee the process terminates even if the exit path wedges.
|
||||
@@ -991,34 +1001,59 @@ async def _animate_dialog(session: TuiSession, dialog: ReconnectDialog) -> None:
|
||||
async def _auto_reconnect(ctx: AppContext, dialog: ReconnectDialog) -> None:
|
||||
"""Poll for the device to return, reconnect when it does, then dismiss ``dialog``.
|
||||
|
||||
For serial, waits for the OS to re-enumerate the port the connection was opened on before
|
||||
each attempt, so a reconnect is only tried once there's a device to reach. For Bluetooth
|
||||
there is no cheap "is it back yet" probe short of a full scan, so it simply attempts a
|
||||
reconnect each interval — ``create_ble`` connects directly by address and fails fast when
|
||||
the peripheral isn't in range. Either way a failed attempt (the endpoint is back but the
|
||||
board isn't ready yet, or it dropped again) just loops and retries. On the first success
|
||||
the dialog's future is resolved with ``"reconnected"``, dismissing the popup. Runs until
|
||||
it succeeds or the task is cancelled (the user quit).
|
||||
The dead link is released up front (:meth:`~meshterm.context.AppContext.release_link`) so
|
||||
the peripheral is free to advertise while we wait for it. Each transport then waits for its
|
||||
own evidence that the device is reachable before touching the radio, so an attempt is only ever made against something that is actually there. Serial
|
||||
waits for the OS to re-enumerate the port the connection was opened on. Bluetooth listens
|
||||
for the companion's advertisement (:func:`~meshterm.core.discovery.find_ble_device`), which
|
||||
is the same question asked of the only registry BLE has, and hands the resulting live
|
||||
handle to :meth:`~meshterm.context.AppContext.reconnect` — a power-cycled peripheral is a
|
||||
new one to the OS, so scanning is both how we notice it came back and how we get something
|
||||
that can open it. Blind-retrying instead, as this used to, was doubly wrong: it drove
|
||||
bleak's own internal address lookup into a device that wasn't advertising (a slow failure
|
||||
that overlapped the window the device returned in) and, when one finally landed, it landed
|
||||
with the startup scan's dead handle. TCP has no such probe and simply retries the connect,
|
||||
which fails fast against an absent endpoint.
|
||||
|
||||
A failed attempt (the device is back but the board isn't ready yet, or it dropped again)
|
||||
just loops and retries. On the first success the dialog's future is resolved with
|
||||
``"reconnected"``, dismissing the popup. Runs until it succeeds or the task is cancelled
|
||||
(the user quit).
|
||||
|
||||
Args:
|
||||
ctx: The shared application context.
|
||||
dialog: The reconnect dialog to dismiss once the link is back.
|
||||
"""
|
||||
from ..core.connection import serial_port_present
|
||||
from ..core.discovery import find_ble_device
|
||||
|
||||
# Put the dead link down before going looking for the device. A Bluetooth companion we are
|
||||
# still nominally connected to does not advertise, so the scan below would wait out a
|
||||
# peripheral that is powered on and silent purely because we never let go of it.
|
||||
await ctx.release_link()
|
||||
|
||||
while True:
|
||||
# Serial: wait for the port to re-appear before touching the radio. BLE/TCP (and an
|
||||
# unknown/deferred serial port): skip the wait and just retry the reconnect itself —
|
||||
# ``create_ble``/``create_tcp`` connect by address/endpoint and fail fast when absent.
|
||||
if ctx.active_transport == "serial":
|
||||
ble_device: object | None = None
|
||||
transport = ctx.active_transport
|
||||
if transport == "serial":
|
||||
port = ctx.active_port
|
||||
# Off the loop: the probe enumerates ports, which blocks long enough to stall
|
||||
# the very spinner this dialog is showing while it waits.
|
||||
if port is not None and not await asyncio.to_thread(serial_port_present, port):
|
||||
await asyncio.sleep(_LIVENESS_POLL_S)
|
||||
continue
|
||||
elif transport == "ble":
|
||||
address = ctx.active_address
|
||||
if address is not None:
|
||||
ble_device = await find_ble_device(address, timeout=_BLE_PRESENCE_SCAN_S)
|
||||
if ble_device is None:
|
||||
# The scan window is itself the wait — it just spent several seconds
|
||||
# listening — so only a brief pause is needed to keep a scan that fails
|
||||
# instantly (Bluetooth switched off mid-session) from spinning.
|
||||
await asyncio.sleep(_BLE_RESCAN_PAUSE_S)
|
||||
continue
|
||||
try:
|
||||
await ctx.reconnect()
|
||||
await ctx.reconnect(ble_device=ble_device)
|
||||
except Exception: # noqa: BLE001 - not reachable yet; keep the popup up and retry
|
||||
await asyncio.sleep(_LIVENESS_POLL_S)
|
||||
continue
|
||||
|
||||
+153
-2
@@ -16,12 +16,14 @@ 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
|
||||
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
|
||||
|
||||
@@ -743,7 +745,7 @@ async def test_handle_disconnect_auto_reconnects_when_port_returns(
|
||||
polls["n"] += 1
|
||||
return polls["n"] >= 2 # absent on the first poll, back thereafter
|
||||
|
||||
async def fake_reconnect(self: AppContext) -> None:
|
||||
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)
|
||||
@@ -957,3 +959,152 @@ async def test_disconnect_graceful_path_needs_no_force(monkeypatch) -> None:
|
||||
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()
|
||||
Reference in new issue
Block a user