fix(k1): harden BLE discovery and bridge recovery

This commit is contained in:
DCCONSTRUCTIONS
2026-08-06 12:43:00 +03:00
parent be50144ca8
commit 52da9b75b7
14 changed files with 1010 additions and 48 deletions
+61
View File
@@ -20,6 +20,7 @@ from pydantic import ValidationError
import k1link.web.device_plugin_composition as plugin_composition
from k1link.device_plugins.xgrids_k1.facade import (
ACTION_CONNECTION_VERIFY,
ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ,
ACTION_DISCOVERY_SCAN,
ACTION_NETWORK_PROVISION,
@@ -30,6 +31,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
XGRIDS_K1_PLUGIN_ID,
XGRIDS_K1_PLUGIN_VERSION,
CompatibilityAttestationRequest,
ConnectionVerifyRequest,
ConnectRequest,
ViewerSettingsRequest,
XgridsK1PluginFacade,
@@ -72,6 +74,13 @@ class FakeXgridsService:
self.calls.append(("connect", request))
return {"phase": "connected", "k1_ip": "192.168.1.20"}
def verify_connection(
self,
request: ConnectionVerifyRequest | None = None,
) -> dict[str, Any]:
self.calls.append(("verify", request))
return {"phase": "connected", "k1_ip": "192.168.1.20"}
def start_live(
self,
project_name: str,
@@ -158,6 +167,58 @@ def test_calibration_snapshot_action_calls_the_read_only_service_method() -> Non
assert service.calls == [("calibration", None)]
def test_connection_verify_action_accepts_read_only_adoption_request() -> None:
service = FakeXgridsService()
dispatcher = DevicePluginDispatcher(
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
result = asyncio.run(
dispatcher.invoke(
XGRIDS_K1_PLUGIN_ID,
ACTION_CONNECTION_VERIFY,
{
"device_id": "test-ble-transport",
"compatibility_attestation": {
"firmware_version": "3.0.2",
"topology": "direct-lan",
"verification": "live-device-info",
},
},
)
)
assert result == {"phase": "connected", "k1_ip": "192.168.1.20"}
assert len(service.calls) == 1
action, request = service.calls[0]
assert action == "verify"
assert isinstance(request, ConnectionVerifyRequest)
assert request.device_id == "test-ble-transport"
assert request.compatibility_attestation is not None
assert request.compatibility_attestation.topology == "direct-lan"
def test_connection_verify_action_keeps_empty_refresh_request_compatible() -> None:
service = FakeXgridsService()
dispatcher = DevicePluginDispatcher(
[_in_process_runtime(XgridsK1PluginFacade(service))]
)
asyncio.run(
dispatcher.invoke(
XGRIDS_K1_PLUGIN_ID,
ACTION_CONNECTION_VERIFY,
{},
)
)
action, request = service.calls[0]
assert action == "verify"
assert isinstance(request, ConnectionVerifyRequest)
assert request.device_id is None
assert request.compatibility_attestation is None
def test_repository_runtime_composition_exactly_matches_catalog() -> None:
repository_root = Path(__file__).resolve().parents[1]
environment = load_installed_device_plugins(repository_root)
+14
View File
@@ -43,11 +43,18 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
"field control failure",
extra={
"event_code": "k1_application_control_session_failed",
"operation_id": "operation-test-01",
"operation_stage": "ble-provisioning-write",
"connection_mode": "bridge",
"error_category": "device",
"error_code": "BleakGATTProtocolError",
"reason_code": "mqtt_network_loop_failed",
"mqtt_loop_result_code": 7,
"mqtt_loop_result_name": "The connection was lost.",
"mqtt_loop_phase": "post-publish-drain",
"automatic_retry": False,
"side_effect_status": "unknown",
"network_change_attempted": True,
"camera_source_id": "sensor.camera.right",
"evidence_session_id": "20260728T163450Z_viewer_live",
"activation_trigger": "application-control-scanning",
@@ -73,10 +80,17 @@ def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
assert stat.S_IMODE(target.parent.stat().st_mode) == 0o700
assert stat.S_IMODE(target.stat().st_mode) == 0o600
assert document["event_code"] == "k1_application_control_session_failed"
assert document["operation_id"] == "operation-test-01"
assert document["operation_stage"] == "ble-provisioning-write"
assert document["connection_mode"] == "bridge"
assert document["error_category"] == "device"
assert document["error_code"] == "BleakGATTProtocolError"
assert document["reason_code"] == "mqtt_network_loop_failed"
assert document["mqtt_loop_result_code"] == 7
assert document["mqtt_loop_phase"] == "post-publish-drain"
assert document["automatic_retry"] is False
assert document["side_effect_status"] == "unknown"
assert document["network_change_attempted"] is True
assert document["camera_source_id"] == "sensor.camera.right"
assert document["evidence_session_id"] == "20260728T163450Z_viewer_live"
assert document["activation_trigger"] == "application-control-scanning"
+40
View File
@@ -3,12 +3,14 @@ from types import SimpleNamespace
from typing import Any
import pytest
from bleak.exc import BleakDeviceNotFoundError
import k1link.device_plugins.xgrids_k1.ble.wifi_provisioning as wifi_module
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
FRAME_LENGTH,
build_wifi_provisioning_frame,
parse_wifi_status,
provision_wifi_once,
read_wifi_status_once,
)
@@ -185,3 +187,41 @@ 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(
monkeypatch: pytest.MonkeyPatch,
) -> None:
stale_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 ForbiddenClient:
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")
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle)
monkeypatch.setattr(
wifi_module.BleakScanner,
"find_device_by_address",
missing_device,
)
monkeypatch.setattr(wifi_module, "BleakClient", ForbiddenClient)
with pytest.raises(BleakDeviceNotFoundError):
asyncio.run(
provision_wifi_once(
"synthetic-corebluetooth-uuid",
"LabNet",
"synthetic-password",
timeout_seconds=1.0,
)
)
assert rediscovery_calls == [("synthetic-corebluetooth-uuid", 1.0)]
assert client_calls == []
+411 -15
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import asyncio
import json
import logging
import threading
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
@@ -18,6 +19,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
AbortAcquisitionRequest,
CameraPreviewSelectRequest,
CompatibilityAttestationRequest,
ConnectionVerifyRequest,
ConnectRequest,
OpenApplicationControlSessionRequest,
OperatorPresenceRequest,
@@ -198,7 +200,7 @@ def service_with_fake_runtime(
return service, runtime
def _wifi_status_read(ipv4: str) -> dict[str, Any]:
def _wifi_status_read(ipv4: str | None) -> dict[str, Any]:
return {
"schema_version": 1,
"profile_id": "xgrids-k1-fw3-wifi-v1",
@@ -222,6 +224,383 @@ def _wifi_status_read(ipv4: str) -> dict[str, Any]:
}
def _set_scanned_devices(
service: XgridsK1CompatibilityService,
devices: list[dict[str, Any]],
) -> None:
service._devices = devices # noqa: SLF001
observed_monotonic = facade_module.time.monotonic()
service._ble_device_last_seen_monotonic = { # noqa: SLF001
str(item["device_id"]): observed_monotonic for item in devices
}
def _set_scanned_k1(
service: XgridsK1CompatibilityService,
*,
device_id: str = "test-ble-transport",
) -> None:
_set_scanned_devices(service, [
{
"device_id": device_id,
"name": "XGR-K1",
"rssi": -44,
"address": None,
"connectable": True,
"likely_k1": True,
}
])
def test_ble_discovery_lease_expiry_hides_and_rejects_candidate(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
_set_scanned_k1(service)
service._ble_device_last_seen_monotonic["test-ble-transport"] = ( # noqa: SLF001
facade_module.time.monotonic()
- facade_module.BLE_DISCOVERY_LEASE_TTL_SECONDS
- 0.001
)
async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("an expired candidate must not be rediscovered by an action")
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("an expired candidate must not reach the Wi-Fi write boundary")
monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read)
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
stale_state = service.state()
assert stale_state["devices"] == []
assert "устарели" in stale_state["message"]
with pytest.raises(ValueError, match="найдите и выберите"):
service.verify_connection(
ConnectionVerifyRequest(
device_id="test-ble-transport",
compatibility_attestation=ATTESTATION,
)
)
with pytest.raises(ValueError, match="найдите и выберите"):
asyncio.run(
service.connect(
ConnectRequest(
device_id="test-ble-transport",
ssid="lab-network",
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
compatibility_attestation=ATTESTATION,
)
)
)
def test_new_ble_scan_generation_invalidates_old_candidates_before_io_and_on_failure(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
_set_scanned_k1(service, device_id="old-scan-device")
async def scenario() -> None:
entered = asyncio.Event()
release = asyncio.Event()
async def failing_scan(_duration_seconds: float) -> dict[str, Any]:
entered.set()
await release.wait()
raise RuntimeError("synthetic BLE scan failure")
monkeypatch.setattr(facade_module, "scan", failing_scan)
scan_task = asyncio.create_task(service.scan_ble(6.0))
await asyncio.wait_for(entered.wait(), timeout=1.0)
assert service.state()["devices"] == []
release.set()
with pytest.raises(RuntimeError, match="synthetic BLE scan failure"):
await asyncio.wait_for(scan_task, timeout=1.0)
asyncio.run(scenario())
assert service.state()["devices"] == []
assert service._devices == [] # noqa: SLF001
assert service._ble_device_last_seen_monotonic == {} # noqa: SLF001
def test_older_ble_scan_cannot_replace_a_newer_generation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
first_entered = asyncio.Event()
release_first = asyncio.Event()
call_count = 0
def scan_result(device_id: str) -> dict[str, Any]:
return {
"devices": [
{
"macos_uuid": device_id,
"name": "XGR-K1",
"local_name": "XGR-K1",
"rssi": -44,
"k1_name_candidate": True,
}
]
}
async def overlapping_scan(_duration_seconds: float) -> dict[str, Any]:
nonlocal call_count
call_count += 1
if call_count == 1:
first_entered.set()
await release_first.wait()
return scan_result("older-generation")
return scan_result("newer-generation")
async def scenario() -> dict[str, Any]:
monkeypatch.setattr(facade_module, "scan", overlapping_scan)
older_task = asyncio.create_task(service.scan_ble(6.0))
await asyncio.wait_for(first_entered.wait(), timeout=1.0)
newer_state = await service.scan_ble(6.0)
release_first.set()
await asyncio.wait_for(older_task, timeout=1.0)
return newer_state
newer_state = asyncio.run(scenario())
assert [item["device_id"] for item in newer_state["devices"]] == ["newer-generation"]
assert [item["device_id"] for item in service.state()["devices"]] == [
"newer-generation"
]
def test_connect_stops_before_ble_write_when_a_new_scan_replaces_its_generation(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
_set_scanned_k1(service)
preflight_entered = threading.Event()
release_preflight = threading.Event()
def delayed_preflight(*_: object, **__: object) -> dict[str, Any]:
preflight_entered.set()
if not release_preflight.wait(timeout=1.0):
raise RuntimeError("test preflight release timed out")
return {
"schema_version": 1,
"adapter": "macOS Keychain",
"available": True,
"profile_enrolled": False,
"credential_source": "exact-firmware-profile",
}
async def replacement_scan(_duration_seconds: float) -> dict[str, Any]:
return {
"devices": [
{
"macos_uuid": "replacement-device",
"name": "XGR-NEW",
"local_name": "XGR-NEW",
"rssi": -40,
"k1_name_candidate": True,
}
]
}
@asynccontextmanager
async def forbidden_activation(
*_args: object,
**_kwargs: object,
) -> AsyncIterator[dict[str, Any]]:
raise AssertionError("changed discovery generation must stop before BLE write")
yield {}
monkeypatch.setattr(
facade_module,
"ensure_wifi_profile_from_credential_source",
delayed_preflight,
)
monkeypatch.setattr(facade_module, "scan", replacement_scan)
monkeypatch.setattr(facade_module, "device_ap_activation_session", forbidden_activation)
async def scenario() -> None:
connect_task = asyncio.create_task(
service.connect(
ConnectRequest(
device_id="test-ble-transport",
connection_mode="quick-connect",
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
)
)
)
entered = await asyncio.wait_for(
asyncio.to_thread(preflight_entered.wait, 1.0),
timeout=1.5,
)
assert entered is True
replacement_state = await service.scan_ble(6.0)
assert [item["device_id"] for item in replacement_state["devices"]] == [
"replacement-device"
]
release_preflight.set()
with pytest.raises(ValueError, match="изменились или устарели"):
await asyncio.wait_for(connect_task, timeout=1.0)
try:
asyncio.run(scenario())
finally:
release_preflight.set()
def test_verify_connection_adopts_scanned_existing_lan_without_device_write(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
_set_scanned_k1(service)
status_reads: list[tuple[str, float, bool]] = []
async def fake_status_read(
device_id: str,
*,
timeout_seconds: float,
rediscover: bool,
) -> dict[str, Any]:
status_reads.append((device_id, timeout_seconds, rediscover))
return _wifi_status_read("10.255.254.77")
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("connection.verify must never call Wi-Fi provisioning")
monkeypatch.setattr(facade_module, "read_wifi_status_once", fake_status_read)
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: False)
monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: "direct-or-routed")
monkeypatch.setattr(facade_module, "_control_endpoint_reachable", lambda _target: True)
state = service.verify_connection(
ConnectionVerifyRequest(
device_id="test-ble-transport",
compatibility_attestation=ATTESTATION,
)
)
assert status_reads == [("test-ble-transport", 20.0, True)]
assert state["selected_device_id"] == "test-ble-transport"
assert state["k1_ip"] == "10.255.254.77"
assert state["connection_mode"] == "bridge"
assert state["device_ref"]["transport_alias"] == "test-ble-transport"
assert state["device_session"]["connectivity"] == "connected"
assert state["compatibility"]["attestation"]["topology"] == "direct-lan"
assert state["connection_verification"] == {
"status": "adopted",
"lease_state": "reachable",
"lease_generation": 1,
"endpoint_validation": "ble-wifi-status-read+mqtt-tcp-connect",
"network_reachability": "reachable",
"host_route_class": "direct-or-routed",
"address_source": "ble-wifi-status-read",
"connection_origin": "external-existing-network",
"admission_source": "connection.verify",
"address_changed": True,
"previous_address_present": False,
"write_performed": False,
"observed_at": "2026-07-20T12:00:00Z",
}
def test_verify_connection_adoption_requires_current_scan_candidate(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("an unscanned device must not be probed")
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("connection.verify must never call Wi-Fi provisioning")
monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read)
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
with pytest.raises(ValueError, match="найдите и выберите"):
service.verify_connection(
ConnectionVerifyRequest(
device_id="not-in-current-scan",
compatibility_attestation=ATTESTATION,
)
)
@pytest.mark.parametrize(
("ipv4", "is_local", "route_class", "endpoint_reachable", "message"),
[
(None, False, "direct-or-routed", True, "не сообщил актуальный DHCP-адрес"),
("192.168.56.1", False, "device-ap", True, "не сообщил актуальный DHCP-адрес"),
("10.255.254.77", True, "direct-or-routed", True, "этому компьютеру"),
("10.255.254.77", False, "tunnel", True, "прямой локальный маршрут"),
("10.255.254.77", False, "default-route", True, "прямой локальный маршрут"),
("10.255.254.77", False, "direct-or-routed", False, "1883 недоступен"),
],
)
def test_verify_connection_adoption_fails_closed_before_establishing_lease(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
ipv4: str | None,
is_local: bool,
route_class: str,
endpoint_reachable: bool,
message: str,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
_set_scanned_k1(service)
async def fake_status_read(*_: object, **__: object) -> dict[str, Any]:
return _wifi_status_read(ipv4)
async def forbidden_provision(*_: object, **__: object) -> dict[str, Any]:
raise AssertionError("connection.verify must never call Wi-Fi provisioning")
monkeypatch.setattr(facade_module, "read_wifi_status_once", fake_status_read)
monkeypatch.setattr(facade_module, "provision_wifi_once", forbidden_provision)
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: is_local)
monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: route_class)
monkeypatch.setattr(
facade_module,
"_control_endpoint_reachable",
lambda _target: endpoint_reachable,
)
with pytest.raises(RuntimeError, match=message):
service.verify_connection(
ConnectionVerifyRequest(
device_id="test-ble-transport",
compatibility_attestation=ATTESTATION,
)
)
state = service.state()
assert state["selected_device_id"] is None
assert state["k1_ip"] is None
assert state["connection_mode"] is None
assert state["device_session"] is None
assert state["connection_verification"].get("write_performed") is not True
def test_connection_verify_request_requires_exact_bridge_attestation() -> None:
with pytest.raises(ValidationError, match="provided together"):
ConnectionVerifyRequest(device_id="test-ble-transport")
with pytest.raises(ValidationError, match="topology=direct-lan"):
ConnectionVerifyRequest(
device_id="test-ble-transport",
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
)
def test_verify_connection_refreshes_dynamic_dhcp_address_and_rotates_session(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
@@ -2273,10 +2652,10 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [ # noqa: SLF001 - deliberate white-box concurrency fixture
{"device_id": "k1-a"},
{"device_id": "k1-b"},
]
_set_scanned_devices(
service,
[{"device_id": "k1-a"}, {"device_id": "k1-b"}],
)
boundary_calls: list[tuple[str, str, str]] = []
async def scenario() -> dict[str, Any]:
@@ -2346,7 +2725,7 @@ def test_bridge_network_change_retires_terminal_acquisition_and_receiver_error(
tmp_path: Path,
) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
prepared = service.prepare_acquisition(
PrepareAcquisitionRequest(
project_name=PROJECT_NAME,
@@ -2403,7 +2782,7 @@ def test_bridge_provisioning_reports_host_route_mismatch_without_hiding_device_s
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
return {
@@ -2457,7 +2836,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
activation_calls: list[str] = []
association_calls: list[tuple[Path, str, str]] = []
ble_session_open = False
@@ -2574,7 +2953,7 @@ def test_direct_connect_reuses_the_reviewed_ble_provisioning_frame(
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
provisioning_calls: list[tuple[str, str, str]] = []
async def fake_provision(
@@ -2631,7 +3010,7 @@ def test_quick_connect_missing_credential_provider_stops_before_ap_write(
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
service._selected_device_id = "previous-k1" # noqa: SLF001
service._k1_ip = "192.168.1.20" # noqa: SLF001
service._connection_mode = "bridge" # noqa: SLF001
@@ -2688,7 +3067,7 @@ def test_quick_connect_does_not_start_host_wifi_without_ap_ready_flag(
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
monkeypatch.setattr(
facade_module,
"ensure_wifi_profile_from_credential_source",
@@ -2749,7 +3128,7 @@ def test_failed_connection_change_revokes_the_previous_route(
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
service._selected_device_id = "previous-k1" # noqa: SLF001
service._k1_ip = "192.168.1.20" # noqa: SLF001
service._connection_mode = "bridge" # noqa: SLF001
@@ -2819,9 +3198,10 @@ def test_failed_connection_change_revokes_the_previous_route(
def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
monkeypatch: pytest.MonkeyPatch,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
_set_scanned_devices(service, [{"device_id": "k1-a"}])
async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
return {
@@ -2835,7 +3215,10 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: True)
with pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"):
with (
caplog.at_level(logging.ERROR, logger=facade_module.__name__),
pytest.raises(RuntimeError, match="уже принадлежит этому компьютеру"),
):
asyncio.run(
service.connect(
ConnectRequest(
@@ -2854,6 +3237,19 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
assert operation["status"] == "failed"
assert operation["error"]["safe_to_retry"] is False
assert operation["error"]["side_effect_status"] == "unknown"
failure_log = next(
record
for record in caplog.records
if getattr(record, "event_code", None) == "k1_network_provision_failed"
)
assert failure_log.operation_stage == "ble-provisioning-write"
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.network_change_attempted is True
assert PRIMARY_TEST_CREDENTIAL not in caplog.text
assert "lab-network" not in caplog.text
def test_provisioning_cannot_switch_device_during_active_acquisition(
@@ -2861,7 +3257,7 @@ def test_provisioning_cannot_switch_device_during_active_acquisition(
tmp_path: Path,
) -> None:
service, _ = service_with_fake_runtime(tmp_path)
service._devices = [{"device_id": "k1-a"}] # noqa: SLF001
_set_scanned_devices(service, [{"device_id": "k1-a"}])
service.prepare_acquisition(
PrepareAcquisitionRequest(
project_name=PROJECT_NAME,
+46
View File
@@ -1,3 +1,10 @@
import asyncio
import pytest
from bleak.exc import BleakDeviceNotFoundError
import k1link.device_plugins.xgrids_k1.ble.ap_activation as ap_module
import k1link.device_plugins.xgrids_k1.ble.scanner as scanner_module
from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
COMMAND_OFFSET,
ENABLE_AP_COMMAND,
@@ -33,3 +40,42 @@ def test_ap_control_mode_without_ready_flag_is_not_ready() -> None:
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(
monkeypatch: pytest.MonkeyPatch,
) -> None:
device_id = "synthetic-corebluetooth-uuid"
stale_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 ForbiddenClient:
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")
# 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(
ap_module.BleakScanner,
"find_device_by_address",
missing_device,
)
monkeypatch.setattr(ap_module, "BleakClient", ForbiddenClient)
with pytest.raises(BleakDeviceNotFoundError):
asyncio.run(
ap_module.activate_device_ap_once(
device_id,
timeout_seconds=1.0,
)
)
assert rediscovery_calls == [(device_id, 1.0)]
assert client_calls == []