fix(k1): restore canonical local connection lifecycle
This commit is contained in:
@@ -1,16 +1,33 @@
|
||||
import asyncio
|
||||
from collections.abc import Iterator
|
||||
|
||||
import pytest
|
||||
from bleak.backends.device import BLEDevice
|
||||
from bleak.backends.scanner import AdvertisementData
|
||||
from pytest import MonkeyPatch
|
||||
|
||||
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import (
|
||||
advertisement_record,
|
||||
discovered_device,
|
||||
discovered_device_selection,
|
||||
scan,
|
||||
)
|
||||
|
||||
|
||||
@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 test_advertisement_record_marks_k1_candidate() -> None:
|
||||
device = BLEDevice("TEST-UUID", "Unknown", details=None)
|
||||
advertisement = AdvertisementData(
|
||||
@@ -72,3 +89,136 @@ def test_scan_retains_the_live_corebluetooth_handle(
|
||||
|
||||
assert result["devices"][0]["macos_uuid"] == "LIVE-UUID"
|
||||
assert discovered_device("LIVE-UUID") is device
|
||||
assert discovered_device_selection("LIVE-UUID").from_fresh_scan is True
|
||||
|
||||
|
||||
def test_scan_start_invalidates_previous_lease_and_failure_leaves_it_empty(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
old_device = BLEDevice("OLD-UUID", "XGR-OLD", details=object())
|
||||
old_advertisement = AdvertisementData(
|
||||
local_name="XGR-OLD",
|
||||
manufacturer_data={},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
tx_power=0,
|
||||
rssi=-41,
|
||||
platform_data=(),
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
async def initial_discover(
|
||||
**_kwargs: object,
|
||||
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
||||
return {old_device.address: (old_device, old_advertisement)}
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", initial_discover)
|
||||
await scan(1.0)
|
||||
assert discovered_device(old_device.address) is old_device
|
||||
|
||||
failing_scan_started = asyncio.Event()
|
||||
release_failing_scan = asyncio.Event()
|
||||
|
||||
async def failing_discover(**_kwargs: object) -> object:
|
||||
failing_scan_started.set()
|
||||
await release_failing_scan.wait()
|
||||
raise RuntimeError("synthetic BLE scan failure")
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", failing_discover)
|
||||
scan_task = asyncio.create_task(scan(1.0))
|
||||
await failing_scan_started.wait()
|
||||
|
||||
# Starting a new explicit scan revokes the prior generation before I/O.
|
||||
assert discovered_device(old_device.address) is None
|
||||
assert discovered_device_selection(old_device.address).from_fresh_scan is False
|
||||
|
||||
release_failing_scan.set()
|
||||
with pytest.raises(RuntimeError, match="synthetic BLE scan failure"):
|
||||
await scan_task
|
||||
|
||||
assert discovered_device(old_device.address) is None
|
||||
assert discovered_device_selection(old_device.address).from_fresh_scan is False
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("older_scan_fails", [False, True])
|
||||
def test_late_scan_generation_cannot_replace_or_clear_newer_lease(
|
||||
monkeypatch: MonkeyPatch,
|
||||
older_scan_fails: bool,
|
||||
) -> None:
|
||||
older_device = BLEDevice("OLDER-UUID", "XGR-OLDER", details=object())
|
||||
newer_device = BLEDevice("NEWER-UUID", "XGR-NEWER", details=object())
|
||||
older_advertisement = AdvertisementData(
|
||||
local_name="XGR-OLDER",
|
||||
manufacturer_data={},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
tx_power=0,
|
||||
rssi=-51,
|
||||
platform_data=(),
|
||||
)
|
||||
newer_advertisement = AdvertisementData(
|
||||
local_name="XGR-NEWER",
|
||||
manufacturer_data={},
|
||||
service_data={},
|
||||
service_uuids=[],
|
||||
tx_power=0,
|
||||
rssi=-31,
|
||||
platform_data=(),
|
||||
)
|
||||
|
||||
async def scenario() -> None:
|
||||
call_count = 0
|
||||
older_scan_started = asyncio.Event()
|
||||
release_older_scan = asyncio.Event()
|
||||
|
||||
async def overlapping_discover(
|
||||
**_kwargs: object,
|
||||
) -> dict[str, tuple[BLEDevice, AdvertisementData]]:
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
older_scan_started.set()
|
||||
await release_older_scan.wait()
|
||||
if older_scan_fails:
|
||||
raise RuntimeError("late older scan failed")
|
||||
return {older_device.address: (older_device, older_advertisement)}
|
||||
return {newer_device.address: (newer_device, newer_advertisement)}
|
||||
|
||||
monkeypatch.setattr(scanner_module.BleakScanner, "discover", overlapping_discover)
|
||||
older_task = asyncio.create_task(scan(1.0))
|
||||
await older_scan_started.wait()
|
||||
|
||||
await scan(1.0)
|
||||
assert discovered_device(newer_device.address) is newer_device
|
||||
assert discovered_device(older_device.address) is None
|
||||
|
||||
release_older_scan.set()
|
||||
if older_scan_fails:
|
||||
with pytest.raises(RuntimeError, match="late older scan failed"):
|
||||
await older_task
|
||||
else:
|
||||
await older_task
|
||||
|
||||
# Neither a late success nor a late failure owns the current lease.
|
||||
assert discovered_device(newer_device.address) is newer_device
|
||||
assert discovered_device(older_device.address) is None
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_runtime_handle_outlives_operator_candidate_boundary(
|
||||
monkeypatch: MonkeyPatch,
|
||||
) -> None:
|
||||
clock = [100.0]
|
||||
handle = BLEDevice("LIVE-UUID", "XGR-K1", details=object())
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: clock[0])
|
||||
scanner_module._runtime_handles[handle.address] = handle # noqa: SLF001
|
||||
scanner_module._runtime_handle_observed_at_monotonic = clock[0] # noqa: SLF001
|
||||
|
||||
clock[0] += scanner_module.BLE_DISCOVERY_CANDIDATE_LEASE_TTL_SECONDS + 0.001
|
||||
assert discovered_device(handle.address) is handle
|
||||
|
||||
clock[0] = 100.0 + scanner_module.BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS + 0.001
|
||||
assert discovered_device(handle.address) is None
|
||||
|
||||
@@ -55,6 +55,12 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
"automatic_retry": False,
|
||||
"side_effect_status": "unknown",
|
||||
"network_change_attempted": True,
|
||||
"device_write_attempted": True,
|
||||
"device_write_confirmed": False,
|
||||
"ble_att_error_code": 4,
|
||||
"ble_att_error_name": "INVALID_PDU",
|
||||
"helper_stage": "compile",
|
||||
"helper_elapsed_ms": 34720,
|
||||
"camera_source_id": "sensor.camera.right",
|
||||
"evidence_session_id": "20260728T163450Z_viewer_live",
|
||||
"activation_trigger": "application-control-scanning",
|
||||
@@ -91,6 +97,12 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
assert document["automatic_retry"] is False
|
||||
assert document["side_effect_status"] == "unknown"
|
||||
assert document["network_change_attempted"] is True
|
||||
assert document["device_write_attempted"] is True
|
||||
assert document["device_write_confirmed"] is False
|
||||
assert document["ble_att_error_code"] == 4
|
||||
assert document["ble_att_error_name"] == "INVALID_PDU"
|
||||
assert document["helper_stage"] == "compile"
|
||||
assert document["helper_elapsed_ms"] == 34720
|
||||
assert document["camera_source_id"] == "sensor.camera.right"
|
||||
assert document["evidence_session_id"] == "20260728T163450Z_viewer_live"
|
||||
assert document["activation_trigger"] == "application-control-scanning"
|
||||
|
||||
+284
-22
@@ -1,10 +1,12 @@
|
||||
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.scanner as scanner_module
|
||||
import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
FRAME_LENGTH,
|
||||
@@ -15,6 +17,27 @@ from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
)
|
||||
|
||||
|
||||
@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_wifi_provisioning_frame_layout() -> None:
|
||||
credential = "x" * 13
|
||||
frame = build_wifi_provisioning_frame("LabNet", credential)
|
||||
@@ -122,7 +145,15 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
|
||||
self.write_calls += 1
|
||||
raise AssertionError("status refresh must not write a BLE characteristic")
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: object())
|
||||
retained_handle = object()
|
||||
monkeypatch.setattr(
|
||||
wifi_module,
|
||||
"discovered_device_selection",
|
||||
lambda _uuid: SimpleNamespace(
|
||||
device=retained_handle,
|
||||
from_fresh_scan=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", FakeClient)
|
||||
|
||||
result = asyncio.run(read_wifi_status_once("synthetic-corebluetooth-uuid"))
|
||||
@@ -132,7 +163,7 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
|
||||
assert result["status"]["ipv4"] == "10.255.254.77"
|
||||
|
||||
|
||||
def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
def test_read_wifi_status_recovery_keeps_fresh_retained_handle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
value = bytearray(54)
|
||||
@@ -141,8 +172,7 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
value[33] = 4
|
||||
value[34:38] = bytes((10, 255, 254, 77))
|
||||
value[50] = 1
|
||||
stale_handle = object()
|
||||
recovered_handle = object()
|
||||
retained_handle = object()
|
||||
characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
@@ -159,7 +189,7 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
assert device is recovered_handle
|
||||
assert device is retained_handle
|
||||
self.services = FakeServices()
|
||||
self.name = "XGR-K1"
|
||||
|
||||
@@ -173,9 +203,16 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
return bytes(value)
|
||||
|
||||
async def rediscover(*_args: object, **_kwargs: object) -> object:
|
||||
return recovered_handle
|
||||
raise AssertionError("a fresh explicit scan handle must not be discarded")
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle)
|
||||
monkeypatch.setattr(
|
||||
wifi_module,
|
||||
"discovered_device_selection",
|
||||
lambda _uuid: SimpleNamespace(
|
||||
device=retained_handle,
|
||||
from_fresh_scan=True,
|
||||
),
|
||||
)
|
||||
monkeypatch.setattr(wifi_module.BleakScanner, "find_device_by_address", rediscover)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", FakeClient)
|
||||
|
||||
@@ -189,39 +226,264 @@ def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
assert result["status"]["ipv4"] == "10.255.254.77"
|
||||
|
||||
|
||||
def test_provisioning_write_requires_fresh_rediscovery_before_connecting(
|
||||
def test_provisioning_write_uses_retained_handle_without_rediscovery(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
stale_handle = object()
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
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
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle)
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
|
||||
monkeypatch.setattr(
|
||||
wifi_module.BleakScanner,
|
||||
"find_device_by_address",
|
||||
missing_device,
|
||||
forbidden_rediscovery,
|
||||
)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", ForbiddenClient)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", CapturingClient)
|
||||
|
||||
with pytest.raises(BleakDeviceNotFoundError):
|
||||
with pytest.raises(SelectedHandleObserved) as caught:
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
"synthetic-corebluetooth-uuid",
|
||||
device_id,
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert rediscovery_calls == [("synthetic-corebluetooth-uuid", 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_provisioning_write_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(
|
||||
wifi_module.BleakScanner,
|
||||
"find_device_by_address",
|
||||
forbidden_rediscovery,
|
||||
)
|
||||
|
||||
with pytest.raises(BleakDeviceNotFoundError) as caught:
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
"not-in-fresh-scan",
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
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_provisioning_write_rediscovery_fallback_after_scan_lease_expires(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
expired_handle = object()
|
||||
rediscovered_handle = object()
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
client_calls: list[object] = []
|
||||
clock = [100.0]
|
||||
|
||||
class RediscoveredHandleObserved(RuntimeError):
|
||||
pass
|
||||
|
||||
async def rediscover(address: str, *, timeout: float) -> object:
|
||||
rediscovery_calls.append((address, timeout))
|
||||
return rediscovered_handle
|
||||
|
||||
class CapturingClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
client_calls.append(device)
|
||||
raise RediscoveredHandleObserved
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: clock[0])
|
||||
_seed_scan_lease({device_id: expired_handle}, observed_at=clock[0])
|
||||
clock[0] += scanner_module.BLE_RUNTIME_HANDLE_LEASE_TTL_SECONDS + 0.001
|
||||
monkeypatch.setattr(wifi_module.BleakScanner, "find_device_by_address", rediscover)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", CapturingClient)
|
||||
|
||||
with pytest.raises(RediscoveredHandleObserved):
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
device_id,
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
assert rediscovery_calls == [(device_id, 1.0)]
|
||||
assert client_calls == [rediscovered_handle]
|
||||
assert scanner_module.discovered_device(device_id) is None
|
||||
|
||||
|
||||
def test_provisioning_baseline_error_keeps_type_and_adds_safe_gatt_facts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
retained_handle = object()
|
||||
service = SimpleNamespace(uuid=wifi_module.SERVICE_UUID)
|
||||
write_characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.WRITE_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
properties=["write"],
|
||||
max_write_without_response_size=512,
|
||||
)
|
||||
status_characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
properties=["read"],
|
||||
)
|
||||
|
||||
class FakeServices:
|
||||
def get_service(self, uuid: str) -> object | None:
|
||||
return service if uuid == wifi_module.SERVICE_UUID else None
|
||||
|
||||
def get_characteristic(self, uuid: str) -> object | None:
|
||||
if uuid == wifi_module.WRITE_CHARACTERISTIC_UUID:
|
||||
return write_characteristic
|
||||
if uuid == wifi_module.STATUS_CHARACTERISTIC_UUID:
|
||||
return status_characteristic
|
||||
return None
|
||||
|
||||
class FailingBaselineClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
assert device is retained_handle
|
||||
self.services = FakeServices()
|
||||
self.name = "XGR-K1"
|
||||
|
||||
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:
|
||||
raise BleakGATTProtocolError(0x0E)
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", FailingBaselineClient)
|
||||
|
||||
with pytest.raises(BleakGATTProtocolError) as caught:
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
device_id,
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
error = caught.value
|
||||
assert error.operation_stage == "baseline-read" # type: ignore[attr-defined]
|
||||
assert error.device_write_attempted is False # type: ignore[attr-defined]
|
||||
assert error.device_write_confirmed is False # type: ignore[attr-defined]
|
||||
assert error.att_error_code == 0x0E # type: ignore[attr-defined]
|
||||
assert error.att_error_name == "UNLIKELY_ERROR" # type: ignore[attr-defined]
|
||||
|
||||
|
||||
def test_provisioning_status_poll_error_reports_confirmed_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
retained_handle = object()
|
||||
service = SimpleNamespace(uuid=wifi_module.SERVICE_UUID)
|
||||
write_characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.WRITE_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
properties=["write"],
|
||||
max_write_without_response_size=512,
|
||||
)
|
||||
status_characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
properties=["read"],
|
||||
)
|
||||
baseline = bytearray(52)
|
||||
|
||||
class FakeServices:
|
||||
def get_service(self, uuid: str) -> object | None:
|
||||
return service if uuid == wifi_module.SERVICE_UUID else None
|
||||
|
||||
def get_characteristic(self, uuid: str) -> object | None:
|
||||
if uuid == wifi_module.WRITE_CHARACTERISTIC_UUID:
|
||||
return write_characteristic
|
||||
if uuid == wifi_module.STATUS_CHARACTERISTIC_UUID:
|
||||
return status_characteristic
|
||||
return None
|
||||
|
||||
class FailingPollClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
assert device is retained_handle
|
||||
self.services = FakeServices()
|
||||
self.name = "XGR-K1"
|
||||
self.is_connected = True
|
||||
self.read_count = 0
|
||||
|
||||
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:
|
||||
self.read_count += 1
|
||||
if self.read_count == 1:
|
||||
return bytes(baseline)
|
||||
raise BleakGATTProtocolError(0x12)
|
||||
|
||||
async def write_gatt_char(self, *_args: object, **_kwargs: object) -> None:
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
_seed_scan_lease({device_id: retained_handle}, observed_at=100.0)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", FailingPollClient)
|
||||
|
||||
with pytest.raises(BleakGATTProtocolError) as caught:
|
||||
asyncio.run(
|
||||
provision_wifi_once(
|
||||
device_id,
|
||||
"LabNet",
|
||||
"synthetic-password",
|
||||
timeout_seconds=1.0,
|
||||
)
|
||||
)
|
||||
|
||||
error = caught.value
|
||||
assert error.operation_stage == "status-poll" # type: ignore[attr-defined]
|
||||
assert error.device_write_attempted is True # type: ignore[attr-defined]
|
||||
assert error.device_write_confirmed is True # type: ignore[attr-defined]
|
||||
assert error.att_error_code == 0x12 # type: ignore[attr-defined]
|
||||
assert error.att_error_name == "DATABASE_OUT_OF_SYNC" # type: ignore[attr-defined]
|
||||
|
||||
@@ -200,14 +200,18 @@ def service_with_fake_runtime(
|
||||
return service, runtime
|
||||
|
||||
|
||||
def _wifi_status_read(ipv4: str | None) -> dict[str, Any]:
|
||||
def _wifi_status_read(
|
||||
ipv4: str | None,
|
||||
*,
|
||||
device_id: str = "test-ble-transport",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||
"observed_at_utc": "2026-07-20T12:00:00Z",
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": "test",
|
||||
"device_macos_uuid": "test-ble-transport",
|
||||
"device_macos_uuid": device_id,
|
||||
"device_name": "XGR-K1",
|
||||
"service_uuid": "00007f00-0000-1000-8000-00805f9b34fb",
|
||||
"status_characteristic_uuid": "00007f02-0000-1000-8000-00805f9b34fb",
|
||||
@@ -2867,6 +2871,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
"ready_observed": True,
|
||||
"write_performed": True,
|
||||
"write_mode": "with_response",
|
||||
"observations": [{"status": {"mode": "WIFI_AP"}}],
|
||||
}
|
||||
finally:
|
||||
ble_session_open = False
|
||||
@@ -3090,6 +3095,7 @@ def test_quick_connect_does_not_start_host_wifi_without_ap_ready_flag(
|
||||
"ready_observed": False,
|
||||
"write_performed": True,
|
||||
"write_mode": "with_response",
|
||||
"observations": [],
|
||||
}
|
||||
|
||||
def forbidden_association(*_: object, **__: object) -> dict[str, Any]:
|
||||
@@ -3121,6 +3127,9 @@ def test_quick_connect_does_not_start_host_wifi_without_ap_ready_flag(
|
||||
assert len(quick_sessions) == 1
|
||||
assert (quick_sessions[0] / "ap-activation.redacted.json").exists()
|
||||
assert not (quick_sessions[0] / "manifest.redacted.json").exists()
|
||||
reconciliation = service.state()["network_write_reconciliation"]
|
||||
assert reconciliation["transport_ref"] == "k1-a"
|
||||
assert reconciliation["status"] == "device-state-unknown-after-write"
|
||||
|
||||
|
||||
def test_failed_connection_change_revokes_the_previous_route(
|
||||
@@ -3154,6 +3163,7 @@ def test_failed_connection_change_revokes_the_previous_route(
|
||||
"ready_observed": True,
|
||||
"write_performed": True,
|
||||
"write_mode": "with_response",
|
||||
"observations": [{"status": {"mode": "WIFI_AP"}}],
|
||||
}
|
||||
|
||||
def failed_association(*_: object, **__: object) -> dict[str, Any]:
|
||||
@@ -3195,6 +3205,75 @@ def test_failed_connection_change_revokes_the_previous_route(
|
||||
assert failure_evidence["scan_elapsed_ms"] == 15014
|
||||
|
||||
|
||||
def test_quick_connect_helper_build_failure_after_ap_write_preserves_side_effect_facts(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"ensure_wifi_profile_from_credential_source",
|
||||
lambda *_args, **_kwargs: {
|
||||
"schema_version": 1,
|
||||
"adapter": "macOS Keychain",
|
||||
"available": True,
|
||||
"profile_enrolled": False,
|
||||
"credential_source": "exact-firmware-profile",
|
||||
},
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def ready_activation(*_: object, **__: object) -> AsyncIterator[dict[str, Any]]:
|
||||
yield {
|
||||
"profile_id": "xgrids-k1-fw3-quick-connect-ap-v1",
|
||||
"started_at_utc": "2026-08-06T10:00:00Z",
|
||||
"completed_at_utc": "2026-08-06T10:00:01Z",
|
||||
"outcome": "ap_ready_observed",
|
||||
"ready_observed": True,
|
||||
"write_performed": True,
|
||||
"write_mode": "with_response",
|
||||
"observations": [{"status": {"mode": "WIFI_AP"}}],
|
||||
}
|
||||
|
||||
def failed_post_write_build(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise facade_module.HostWifiProfileError(
|
||||
"host-wifi-helper-build-failed",
|
||||
helper_stage="compile",
|
||||
helper_elapsed_ms=21,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(facade_module, "device_ap_activation_session", ready_activation)
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"associate_with_wifi_profile_once",
|
||||
failed_post_write_build,
|
||||
)
|
||||
|
||||
with pytest.raises(
|
||||
facade_module.HostWifiProfileError,
|
||||
match="host-wifi-helper-build-failed",
|
||||
):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
connection_mode="quick-connect",
|
||||
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
operation = next(item for item in state["operations"] if item["action"] == "network.provision")
|
||||
assert operation["stage_code"] == "host-wifi-association-failed"
|
||||
assert operation["error"]["code"] == "host-wifi-helper-build-failed"
|
||||
assert operation["error"]["side_effect_status"] == "confirmed"
|
||||
assert operation["error"]["safe_to_retry"] is False
|
||||
assert operation["error"]["helper_stage"] == "compile"
|
||||
assert state["network_write_reconciliation"] is None
|
||||
|
||||
|
||||
def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
@@ -3236,7 +3315,8 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
operation = next(item for item in state["operations"] if item["action"] == "network.provision")
|
||||
assert operation["status"] == "failed"
|
||||
assert operation["error"]["safe_to_retry"] is False
|
||||
assert operation["error"]["side_effect_status"] == "unknown"
|
||||
assert operation["error"]["side_effect_status"] == "confirmed"
|
||||
assert state["network_write_reconciliation"] is None
|
||||
failure_log = next(
|
||||
record
|
||||
for record in caplog.records
|
||||
@@ -3246,12 +3326,212 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
assert failure_log.connection_mode == "bridge"
|
||||
assert failure_log.error_code == "RuntimeError"
|
||||
assert failure_log.safe_to_retry is False
|
||||
assert failure_log.side_effect_status == "unknown"
|
||||
assert failure_log.side_effect_status == "confirmed"
|
||||
assert failure_log.network_change_attempted is True
|
||||
assert failure_log.device_write_attempted is True
|
||||
assert failure_log.device_write_confirmed is True
|
||||
assert PRIMARY_TEST_CREDENTIAL not in caplog.text
|
||||
assert "lab-network" not in caplog.text
|
||||
|
||||
|
||||
def test_ambiguous_ble_write_blocks_new_network_mutation_until_reconciled(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a"}])
|
||||
calls = 0
|
||||
|
||||
async def ambiguous_write(*_: object, **__: object) -> dict[str, Any]:
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
exc = RuntimeError("synthetic transport failure")
|
||||
exc.operation_stage = "gatt-write" # type: ignore[attr-defined]
|
||||
exc.device_write_attempted = True # type: ignore[attr-defined]
|
||||
exc.device_write_confirmed = False # type: ignore[attr-defined]
|
||||
exc.att_error_code = 4 # type: ignore[attr-defined]
|
||||
exc.att_error_name = "INVALID_PDU" # type: ignore[attr-defined]
|
||||
raise exc
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", ambiguous_write)
|
||||
|
||||
with pytest.raises(RuntimeError, match="synthetic transport failure"):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
idempotency_key="ambiguous-write-1",
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
operation = next(item for item in state["operations"] if item["action"] == "network.provision")
|
||||
assert operation["error"] == {
|
||||
"category": "device",
|
||||
"code": "RuntimeError",
|
||||
"retryable": False,
|
||||
"safe_to_retry": False,
|
||||
"side_effect_status": "unknown",
|
||||
"operation_stage": "gatt-write",
|
||||
"device_write_attempted": True,
|
||||
"device_write_confirmed": False,
|
||||
"ble_att_error_code": 4,
|
||||
"ble_att_error_name": "INVALID_PDU",
|
||||
}
|
||||
assert state["network_write_reconciliation"] == {
|
||||
"status": "device-state-unknown-after-write",
|
||||
"operation_id": operation["operation_id"],
|
||||
"transport_ref": "k1-a",
|
||||
"connection_mode": "bridge",
|
||||
"operation_stage": "gatt-write",
|
||||
"reason_code": "RuntimeError",
|
||||
"device_write_confirmed": False,
|
||||
"required_action": "explicit-read-only-ble-status-observation",
|
||||
"scope": "process-runtime",
|
||||
"observed_at": state["network_write_reconciliation"]["observed_at"],
|
||||
}
|
||||
|
||||
with pytest.raises(
|
||||
facade_module.NetworkWriteReconciliationRequired,
|
||||
match="новая запись заблокирована",
|
||||
):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="another-network",
|
||||
password=SecretStr(SECONDARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
idempotency_key="ambiguous-write-2",
|
||||
)
|
||||
)
|
||||
)
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_unchanged_status_does_not_confirm_without_response_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a"}])
|
||||
unchanged_status = {"ipv4": None, "mode": "WIFI_CLIENT"}
|
||||
|
||||
async def unchanged_write(*_: object, **__: object) -> dict[str, Any]:
|
||||
return {
|
||||
"started_at_utc": "2026-08-06T10:00:00Z",
|
||||
"completed_at_utc": "2026-08-06T10:00:01Z",
|
||||
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||
"outcome": "no_status_change_before_timeout",
|
||||
"write_mode": "without_response",
|
||||
"baseline_status": unchanged_status,
|
||||
"observations": [{"status": unchanged_status}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", unchanged_write)
|
||||
|
||||
with (
|
||||
caplog.at_level(logging.ERROR, logger=facade_module.__name__),
|
||||
pytest.raises(RuntimeError, match="не сообщило адрес"),
|
||||
):
|
||||
asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
record = next(
|
||||
item
|
||||
for item in caplog.records
|
||||
if getattr(item, "event_code", None) == "k1_network_provision_failed"
|
||||
)
|
||||
assert record.device_write_attempted is True
|
||||
assert record.device_write_confirmed is False
|
||||
assert record.side_effect_status == "unknown"
|
||||
assert service.state()["network_write_reconciliation"] is None
|
||||
|
||||
|
||||
def test_read_only_ble_status_clears_network_write_reconciliation_fence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_k1(service)
|
||||
service._network_write_reconciliation = { # noqa: SLF001
|
||||
"status": "device-state-unknown-after-write",
|
||||
"operation_id": "ambiguous-operation",
|
||||
"transport_ref": "test-ble-transport",
|
||||
"connection_mode": "bridge",
|
||||
"operation_stage": "gatt-write",
|
||||
"reason_code": "BleakGATTProtocolError",
|
||||
"device_write_confirmed": False,
|
||||
"required_action": "explicit-read-only-ble-status-observation",
|
||||
"scope": "process-runtime",
|
||||
"observed_at": "2026-08-06T10:00:00Z",
|
||||
}
|
||||
|
||||
async def read_current_status(*_: object, **__: object) -> dict[str, Any]:
|
||||
return _wifi_status_read(None)
|
||||
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", read_current_status)
|
||||
|
||||
with pytest.raises(RuntimeError, match="не сообщил актуальный DHCP-адрес"):
|
||||
service.verify_connection(
|
||||
ConnectionVerifyRequest(
|
||||
device_id="test-ble-transport",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
assert service.state()["network_write_reconciliation"] is None
|
||||
|
||||
|
||||
def test_read_only_ble_status_for_another_transport_keeps_reconciliation_fence(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
_set_scanned_k1(service, device_id="k1-b")
|
||||
fence = {
|
||||
"status": "device-state-unknown-after-write",
|
||||
"operation_id": "ambiguous-operation",
|
||||
"transport_ref": "k1-a",
|
||||
"connection_mode": "bridge",
|
||||
"operation_stage": "gatt-write",
|
||||
"reason_code": "BleakGATTProtocolError",
|
||||
"device_write_confirmed": False,
|
||||
"required_action": "explicit-read-only-ble-status-observation",
|
||||
"scope": "process-runtime",
|
||||
"observed_at": "2026-08-06T10:00:00Z",
|
||||
}
|
||||
service._network_write_reconciliation = dict(fence) # noqa: SLF001
|
||||
|
||||
async def read_other_status(*_: object, **__: object) -> dict[str, Any]:
|
||||
return _wifi_status_read(None, device_id="k1-b")
|
||||
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", read_other_status)
|
||||
|
||||
with pytest.raises(RuntimeError, match="не сообщил актуальный DHCP-адрес"):
|
||||
service.verify_connection(
|
||||
ConnectionVerifyRequest(
|
||||
device_id="k1-b",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
assert service.state()["network_write_reconciliation"] == fence
|
||||
|
||||
|
||||
def test_provisioning_cannot_switch_device_during_active_acquisition(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
@@ -13,9 +15,14 @@ TEST_PROFILE_ID = "fixture.quick-connect.v1"
|
||||
TEST_CREDENTIAL_SOURCE_ID = "fixture.firmware-provider.v1"
|
||||
|
||||
|
||||
def _helper(tmp_path: Path) -> Path:
|
||||
def _helper(tmp_path: Path, *, seed_compiled_cache: bool = True) -> Path:
|
||||
helper = tmp_path / "associate_wifi.swift"
|
||||
helper.write_text("// offline fixture\n", encoding="utf-8")
|
||||
if seed_compiled_cache:
|
||||
executable = wifi._compiled_macos_helper_path(helper)
|
||||
executable.parent.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
executable.write_bytes(b"offline compiled fixture\n")
|
||||
executable.chmod(0o700)
|
||||
return helper
|
||||
|
||||
|
||||
@@ -42,8 +49,9 @@ def test_association_exposes_only_profile_id_and_expected_ssid_to_platform_helpe
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
helper = _helper(tmp_path)
|
||||
result = wifi.associate_with_wifi_profile_once(
|
||||
_helper(tmp_path),
|
||||
helper,
|
||||
TEST_PROFILE_ID,
|
||||
"XGR-OFFLINE",
|
||||
runner=fake_runner,
|
||||
@@ -61,7 +69,7 @@ def test_association_exposes_only_profile_id_and_expected_ssid_to_platform_helpe
|
||||
}
|
||||
assert len(calls) == 1
|
||||
call = calls[0]
|
||||
assert call["argv"][:2] == ["/usr/bin/xcrun", "swift"]
|
||||
assert call["argv"] == [str(wifi._compiled_macos_helper_path(helper))]
|
||||
request = json.loads(bytes(call["input"]).decode("utf-8"))
|
||||
assert request == {
|
||||
"action": "associate",
|
||||
@@ -290,6 +298,8 @@ def test_association_reports_operator_timeout_separately_from_missing_helper(
|
||||
)
|
||||
|
||||
assert raised.value.reason_code == "host-wifi-operation-timeout"
|
||||
assert raised.value.helper_stage is None
|
||||
assert raised.value.helper_elapsed_ms is None
|
||||
|
||||
|
||||
def test_association_reports_an_unavailable_helper_separately_from_timeout(
|
||||
@@ -317,6 +327,271 @@ def test_association_reports_an_unavailable_helper_separately_from_timeout(
|
||||
assert raised.value.reason_code == "host-wifi-helper-unavailable"
|
||||
|
||||
|
||||
def test_cold_helper_build_uses_source_hash_and_separate_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wifi.sys, "platform", "darwin")
|
||||
helper = _helper(tmp_path, seed_compiled_cache=False)
|
||||
executable = wifi._compiled_macos_helper_path(helper)
|
||||
calls: list[dict[str, object]] = []
|
||||
|
||||
def fake_runner(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
||||
calls.append({"argv": argv, **kwargs})
|
||||
if argv[:2] == ["/usr/bin/xcrun", "swiftc"]:
|
||||
assert argv[2] == str(helper.resolve())
|
||||
assert argv[3] == "-o"
|
||||
assert kwargs["stdin"] == subprocess.DEVNULL
|
||||
assert "input" not in kwargs
|
||||
assert 0 < float(kwargs["timeout"]) <= wifi.DEFAULT_HELPER_BUILD_TIMEOUT_SECONDS
|
||||
staging = Path(argv[4])
|
||||
staging.write_bytes(b"compiled fixture\n")
|
||||
staging.chmod(0o700)
|
||||
return subprocess.CompletedProcess(argv, 0, stdout=b"", stderr=b"")
|
||||
|
||||
assert argv == [str(executable)]
|
||||
assert kwargs["timeout"] == 30.0
|
||||
return subprocess.CompletedProcess(
|
||||
argv,
|
||||
0,
|
||||
stdout=(
|
||||
b'{"ok":true,"adapter":"macOS Keychain",'
|
||||
b'"profile_available":true}'
|
||||
),
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
result = wifi.check_wifi_profile(
|
||||
helper,
|
||||
TEST_PROFILE_ID,
|
||||
"XGR-OFFLINE",
|
||||
runner=fake_runner,
|
||||
)
|
||||
|
||||
assert result["available"] is True
|
||||
assert len(calls) == 2
|
||||
assert calls[0]["argv"][:2] == ["/usr/bin/xcrun", "swiftc"]
|
||||
assert calls[1]["argv"] == [str(executable)]
|
||||
assert executable.is_file()
|
||||
assert executable.stat().st_mode & 0o111
|
||||
assert executable.name.endswith(hashlib.sha256(helper.read_bytes()).hexdigest())
|
||||
assert not list(executable.parent.glob(f".{executable.name}.*.tmp"))
|
||||
|
||||
|
||||
def test_plugin_helper_cache_is_stable_under_repository_runtime(tmp_path: Path) -> None:
|
||||
helper = tmp_path / "repo" / "plugins" / "xgrids-k1" / "macos" / "associate_wifi.swift"
|
||||
helper.parent.mkdir(parents=True)
|
||||
helper.write_text("// offline fixture\n", encoding="utf-8")
|
||||
|
||||
assert wifi._helper_cache_directory(helper) == (
|
||||
tmp_path / "repo" / ".runtime" / "mission-core" / "helpers"
|
||||
)
|
||||
|
||||
|
||||
def test_helper_lock_and_compile_share_one_build_deadline(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wifi.sys, "platform", "darwin")
|
||||
helper = _helper(tmp_path, seed_compiled_cache=False)
|
||||
monotonic_values = iter((100.0, 100.0, 104.0))
|
||||
monkeypatch.setattr(wifi.time, "monotonic", lambda: next(monotonic_values))
|
||||
monkeypatch.setattr(wifi.time, "monotonic_ns", lambda: 0)
|
||||
lock_timeouts: list[float] = []
|
||||
compile_timeouts: list[float] = []
|
||||
|
||||
class FakeLock:
|
||||
def __enter__(self) -> None:
|
||||
return None
|
||||
|
||||
def __exit__(self, *_args: object) -> None:
|
||||
return None
|
||||
|
||||
def fake_lock(_path: Path, *, timeout_seconds: float) -> FakeLock:
|
||||
lock_timeouts.append(timeout_seconds)
|
||||
return FakeLock()
|
||||
|
||||
def fake_runner(argv: list[str], **kwargs: object) -> subprocess.CompletedProcess[bytes]:
|
||||
if argv[:2] == ["/usr/bin/xcrun", "swiftc"]:
|
||||
compile_timeouts.append(float(kwargs["timeout"]))
|
||||
staging = Path(argv[4])
|
||||
staging.write_bytes(b"compiled fixture\n")
|
||||
staging.chmod(0o700)
|
||||
return subprocess.CompletedProcess(argv, 0, stdout=b"", stderr=b"")
|
||||
return subprocess.CompletedProcess(
|
||||
argv,
|
||||
0,
|
||||
stdout=b'{"ok":true,"adapter":"macOS Keychain","profile_available":true}',
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(wifi, "_exclusive_helper_build_lock", fake_lock)
|
||||
result = wifi._run_macos_helper(
|
||||
helper,
|
||||
{"action": "check-profile"},
|
||||
timeout_seconds=3.0,
|
||||
build_timeout_seconds=10.0,
|
||||
runner=fake_runner,
|
||||
)
|
||||
|
||||
assert result["ok"] is True
|
||||
assert lock_timeouts == [10.0]
|
||||
assert compile_timeouts == [6.0]
|
||||
|
||||
|
||||
def test_compiled_helper_is_reused_without_recompiling(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wifi.sys, "platform", "darwin")
|
||||
helper = _helper(tmp_path, seed_compiled_cache=False)
|
||||
executable = wifi._compiled_macos_helper_path(helper)
|
||||
compile_count = 0
|
||||
runtime_count = 0
|
||||
|
||||
def fake_runner(argv: list[str], **_: object) -> subprocess.CompletedProcess[bytes]:
|
||||
nonlocal compile_count, runtime_count
|
||||
if argv[:2] == ["/usr/bin/xcrun", "swiftc"]:
|
||||
compile_count += 1
|
||||
staging = Path(argv[4])
|
||||
staging.write_bytes(b"compiled fixture\n")
|
||||
staging.chmod(0o700)
|
||||
return subprocess.CompletedProcess(argv, 0, stdout=b"", stderr=b"")
|
||||
runtime_count += 1
|
||||
assert argv == [str(executable)]
|
||||
return subprocess.CompletedProcess(
|
||||
argv,
|
||||
0,
|
||||
stdout=(
|
||||
b'{"ok":true,"adapter":"macOS Keychain",'
|
||||
b'"profile_available":true}'
|
||||
),
|
||||
stderr=b"",
|
||||
)
|
||||
|
||||
for _ in range(2):
|
||||
wifi.check_wifi_profile(
|
||||
helper,
|
||||
TEST_PROFILE_ID,
|
||||
"XGR-OFFLINE",
|
||||
runner=fake_runner,
|
||||
)
|
||||
|
||||
assert compile_count == 1
|
||||
assert runtime_count == 2
|
||||
|
||||
|
||||
def test_helper_build_timeout_is_separate_from_operation_timeout(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wifi.sys, "platform", "darwin")
|
||||
helper = _helper(tmp_path, seed_compiled_cache=False)
|
||||
seen_timeout: float | None = None
|
||||
monotonic_values = iter((1_000_000_000, 1_123_000_000))
|
||||
monkeypatch.setattr(wifi.time, "monotonic_ns", lambda: next(monotonic_values))
|
||||
|
||||
def timed_out_compiler(
|
||||
argv: list[str], **kwargs: object
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
nonlocal seen_timeout
|
||||
assert argv[:2] == ["/usr/bin/xcrun", "swiftc"]
|
||||
seen_timeout = float(kwargs["timeout"])
|
||||
raise subprocess.TimeoutExpired(argv, timeout=seen_timeout)
|
||||
|
||||
with pytest.raises(
|
||||
wifi.HostWifiProfileError,
|
||||
match="host-wifi-helper-build-timeout",
|
||||
) as raised:
|
||||
wifi._run_macos_helper(
|
||||
helper,
|
||||
{"action": "check-profile"},
|
||||
timeout_seconds=3.0,
|
||||
build_timeout_seconds=7.5,
|
||||
runner=timed_out_compiler,
|
||||
)
|
||||
|
||||
assert raised.value.reason_code == "host-wifi-helper-build-timeout"
|
||||
assert raised.value.helper_stage == "compile"
|
||||
assert raised.value.helper_elapsed_ms == 123
|
||||
assert seen_timeout is not None
|
||||
assert 0 < seen_timeout <= 7.5
|
||||
assert not wifi._compiled_macos_helper_path(helper).exists()
|
||||
|
||||
|
||||
def test_helper_build_lock_contention_is_bounded(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
fcntl = pytest.importorskip("fcntl")
|
||||
lock_path = tmp_path / "helper.lock"
|
||||
descriptor = os.open(lock_path, os.O_RDWR | os.O_CREAT, 0o600)
|
||||
fcntl.flock(descriptor, fcntl.LOCK_EX)
|
||||
monotonic_values = iter((100.0, 100.02))
|
||||
monkeypatch.setattr(wifi.time, "monotonic", lambda: next(monotonic_values))
|
||||
|
||||
try:
|
||||
with (
|
||||
pytest.raises(wifi.HostWifiProfileError) as raised,
|
||||
wifi._exclusive_helper_build_lock(
|
||||
lock_path,
|
||||
timeout_seconds=0.01,
|
||||
),
|
||||
):
|
||||
raise AssertionError("contended lock must not be acquired")
|
||||
finally:
|
||||
fcntl.flock(descriptor, fcntl.LOCK_UN)
|
||||
os.close(descriptor)
|
||||
|
||||
assert raised.value.reason_code == "host-wifi-helper-build-timeout"
|
||||
assert raised.value.helper_stage == "compile-lock"
|
||||
assert raised.value.helper_elapsed_ms == 19
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("compiler_failure", "expected_reason_code"),
|
||||
[
|
||||
("exit", "host-wifi-helper-build-failed"),
|
||||
("unavailable", "host-wifi-helper-compiler-unavailable"),
|
||||
],
|
||||
)
|
||||
def test_helper_build_errors_have_sanitized_build_taxonomy(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
compiler_failure: str,
|
||||
expected_reason_code: str,
|
||||
) -> None:
|
||||
monkeypatch.setattr(wifi.sys, "platform", "darwin")
|
||||
helper = _helper(tmp_path, seed_compiled_cache=False)
|
||||
|
||||
def failing_compiler(
|
||||
argv: list[str], **_: object
|
||||
) -> subprocess.CompletedProcess[bytes]:
|
||||
assert argv[:2] == ["/usr/bin/xcrun", "swiftc"]
|
||||
if compiler_failure == "unavailable":
|
||||
raise OSError(f"private diagnostic {TEST_PASSWORD}")
|
||||
return subprocess.CompletedProcess(
|
||||
argv,
|
||||
1,
|
||||
stdout=b"",
|
||||
stderr=f"private diagnostic {TEST_PASSWORD}".encode(),
|
||||
)
|
||||
|
||||
with pytest.raises(wifi.HostWifiProfileError) as raised:
|
||||
wifi.check_wifi_profile(
|
||||
helper,
|
||||
TEST_PROFILE_ID,
|
||||
"XGR-OFFLINE",
|
||||
runner=failing_compiler,
|
||||
)
|
||||
|
||||
assert raised.value.reason_code == expected_reason_code
|
||||
assert raised.value.helper_stage == "compile"
|
||||
assert isinstance(raised.value.helper_elapsed_ms, int)
|
||||
assert raised.value.helper_elapsed_ms >= 0
|
||||
assert TEST_PASSWORD not in str(raised.value)
|
||||
|
||||
|
||||
def test_association_rejects_an_uninstalled_platform(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
|
||||
Reference in New Issue
Block a user