fix(k1): restore canonical local connection lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 13:52:46 +03:00
parent 52da9b75b7
commit aff331082f
19 changed files with 2169 additions and 274 deletions
+145 -16
View File
@@ -1,7 +1,10 @@
import asyncio
from collections.abc import Iterator
from types import SimpleNamespace
from typing import Any
import pytest
from bleak.exc import BleakDeviceNotFoundError
from bleak.exc import BleakDeviceNotFoundError, BleakGATTProtocolError
import k1link.device_plugins.xgrids_k1.ble.ap_activation as ap_module
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
@@ -15,6 +18,27 @@ from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import WifiStatus
@pytest.fixture(autouse=True)
def reset_runtime_handle_lease() -> Iterator[None]:
with scanner_module._runtime_handle_lock: # noqa: SLF001
scanner_module._runtime_handles.clear() # noqa: SLF001
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
yield
with scanner_module._runtime_handle_lock: # noqa: SLF001
scanner_module._runtime_handles.clear() # noqa: SLF001
scanner_module._runtime_handle_observed_at_monotonic = None # noqa: SLF001
scanner_module._runtime_handle_generation = 0 # noqa: SLF001
def _seed_scan_lease(handles: dict[str, object], *, observed_at: float) -> None:
with scanner_module._runtime_handle_lock: # noqa: SLF001
scanner_module._runtime_handles.clear() # noqa: SLF001
scanner_module._runtime_handles.update(handles) # type: ignore[arg-type] # noqa: SLF001
scanner_module._runtime_handle_observed_at_monotonic = observed_at # noqa: SLF001
scanner_module._runtime_handle_generation += 1 # noqa: SLF001
def test_build_ap_activation_frame_matches_reviewed_lixelgo_layout() -> None:
frame = build_ap_activation_frame()
@@ -42,34 +66,36 @@ def test_ap_ready_requires_the_reviewed_byte_51_flag() -> None:
assert is_ap_ready_status(_status(reserved=1))
def test_ap_activation_requires_fresh_rediscovery_before_connecting(
def test_ap_activation_uses_retained_handle_without_rediscovery(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device_id = "synthetic-corebluetooth-uuid"
stale_handle = object()
retained_handle = object()
rediscovery_calls: list[tuple[str, float]] = []
client_calls: list[object] = []
async def missing_device(address: str, *, timeout: float) -> None:
rediscovery_calls.append((address, timeout))
return None
class SelectedHandleObserved(RuntimeError):
pass
class ForbiddenClient:
async def forbidden_rediscovery(address: str, *, timeout: float) -> None:
rediscovery_calls.append((address, timeout))
raise AssertionError("a fresh explicit scan handle must be used directly")
class CapturingClient:
def __init__(self, device: object, **_kwargs: object) -> None:
client_calls.append(device)
raise AssertionError("a failed fresh discovery must stop before the BLE write session")
raise SelectedHandleObserved
# Seed the real retained-handle cache so the test fails if the mutating
# path ever regresses to discovered_device(...)-first behavior.
monkeypatch.setitem(scanner_module._runtime_handles, device_id, stale_handle) # noqa: SLF001
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
monkeypatch.setattr(
ap_module.BleakScanner,
"find_device_by_address",
missing_device,
forbidden_rediscovery,
)
monkeypatch.setattr(ap_module, "BleakClient", ForbiddenClient)
monkeypatch.setattr(ap_module, "BleakClient", CapturingClient)
with pytest.raises(BleakDeviceNotFoundError):
with pytest.raises(SelectedHandleObserved) as caught:
asyncio.run(
ap_module.activate_device_ap_once(
device_id,
@@ -77,5 +103,108 @@ def test_ap_activation_requires_fresh_rediscovery_before_connecting(
)
)
assert rediscovery_calls == [(device_id, 1.0)]
assert client_calls == []
assert rediscovery_calls == []
assert client_calls == [retained_handle]
assert caught.value.operation_stage == "connect" # type: ignore[attr-defined]
assert caught.value.device_write_attempted is False # type: ignore[attr-defined]
assert caught.value.device_write_confirmed is False # type: ignore[attr-defined]
def test_ap_activation_does_not_fallback_when_fresh_scan_omits_device(
monkeypatch: pytest.MonkeyPatch,
) -> None:
rediscovery_calls: list[tuple[str, float]] = []
async def forbidden_rediscovery(address: str, *, timeout: float) -> None:
rediscovery_calls.append((address, timeout))
return None
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
_seed_scan_lease({}, observed_at=100.0)
monkeypatch.setattr(
ap_module.BleakScanner,
"find_device_by_address",
forbidden_rediscovery,
)
with pytest.raises(BleakDeviceNotFoundError) as caught:
asyncio.run(
ap_module.activate_device_ap_once(
"not-in-fresh-scan",
timeout_seconds=1.0,
)
)
assert rediscovery_calls == []
assert caught.value.operation_stage == "resolution" # type: ignore[attr-defined]
assert caught.value.device_write_attempted is False # type: ignore[attr-defined]
assert caught.value.device_write_confirmed is False # type: ignore[attr-defined]
def test_ap_activation_write_error_keeps_type_and_adds_safe_gatt_facts(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device_id = "synthetic-corebluetooth-uuid"
retained_handle = object()
service = SimpleNamespace(uuid=ap_module.SERVICE_UUID)
write_characteristic = SimpleNamespace(
uuid=ap_module.WRITE_CHARACTERISTIC_UUID,
service_uuid=ap_module.SERVICE_UUID,
properties=["write"],
max_write_without_response_size=512,
)
status_characteristic = SimpleNamespace(
uuid=ap_module.STATUS_CHARACTERISTIC_UUID,
service_uuid=ap_module.SERVICE_UUID,
properties=["read"],
)
baseline = bytearray(52)
class FakeServices:
def get_service(self, uuid: str) -> object | None:
return service if uuid == ap_module.SERVICE_UUID else None
def get_characteristic(self, uuid: str) -> object | None:
if uuid == ap_module.WRITE_CHARACTERISTIC_UUID:
return write_characteristic
if uuid == ap_module.STATUS_CHARACTERISTIC_UUID:
return status_characteristic
return None
class FailingWriteClient:
def __init__(self, device: object, **_kwargs: object) -> None:
assert device is retained_handle
self.services = FakeServices()
self.name = "XGR-K1"
self.is_connected = True
async def __aenter__(self) -> Any:
return self
async def __aexit__(self, *_args: object) -> None:
return None
async def read_gatt_char(self, _characteristic: object) -> bytes:
return bytes(baseline)
async def write_gatt_char(self, *_args: object, **_kwargs: object) -> None:
raise BleakGATTProtocolError(0x03)
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
monkeypatch.setattr(ap_module, "BleakClient", FailingWriteClient)
with pytest.raises(BleakGATTProtocolError) as caught:
asyncio.run(
ap_module.activate_device_ap_once(
device_id,
timeout_seconds=1.0,
)
)
error = caught.value
assert error.operation_stage == "gatt-write" # type: ignore[attr-defined]
assert error.device_write_attempted is True # type: ignore[attr-defined]
assert error.device_write_confirmed is False # type: ignore[attr-defined]
assert error.att_error_code == 0x03 # type: ignore[attr-defined]
assert error.att_error_name == "WRITE_NOT_PERMITTED" # type: ignore[attr-defined]