fix(k1): stabilize repeated acquisition and live viewer recovery
This commit is contained in:
@@ -136,6 +136,31 @@ def test_live_ingress_allows_only_one_worker_consumer() -> None:
|
||||
ingress.open_consumer("worker-2")
|
||||
|
||||
|
||||
def test_live_ingress_new_session_discards_queued_events_from_previous_session() -> None:
|
||||
ingress = LivePerceptionIngress()
|
||||
ingress.open_consumer("worker-1")
|
||||
ingress.begin_session("session-1")
|
||||
assert ingress.publish(
|
||||
modality="lidar",
|
||||
source_id="lixel/application/report/lio_pcl",
|
||||
source_sequence=1,
|
||||
captured_at_epoch_ns=1,
|
||||
received_monotonic_ns=1,
|
||||
payload=b"old-session",
|
||||
)
|
||||
|
||||
ingress.end_session("session-1")
|
||||
ingress.begin_session("session-2")
|
||||
|
||||
events = []
|
||||
while (event := ingress.take_next("worker-1", timeout=0)) is not None:
|
||||
events.append(event)
|
||||
assert len(events) == 1
|
||||
assert events[0].session_id == "session-2"
|
||||
assert events[0].modality == "control"
|
||||
assert events[0].payload == b'{"event":"session-start"}'
|
||||
|
||||
|
||||
def test_live_result_round_trip_keeps_video_mask_boxes_and_shadow_authority() -> None:
|
||||
mask = np.zeros((600, 800), dtype=np.uint8)
|
||||
mask[100:120, 200:240] = 4
|
||||
|
||||
+40
-16
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import socket
|
||||
import struct
|
||||
import threading
|
||||
import time
|
||||
@@ -20,6 +22,7 @@ from k1link.viewer.rerun_bridge import (
|
||||
RerunSceneSettings,
|
||||
_live_time_panel,
|
||||
_point_colors,
|
||||
_select_available_grpc_port,
|
||||
)
|
||||
|
||||
|
||||
@@ -62,6 +65,33 @@ class DisconnectFailureRecording(FakeRecording):
|
||||
raise RuntimeError("synthetic disconnect failure")
|
||||
|
||||
|
||||
def test_rerun_port_selection_skips_a_recording_still_held_by_a_viewer(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as occupied:
|
||||
occupied.bind(("0.0.0.0", 0))
|
||||
occupied.listen()
|
||||
preferred_port = int(occupied.getsockname()[1])
|
||||
|
||||
selected_port = _select_available_grpc_port(preferred_port, search_span=8)
|
||||
recording = FakeRecording()
|
||||
with caplog.at_level(
|
||||
logging.INFO,
|
||||
logger="k1link.device_plugins.xgrids_k1.viewer_receiver",
|
||||
):
|
||||
bridge = RerunBridge(
|
||||
grpc_port=preferred_port,
|
||||
recording_factory=lambda _: recording, # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
assert selected_port != preferred_port
|
||||
assert preferred_port < selected_port < preferred_port + 8
|
||||
assert caplog.records[-1].event_code == "rerun_grpc_port_rotated"
|
||||
assert caplog.records[-1].preferred_port == preferred_port
|
||||
assert caplog.records[-1].selected_port == selected_port
|
||||
bridge.close()
|
||||
|
||||
|
||||
def _message(
|
||||
topic: str,
|
||||
payload: bytes,
|
||||
@@ -266,7 +296,7 @@ def test_palettes_are_deterministic_and_custom_color_is_exact() -> None:
|
||||
assert custom_over_rgb.tolist() == [[16, 32, 48], [16, 32, 48]]
|
||||
|
||||
|
||||
def test_runtime_reuses_one_bridge_across_sequential_sessions(tmp_path: Path) -> None:
|
||||
def test_runtime_owns_fresh_bridge_for_each_sequential_session(tmp_path: Path) -> None:
|
||||
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||
point_topic = "RealtimePointcloud"
|
||||
pose_topic = "RealtimePath"
|
||||
@@ -300,10 +330,12 @@ def test_runtime_reuses_one_bridge_across_sequential_sessions(tmp_path: Path) ->
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
recording = FakeRecording()
|
||||
recordings: list[FakeRecording] = []
|
||||
created: list[RerunBridge] = []
|
||||
|
||||
def bridge_factory(**kwargs: object) -> RerunBridge:
|
||||
recording = FakeRecording()
|
||||
recordings.append(recording)
|
||||
bridge = RerunBridge(
|
||||
recording_factory=lambda _: recording, # type: ignore[arg-type]
|
||||
**kwargs, # type: ignore[arg-type]
|
||||
@@ -329,8 +361,8 @@ def test_runtime_reuses_one_bridge_across_sequential_sessions(tmp_path: Path) ->
|
||||
assert snapshot["metrics"]["mqtt_to_publish_ms"] is None
|
||||
runtime.stop(wait_seconds=5.0)
|
||||
assert runtime.snapshot()["phase"] == "idle"
|
||||
assert runtime.snapshot()["rerun_grpc_url"] == "rerun+http://127.0.0.1:9876/proxy"
|
||||
assert recording.disconnected is False
|
||||
assert runtime.snapshot()["rerun_grpc_url"] is None
|
||||
assert recordings[0].disconnected is True
|
||||
|
||||
runtime.start_replay(capture, speed=0.0)
|
||||
deadline = time.monotonic() + 5.0
|
||||
@@ -340,13 +372,12 @@ def test_runtime_reuses_one_bridge_across_sequential_sessions(tmp_path: Path) ->
|
||||
time.sleep(0.05)
|
||||
runtime.stop(wait_seconds=5.0)
|
||||
|
||||
assert len(created) == 1
|
||||
assert len(created) == 2
|
||||
assert runtime.snapshot()["metrics"]["pcl_frames"] == 1
|
||||
assert runtime.snapshot()["rerun_grpc_url"] == "rerun+http://127.0.0.1:9876/proxy"
|
||||
assert recording.disconnected is False
|
||||
assert runtime.snapshot()["rerun_grpc_url"] is None
|
||||
assert all(recording.disconnected for recording in recordings)
|
||||
runtime.close()
|
||||
assert runtime.snapshot()["rerun_grpc_url"] is None
|
||||
assert recording.disconnected is True
|
||||
|
||||
|
||||
def test_runtime_reports_bridge_close_failure_instead_of_false_idle(tmp_path: Path) -> None:
|
||||
@@ -385,18 +416,11 @@ def test_runtime_reports_bridge_close_failure_instead_of_false_idle(tmp_path: Pa
|
||||
time.sleep(0.01)
|
||||
snapshot = runtime.snapshot()
|
||||
|
||||
assert snapshot["phase"] == "idle"
|
||||
assert snapshot["rerun_grpc_url"] == "rerun+http://127.0.0.1:9876/proxy"
|
||||
assert recording.disconnected is False
|
||||
|
||||
with pytest.raises(RuntimeError, match="synthetic disconnect failure"):
|
||||
runtime.close()
|
||||
|
||||
snapshot = runtime.snapshot()
|
||||
assert snapshot["phase"] == "error"
|
||||
assert "synthetic disconnect failure" in snapshot["message"]
|
||||
assert snapshot["rerun_grpc_url"] is None
|
||||
assert recording.disconnected is True
|
||||
runtime.close()
|
||||
|
||||
|
||||
def test_close_during_blocked_factory_closes_the_late_bridge(tmp_path: Path) -> None:
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import stat
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
from fastapi import APIRouter
|
||||
from fastapi.routing import APIRoute
|
||||
from pydantic import ValidationError
|
||||
|
||||
from k1link.web.runtime_diagnostics import (
|
||||
SCANNER_LOGGER_NAME,
|
||||
configure_scanner_diagnostics,
|
||||
)
|
||||
from k1link.web.viewer_diagnostics_api import (
|
||||
LiveViewerDiagnosticEvent,
|
||||
build_viewer_diagnostics_router,
|
||||
)
|
||||
|
||||
|
||||
def _endpoint(router: APIRouter, path: str, method: str) -> Callable[..., Any]:
|
||||
for route in router.routes:
|
||||
if (
|
||||
isinstance(route, APIRoute)
|
||||
and route.path == path
|
||||
and route.methods is not None
|
||||
and method in route.methods
|
||||
):
|
||||
return route.endpoint
|
||||
raise AssertionError(f"{method} {path} route is missing")
|
||||
|
||||
|
||||
def test_private_scanner_diagnostics_are_durable_structured_and_bounded(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
target = configure_scanner_diagnostics(tmp_path / "logs")
|
||||
logger = logging.getLogger(f"{SCANNER_LOGGER_NAME}.test")
|
||||
logger.error(
|
||||
"field control failure",
|
||||
extra={
|
||||
"event_code": "k1_application_control_session_failed",
|
||||
"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,
|
||||
"camera_source_id": "sensor.camera.right",
|
||||
"evidence_session_id": "20260728T163450Z_viewer_live",
|
||||
"activation_trigger": "application-control-scanning",
|
||||
"network_change_admissible": True,
|
||||
"network_change_reconciliation": (
|
||||
"explicit-network-change-only-after-acknowledged-stop"
|
||||
),
|
||||
"lease_generation": 3,
|
||||
"lease_state": "reachable",
|
||||
"recovery_strategy": "existing-mqtt-endpoint",
|
||||
"endpoint_reachable": True,
|
||||
"address_changed": False,
|
||||
"device_write_performed": False,
|
||||
"preferred_port": 9876,
|
||||
"selected_port": 9877,
|
||||
"unapproved_secret_field": "must-not-be-written",
|
||||
},
|
||||
)
|
||||
for handler in logging.getLogger(SCANNER_LOGGER_NAME).handlers:
|
||||
handler.flush()
|
||||
|
||||
document = json.loads(target.read_text(encoding="utf-8").splitlines()[-1])
|
||||
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["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["camera_source_id"] == "sensor.camera.right"
|
||||
assert document["evidence_session_id"] == "20260728T163450Z_viewer_live"
|
||||
assert document["activation_trigger"] == "application-control-scanning"
|
||||
assert document["network_change_admissible"] is True
|
||||
assert document["network_change_reconciliation"] == (
|
||||
"explicit-network-change-only-after-acknowledged-stop"
|
||||
)
|
||||
assert document["lease_generation"] == 3
|
||||
assert document["lease_state"] == "reachable"
|
||||
assert document["recovery_strategy"] == "existing-mqtt-endpoint"
|
||||
assert document["endpoint_reachable"] is True
|
||||
assert document["address_changed"] is False
|
||||
assert document["device_write_performed"] is False
|
||||
assert document["preferred_port"] == 9876
|
||||
assert document["selected_port"] == 9877
|
||||
assert "unapproved_secret_field" not in document
|
||||
|
||||
parent = logging.getLogger(SCANNER_LOGGER_NAME)
|
||||
for handler in list(parent.handlers):
|
||||
if getattr(handler, "baseFilename", None) == str(target):
|
||||
parent.removeHandler(handler)
|
||||
handler.close()
|
||||
|
||||
|
||||
def test_live_viewer_diagnostic_endpoint_accepts_only_bounded_events(
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
router = build_viewer_diagnostics_router()
|
||||
endpoint = _endpoint(router, "/api/v1/viewer/live-diagnostics", "POST")
|
||||
event = LiveViewerDiagnosticEvent(
|
||||
schema_version="missioncore.live-viewer-diagnostic/v1",
|
||||
event_code="live_receiver_stalled",
|
||||
failure_stage="receiver-stalled",
|
||||
stream_id="acquisition-123",
|
||||
backend_activity_sequence=8_572,
|
||||
viewer_range_max_ns=231_000_000_000,
|
||||
stalled_for_ms=5_500,
|
||||
recovery_attempt=1,
|
||||
)
|
||||
|
||||
with caplog.at_level(
|
||||
logging.INFO,
|
||||
logger="k1link.device_plugins.xgrids_k1.viewer_receiver",
|
||||
):
|
||||
response = endpoint(event)
|
||||
|
||||
assert response.status_code == 204
|
||||
assert "event=live_receiver_stalled" in caplog.text
|
||||
assert caplog.records[-1].failure_stage == "receiver-stalled"
|
||||
with pytest.raises(ValidationError):
|
||||
LiveViewerDiagnosticEvent.model_validate(
|
||||
{
|
||||
**event.model_dump(),
|
||||
"source_url": "http://192.168.56.1/private",
|
||||
}
|
||||
)
|
||||
fallback = LiveViewerDiagnosticEvent(
|
||||
schema_version="missioncore.live-viewer-diagnostic/v1",
|
||||
event_code="live_receiver_active_store_admitted",
|
||||
stream_id="acquisition-123",
|
||||
backend_activity_sequence=8_573,
|
||||
)
|
||||
assert fallback.failure_stage is None
|
||||
@@ -128,3 +128,60 @@ def test_read_wifi_status_once_reads_only_and_returns_current_dhcp_address(
|
||||
assert result["operation"] == "single_reviewed_wifi_status_read"
|
||||
assert result["write_performed"] is False
|
||||
assert result["status"]["ipv4"] == "10.255.254.77"
|
||||
|
||||
|
||||
def test_read_wifi_status_recovery_rediscover_ignores_retained_handle(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
value = bytearray(54)
|
||||
value[0] = 11
|
||||
value[1:12] = b"WIFI_CLIENT"
|
||||
value[33] = 4
|
||||
value[34:38] = bytes((10, 255, 254, 77))
|
||||
value[50] = 1
|
||||
stale_handle = object()
|
||||
recovered_handle = object()
|
||||
characteristic = SimpleNamespace(
|
||||
uuid=wifi_module.STATUS_CHARACTERISTIC_UUID,
|
||||
service_uuid=wifi_module.SERVICE_UUID,
|
||||
properties=["read"],
|
||||
)
|
||||
service = SimpleNamespace(uuid=wifi_module.SERVICE_UUID)
|
||||
|
||||
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:
|
||||
return characteristic if uuid == wifi_module.STATUS_CHARACTERISTIC_UUID else None
|
||||
|
||||
class FakeClient:
|
||||
def __init__(self, device: object, **_kwargs: object) -> None:
|
||||
assert device is recovered_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:
|
||||
return bytes(value)
|
||||
|
||||
async def rediscover(*_args: object, **_kwargs: object) -> object:
|
||||
return recovered_handle
|
||||
|
||||
monkeypatch.setattr(wifi_module, "discovered_device", lambda _uuid: stale_handle)
|
||||
monkeypatch.setattr(wifi_module.BleakScanner, "find_device_by_address", rediscover)
|
||||
monkeypatch.setattr(wifi_module, "BleakClient", FakeClient)
|
||||
|
||||
result = asyncio.run(
|
||||
read_wifi_status_once(
|
||||
"synthetic-corebluetooth-uuid",
|
||||
rediscover=True,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["status"]["ipv4"] == "10.255.254.77"
|
||||
|
||||
@@ -247,6 +247,8 @@ def test_verify_connection_refreshes_dynamic_dhcp_address_and_rotates_session(
|
||||
assert state["device_session"]["device_session_id"] != "old-device-session"
|
||||
assert state["connection_verification"] == {
|
||||
"status": "live-address-observed",
|
||||
"lease_state": "configured",
|
||||
"lease_generation": 1,
|
||||
"endpoint_validation": "ble-wifi-status-read",
|
||||
"network_reachability": "not-probed",
|
||||
"address_changed": True,
|
||||
@@ -283,7 +285,7 @@ def test_implicit_acquisition_target_uses_current_ble_dhcp_address(
|
||||
assert state["acquisition"]["target_host"] == "10.255.254.77"
|
||||
|
||||
|
||||
def test_control_session_opens_against_current_ble_dhcp_address(
|
||||
def test_control_session_reuses_reachable_process_owned_connection_lease(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
@@ -291,6 +293,8 @@ def test_control_session_opens_against_current_ble_dhcp_address(
|
||||
service._selected_device_id = "test-ble-transport" # noqa: SLF001
|
||||
service._connection_mode = "bridge" # noqa: SLF001
|
||||
service._k1_ip = "10.255.254.54" # noqa: SLF001
|
||||
service._device_id = "known-k1" # noqa: SLF001
|
||||
service._device_session_id = "known-session" # noqa: SLF001
|
||||
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
|
||||
opened_hosts: list[str] = []
|
||||
|
||||
@@ -302,12 +306,12 @@ def test_control_session_opens_against_current_ble_dhcp_address(
|
||||
opened_hosts.append(host)
|
||||
return self.snapshot()
|
||||
|
||||
async def fake_status_read(*_: object, **__: object) -> dict[str, Any]:
|
||||
return _wifi_status_read("10.255.254.77")
|
||||
async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise AssertionError("reachable connection lease must not reopen BLE")
|
||||
|
||||
service._application_control_session = FakeOpenControlSession() # type: ignore[assignment] # noqa: SLF001
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", fake_status_read)
|
||||
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: False)
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read)
|
||||
monkeypatch.setattr(facade_module, "_control_endpoint_reachable", lambda _target: True)
|
||||
|
||||
state = service.open_application_control_session(
|
||||
OpenApplicationControlSessionRequest(
|
||||
@@ -320,8 +324,241 @@ def test_control_session_opens_against_current_ble_dhcp_address(
|
||||
)
|
||||
)
|
||||
|
||||
assert opened_hosts == ["10.255.254.54"]
|
||||
assert state["k1_ip"] == "10.255.254.54"
|
||||
assert state["connection_verification"]["lease_state"] == "reachable"
|
||||
assert state["connection_verification"]["network_reachability"] == "reachable"
|
||||
open_operation = next(
|
||||
operation
|
||||
for operation in state["operations"]
|
||||
if operation["action"] == "application-control.session.open"
|
||||
)
|
||||
assert open_operation["status"] == "succeeded"
|
||||
assert open_operation["result"] == {
|
||||
"lease_generation": 0,
|
||||
"connection_lease_reused": True,
|
||||
"recovery_performed": False,
|
||||
"address_changed": False,
|
||||
"device_write_performed": False,
|
||||
}
|
||||
|
||||
|
||||
def test_reachable_connection_lease_supports_repeated_independent_control_sessions(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._selected_device_id = "test-ble-transport" # noqa: SLF001
|
||||
service._connection_mode = "bridge" # noqa: SLF001
|
||||
service._k1_ip = "10.255.254.54" # noqa: SLF001
|
||||
service._device_id = "known-k1" # noqa: SLF001
|
||||
service._device_session_id = "known-session" # noqa: SLF001
|
||||
service._connection_lease_generation = 7 # noqa: SLF001
|
||||
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
|
||||
opened_hosts: list[str] = []
|
||||
|
||||
class FakeCompletedControlSession:
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": "completed",
|
||||
"can_open": True,
|
||||
"can_confirm_standby": False,
|
||||
}
|
||||
|
||||
def open(self, *, host: str, **_: object) -> dict[str, object]:
|
||||
opened_hosts.append(host)
|
||||
return self.snapshot()
|
||||
|
||||
async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise AssertionError("repeated scans must not repeat BLE or Wi-Fi setup")
|
||||
|
||||
service._application_control_session = FakeCompletedControlSession() # type: ignore[assignment] # noqa: SLF001
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read)
|
||||
monkeypatch.setattr(facade_module, "_control_endpoint_reachable", lambda _target: True)
|
||||
request = OpenApplicationControlSessionRequest(
|
||||
operator_present=True,
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
|
||||
service.open_application_control_session(request)
|
||||
state = service.open_application_control_session(request)
|
||||
|
||||
assert opened_hosts == ["10.255.254.54", "10.255.254.54"]
|
||||
assert state["device_session"]["device_session_id"] == "known-session"
|
||||
assert state["connection_verification"]["lease_generation"] == 7
|
||||
open_operations = [
|
||||
operation
|
||||
for operation in state["operations"]
|
||||
if operation["action"] == "application-control.session.open"
|
||||
]
|
||||
assert len(open_operations) == 2
|
||||
assert {operation["status"] for operation in open_operations} == {"succeeded"}
|
||||
assert all(
|
||||
operation["result"]["connection_lease_reused"] is True for operation in open_operations
|
||||
)
|
||||
|
||||
|
||||
def test_control_session_recovers_changed_bridge_address_without_wifi_write(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._selected_device_id = "test-ble-transport" # noqa: SLF001
|
||||
service._connection_mode = "bridge" # noqa: SLF001
|
||||
service._k1_ip = "10.255.254.54" # noqa: SLF001
|
||||
service._device_id = "known-k1" # noqa: SLF001
|
||||
service._device_session_id = "old-session" # noqa: SLF001
|
||||
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
|
||||
opened_hosts: list[str] = []
|
||||
status_calls: list[dict[str, object]] = []
|
||||
|
||||
class FakeOpenControlSession:
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {"state": "idle", "can_confirm_standby": False}
|
||||
|
||||
def open(self, *, host: str, **_: object) -> dict[str, object]:
|
||||
opened_hosts.append(host)
|
||||
return self.snapshot()
|
||||
|
||||
async def fake_status_read(*_: object, **kwargs: object) -> dict[str, Any]:
|
||||
status_calls.append(kwargs)
|
||||
return _wifi_status_read("10.255.254.77")
|
||||
|
||||
service._application_control_session = FakeOpenControlSession() # type: ignore[assignment] # noqa: SLF001
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", fake_status_read)
|
||||
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: target == "10.255.254.77",
|
||||
)
|
||||
|
||||
state = service.open_application_control_session(
|
||||
OpenApplicationControlSessionRequest(
|
||||
operator_present=True,
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
)
|
||||
|
||||
assert status_calls == [{"timeout_seconds": 20.0, "rediscover": True}]
|
||||
assert opened_hosts == ["10.255.254.77"]
|
||||
assert state["k1_ip"] == "10.255.254.77"
|
||||
assert state["device_session"]["device_session_id"] != "old-session"
|
||||
assert state["connection_verification"]["status"] == "recovered"
|
||||
assert state["connection_verification"]["write_performed"] is False
|
||||
open_operation = next(
|
||||
operation
|
||||
for operation in state["operations"]
|
||||
if operation["action"] == "application-control.session.open"
|
||||
)
|
||||
assert open_operation["result"]["recovery_performed"] is True
|
||||
assert open_operation["result"]["address_changed"] is True
|
||||
assert open_operation["result"]["device_write_performed"] is False
|
||||
|
||||
|
||||
def test_control_session_prestart_failure_is_journaled_and_marks_lease_offline(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._selected_device_id = "test-ble-transport" # noqa: SLF001
|
||||
service._connection_mode = "bridge" # noqa: SLF001
|
||||
service._k1_ip = "10.255.254.54" # noqa: SLF001
|
||||
service._device_id = "known-k1" # noqa: SLF001
|
||||
service._device_session_id = "known-session" # noqa: SLF001
|
||||
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
|
||||
|
||||
async def failed_status_read(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise TimeoutError("synthetic BLE recovery timeout")
|
||||
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", failed_status_read)
|
||||
monkeypatch.setattr(facade_module, "_control_endpoint_reachable", lambda _target: False)
|
||||
monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: "direct-or-routed")
|
||||
|
||||
with pytest.raises(
|
||||
facade_module.ConnectionLeaseUnavailable,
|
||||
match="повторное чтение состояния",
|
||||
):
|
||||
service.open_application_control_session(
|
||||
OpenApplicationControlSessionRequest(
|
||||
operator_present=True,
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
assert state["application_control_session"]["state"] == "idle"
|
||||
assert state["device_session"]["connectivity"] == "offline"
|
||||
assert state["connection_verification"]["lease_state"] == "disconnected"
|
||||
open_operation = next(
|
||||
operation
|
||||
for operation in state["operations"]
|
||||
if operation["action"] == "application-control.session.open"
|
||||
)
|
||||
assert open_operation["status"] == "failed"
|
||||
assert open_operation["error"] == {
|
||||
"category": "connection",
|
||||
"code": "connection_lease_ble_recovery_failed",
|
||||
"retryable": False,
|
||||
"safe_to_retry": True,
|
||||
"side_effect_status": "none",
|
||||
}
|
||||
|
||||
|
||||
def test_bridge_route_mismatch_stops_before_ble_recovery_and_vendor_commands(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._selected_device_id = "test-ble-transport" # noqa: SLF001
|
||||
service._connection_mode = "bridge" # noqa: SLF001
|
||||
service._k1_ip = "192.168.68.50" # noqa: SLF001
|
||||
service._device_id = "known-k1" # noqa: SLF001
|
||||
service._device_session_id = "known-session" # noqa: SLF001
|
||||
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
|
||||
|
||||
async def forbidden_status_read(*_: object, **__: object) -> dict[str, Any]:
|
||||
raise AssertionError("host route mismatch must stop before BLE recovery")
|
||||
|
||||
monkeypatch.setattr(facade_module, "_control_endpoint_reachable", lambda _target: False)
|
||||
monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: "tunnel")
|
||||
monkeypatch.setattr(facade_module, "read_wifi_status_once", forbidden_status_read)
|
||||
|
||||
with pytest.raises(
|
||||
facade_module.ConnectionLeaseUnavailable,
|
||||
match="другой локальной сети",
|
||||
):
|
||||
service.open_application_control_session(
|
||||
OpenApplicationControlSessionRequest(
|
||||
operator_present=True,
|
||||
owner_controlled_device=True,
|
||||
lixelgo_closed=True,
|
||||
battery_storage_confirmed=True,
|
||||
expected_physical_state_confirmed=True,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
)
|
||||
|
||||
state = service.state()
|
||||
assert state["connection_verification"]["reason_code"] == (
|
||||
"connection_lease_host_route_mismatch"
|
||||
)
|
||||
assert state["connection_verification"]["endpoint_validation"] == "host-route"
|
||||
assert state["connection_verification"]["host_route_class"] == "tunnel"
|
||||
assert state["application_control_session"]["state"] == "idle"
|
||||
|
||||
|
||||
def test_prepare_creates_provisional_device_session_and_profiled_acquisition(
|
||||
@@ -384,18 +621,24 @@ def test_acquisition_is_unbounded_by_default_and_accepts_ten_hour_hint() -> None
|
||||
|
||||
|
||||
def test_connection_modes_require_their_exact_topology_attestation() -> None:
|
||||
assert ConnectRequest(
|
||||
device_id="synthetic-device",
|
||||
connection_mode="quick-connect",
|
||||
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||
).connection_mode == "quick-connect"
|
||||
assert ConnectRequest(
|
||||
device_id="synthetic-device",
|
||||
ssid="synthetic-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
connection_mode="direct-connect",
|
||||
compatibility_attestation=DIRECT_CONNECT_ATTESTATION,
|
||||
).connection_mode == "direct-connect"
|
||||
assert (
|
||||
ConnectRequest(
|
||||
device_id="synthetic-device",
|
||||
connection_mode="quick-connect",
|
||||
compatibility_attestation=QUICK_CONNECT_ATTESTATION,
|
||||
).connection_mode
|
||||
== "quick-connect"
|
||||
)
|
||||
assert (
|
||||
ConnectRequest(
|
||||
device_id="synthetic-device",
|
||||
ssid="synthetic-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
connection_mode="direct-connect",
|
||||
compatibility_attestation=DIRECT_CONNECT_ATTESTATION,
|
||||
).connection_mode
|
||||
== "direct-connect"
|
||||
)
|
||||
with pytest.raises(ValidationError):
|
||||
ConnectRequest(
|
||||
device_id="synthetic-device",
|
||||
@@ -628,6 +871,91 @@ def test_plugin_commanded_acquisition_keeps_start_and_stop_as_explicit_actions(
|
||||
assert completed["acquisition"]["result"]["device_state"] == "ready"
|
||||
assert completed["last_operation"]["status"] == "succeeded"
|
||||
assert runtime.stop_calls == 1
|
||||
assert completed["live_perception_shadow"]["active"] is False
|
||||
|
||||
|
||||
def test_next_scan_retires_stale_terminal_live_perception_ingress_before_start(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
service.live_perception_ingress.begin_session("stale-completed-session")
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
project_name=PROJECT_NAME,
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
|
||||
state = service.start_acquisition(
|
||||
StartAcquisitionRequest(acquisition_id=prepared["acquisition"]["acquisition_id"])
|
||||
)
|
||||
|
||||
assert runtime.start_calls
|
||||
assert state["acquisition"]["state"] == "starting"
|
||||
assert state["live_perception_shadow"]["active"] is True
|
||||
assert state["live_perception_shadow"]["session_id"] != "stale-completed-session"
|
||||
|
||||
|
||||
def test_confirmed_scanning_activates_and_records_right_camera(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
control = FakeInteractiveControlSession()
|
||||
service._application_control_session = control # type: ignore[assignment] # noqa: SLF001
|
||||
service._k1_ip = "192.168.1.20" # noqa: SLF001
|
||||
service._compatibility_attestation = {"profile": "exact"} # noqa: SLF001
|
||||
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
project_name="TEST001",
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
acquisition_id = prepared["acquisition"]["acquisition_id"]
|
||||
service.start_acquisition(
|
||||
StartAcquisitionRequest(
|
||||
acquisition_id=acquisition_id,
|
||||
physical_acceptance=PHYSICAL_ACCEPTANCE,
|
||||
)
|
||||
)
|
||||
out_dir = service._acquisition_out_dir # noqa: SLF001
|
||||
assert out_dir is not None
|
||||
out_dir.mkdir(parents=True)
|
||||
events: list[tuple[str, object]] = []
|
||||
camera_state: dict[str, object] = {
|
||||
"phase": "idle",
|
||||
"active_source_id": None,
|
||||
"recording": {"active": False},
|
||||
}
|
||||
|
||||
def select_camera(source_id: str, target: str) -> dict[str, object]:
|
||||
events.append(("select", (source_id, target)))
|
||||
camera_state["phase"] = "selected"
|
||||
camera_state["active_source_id"] = source_id
|
||||
return dict(camera_state)
|
||||
|
||||
def start_recording(session_dir: Path) -> dict[str, object]:
|
||||
events.append(("record", session_dir))
|
||||
camera_state["recording"] = {"active": True}
|
||||
camera_state["phase"] = "connecting"
|
||||
return dict(camera_state)
|
||||
|
||||
monkeypatch.setattr(service.camera_preview, "snapshot", lambda: dict(camera_state))
|
||||
monkeypatch.setattr(service.camera_preview, "select", select_camera)
|
||||
monkeypatch.setattr(service.camera_preview, "start_recording", start_recording)
|
||||
|
||||
service._activate_default_acquisition_camera() # noqa: SLF001
|
||||
|
||||
assert events == [
|
||||
("select", ("sensor.camera.right", "192.168.1.20")),
|
||||
("record", out_dir),
|
||||
]
|
||||
assert camera_state["active_source_id"] == "sensor.camera.right"
|
||||
assert camera_state["recording"] == {"active": True}
|
||||
assert runtime.source_mode == "live"
|
||||
|
||||
|
||||
def test_device_standby_retires_sources_after_terminal_local_stop_failure(
|
||||
@@ -1611,6 +1939,9 @@ def test_runtime_error_terminalizes_pending_graceful_stop(tmp_path: Path) -> Non
|
||||
assert failed["acquisition"]["state"] == "failed"
|
||||
assert stop_operation["status"] == "failed"
|
||||
assert stop_operation["error"]["side_effect_status"] == "unknown"
|
||||
assert failed["source_mode"] == "idle"
|
||||
assert failed["phase"] != "error"
|
||||
assert runtime.stop_calls == 1
|
||||
|
||||
|
||||
def test_receiver_completion_terminalizes_unconfirmed_graceful_stop(
|
||||
@@ -1970,6 +2301,11 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
|
||||
}
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"_host_route_class",
|
||||
lambda _target: "direct-or-routed",
|
||||
)
|
||||
first = asyncio.create_task(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
@@ -2005,6 +2341,117 @@ def test_network_provisioning_is_single_flight_and_secret_is_unwrapped_only_at_b
|
||||
assert {item["status"] for item in provision_operations} == {"succeeded", "failed"}
|
||||
|
||||
|
||||
def test_bridge_network_change_retires_terminal_acquisition_and_receiver_error(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
project_name=PROJECT_NAME,
|
||||
host="192.168.1.20",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
service.abort_acquisition(
|
||||
AbortAcquisitionRequest(
|
||||
acquisition_id=prepared["acquisition"]["acquisition_id"],
|
||||
)
|
||||
)
|
||||
runtime.phase = "error"
|
||||
runtime.source_mode = "live"
|
||||
stop_calls_before_connect = runtime.stop_calls
|
||||
|
||||
async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||
return {
|
||||
"started_at_utc": "2026-07-28T18:11:37Z",
|
||||
"completed_at_utc": "2026-07-28T18:11:44Z",
|
||||
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||
"outcome": "lan_address_observed",
|
||||
"observations": [{"status": {"ipv4": "192.168.1.20"}}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"_host_route_class",
|
||||
lambda _target: "direct-or-routed",
|
||||
)
|
||||
connected = asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="lab-network",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
connection_mode="bridge",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert connected["connection_mode"] == "bridge"
|
||||
assert connected["k1_ip"] == "192.168.1.20"
|
||||
assert connected["acquisition"] is None
|
||||
assert connected["source_mode"] == "idle"
|
||||
assert connected["phase"] == "connected"
|
||||
assert runtime.stop_calls == stop_calls_before_connect + 1
|
||||
|
||||
|
||||
def test_bridge_provisioning_reports_host_route_mismatch_without_hiding_device_success(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service, _ = service_with_fake_runtime(tmp_path)
|
||||
service._devices = [{"device_id": "k1-a", "name": "XGR-TEST-A"}] # noqa: SLF001
|
||||
|
||||
async def fake_provision(*_: object, **__: object) -> dict[str, Any]:
|
||||
return {
|
||||
"started_at_utc": "2026-07-28T19:43:03Z",
|
||||
"completed_at_utc": "2026-07-28T19:43:16Z",
|
||||
"profile_id": "xgrids-k1-fw3-wifi-v1",
|
||||
"outcome": "lan_address_observed",
|
||||
"observations": [{"status": {"ipv4": "192.168.68.50"}}],
|
||||
}
|
||||
|
||||
monkeypatch.setattr(facade_module, "provision_wifi_once", fake_provision)
|
||||
monkeypatch.setattr(facade_module, "_host_route_class", lambda _target: "tunnel")
|
||||
|
||||
connected = asyncio.run(
|
||||
service.connect(
|
||||
ConnectRequest(
|
||||
device_id="k1-a",
|
||||
ssid="lab-router",
|
||||
password=SecretStr(PRIMARY_TEST_CREDENTIAL),
|
||||
connection_mode="bridge",
|
||||
compatibility_attestation=ATTESTATION,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
assert connected["k1_ip"] == "192.168.68.50"
|
||||
assert connected["phase"] == "device_selected"
|
||||
assert "компьютер подключён к другой сети" in connected["message"]
|
||||
assert connected["device_session"]["connectivity"] == "offline"
|
||||
assert connected["connection_verification"] == {
|
||||
"status": "host-route-mismatch",
|
||||
"lease_state": "disconnected",
|
||||
"lease_generation": 1,
|
||||
"endpoint_validation": "host-route",
|
||||
"network_reachability": "unreachable",
|
||||
"reason_code": "connection_lease_host_route_mismatch",
|
||||
"host_route_class": "tunnel",
|
||||
"write_performed": True,
|
||||
"observed_at": connected["connection_verification"]["observed_at"],
|
||||
}
|
||||
operation = next(
|
||||
item for item in connected["operations"] if item["action"] == "network.provision"
|
||||
)
|
||||
assert operation["status"] == "succeeded"
|
||||
assert operation["result"]["host_route_ready"] is False
|
||||
assert operation["result"]["host_route_class"] == "tunnel"
|
||||
|
||||
|
||||
def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
tmp_path: Path,
|
||||
@@ -2028,9 +2475,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_activation_session(
|
||||
device_id: str, **_: object
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
async def fake_activation_session(device_id: str, **_: object) -> AsyncIterator[dict[str, Any]]:
|
||||
nonlocal ble_session_open
|
||||
activation_calls.append(device_id)
|
||||
ble_session_open = True
|
||||
@@ -2092,9 +2537,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
assert not ble_session_open
|
||||
assert len(association_calls) == 1
|
||||
assert association_calls[0][0].name == "associate_wifi.swift"
|
||||
assert association_calls[0][1] == facade_module.quick_connect_host_profile_id(
|
||||
"XGR-TEST-A"
|
||||
)
|
||||
assert association_calls[0][1] == facade_module.quick_connect_host_profile_id("XGR-TEST-A")
|
||||
assert association_calls[0][2] == "XGR-TEST-A"
|
||||
assert state["connection_mode"] == "quick-connect"
|
||||
assert state["k1_ip"] == "192.168.56.1"
|
||||
@@ -2102,9 +2545,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
quick_sessions = sorted(service.evidence_root.glob("*viewer_k1_ap_association*"))
|
||||
assert len(quick_sessions) == 1
|
||||
assert not (quick_sessions[0] / "provisioning.sensitive.json").exists()
|
||||
redacted_manifest = (quick_sessions[0] / "manifest.redacted.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
redacted_manifest = (quick_sessions[0] / "manifest.redacted.json").read_text(encoding="utf-8")
|
||||
assert PRIMARY_TEST_CREDENTIAL not in redacted_manifest
|
||||
assert "host_wifi_profile_id" in redacted_manifest
|
||||
assert '"host_wifi_profile_ready_before_device_write": true' in redacted_manifest
|
||||
@@ -2112,9 +2553,12 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
assert "credential_provider_id" in redacted_manifest
|
||||
assert "device_ap_activation_profile_id" in redacted_manifest
|
||||
assert (quick_sessions[0] / "ap-activation.redacted.json").exists()
|
||||
assert service._camera_target_for_session( # noqa: SLF001
|
||||
state["device_session"]["device_session_id"]
|
||||
) == "192.168.56.1"
|
||||
assert (
|
||||
service._camera_target_for_session( # noqa: SLF001
|
||||
state["device_session"]["device_session_id"]
|
||||
)
|
||||
== "192.168.56.1"
|
||||
)
|
||||
|
||||
prepared = service.prepare_acquisition(
|
||||
PrepareAcquisitionRequest(
|
||||
@@ -2158,6 +2602,11 @@ def test_direct_connect_reuses_the_reviewed_ble_provisioning_frame(
|
||||
forbidden_host_association,
|
||||
)
|
||||
monkeypatch.setattr(facade_module, "_target_is_local_ipv4", lambda _target: False)
|
||||
monkeypatch.setattr(
|
||||
facade_module,
|
||||
"_host_route_class",
|
||||
lambda _target: "direct-or-routed",
|
||||
)
|
||||
|
||||
state = asyncio.run(
|
||||
service.connect(
|
||||
@@ -2171,14 +2620,10 @@ def test_direct_connect_reuses_the_reviewed_ble_provisioning_frame(
|
||||
)
|
||||
)
|
||||
|
||||
assert provisioning_calls == [
|
||||
("k1-a", "controller-hotspot", PRIMARY_TEST_CREDENTIAL)
|
||||
]
|
||||
assert provisioning_calls == [("k1-a", "controller-hotspot", PRIMARY_TEST_CREDENTIAL)]
|
||||
assert state["connection_mode"] == "direct-connect"
|
||||
assert state["k1_ip"] == "172.20.10.2"
|
||||
assert state["compatibility"]["attestation"]["topology"] == (
|
||||
"controller-hotspot"
|
||||
)
|
||||
assert state["compatibility"]["attestation"]["topology"] == ("controller-hotspot")
|
||||
|
||||
|
||||
def test_quick_connect_missing_credential_provider_stops_before_ap_write(
|
||||
@@ -2232,9 +2677,7 @@ def test_quick_connect_missing_credential_provider_stops_before_ap_write(
|
||||
assert state["k1_ip"] == "192.168.1.20"
|
||||
assert state["connection_mode"] == "bridge"
|
||||
assert not list(service.evidence_root.glob("*viewer_k1_ap_association*"))
|
||||
operation = next(
|
||||
item for item in state["operations"] if item["action"] == "network.provision"
|
||||
)
|
||||
operation = next(item for item in state["operations"] if item["action"] == "network.provision")
|
||||
assert operation["status"] == "failed"
|
||||
assert operation["error"]["side_effect_status"] == "none"
|
||||
assert operation["error"]["safe_to_retry"] is True
|
||||
@@ -2259,9 +2702,7 @@ def test_quick_connect_does_not_start_host_wifi_without_ap_ready_flag(
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def not_ready_session(
|
||||
*_: object, **__: object
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
async def not_ready_session(*_: object, **__: object) -> AsyncIterator[dict[str, Any]]:
|
||||
yield {
|
||||
"profile_id": "xgrids-k1-fw3-quick-connect-ap-v1",
|
||||
"started_at_utc": "2026-07-19T15:00:00Z",
|
||||
@@ -2325,9 +2766,7 @@ def test_failed_connection_change_revokes_the_previous_route(
|
||||
)
|
||||
|
||||
@asynccontextmanager
|
||||
async def fake_activation_session(
|
||||
*_: object, **__: object
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
async def fake_activation_session(*_: object, **__: object) -> AsyncIterator[dict[str, Any]]:
|
||||
yield {
|
||||
"profile_id": "xgrids-k1-fw3-quick-connect-ap-v1",
|
||||
"started_at_utc": "2026-07-19T15:00:00Z",
|
||||
@@ -2370,9 +2809,7 @@ def test_failed_connection_change_revokes_the_previous_route(
|
||||
assert state["compatibility"]["attestation"] is None
|
||||
quick_sessions = sorted(service.evidence_root.glob("*viewer_k1_ap_association*"))
|
||||
failure_evidence = json.loads(
|
||||
(quick_sessions[0] / "host-wifi-association.redacted.json").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
(quick_sessions[0] / "host-wifi-association.redacted.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert failure_evidence["reason_code"] == "network-not-found"
|
||||
assert failure_evidence["scan_attempt_count"] == 4
|
||||
@@ -2413,9 +2850,7 @@ def test_network_provisioning_rejects_an_ipv4_owned_by_the_local_host(
|
||||
state = service.state()
|
||||
assert state["selected_device_id"] is None
|
||||
assert state["k1_ip"] is None
|
||||
operation = next(
|
||||
item for item in state["operations"] if item["action"] == "network.provision"
|
||||
)
|
||||
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"
|
||||
|
||||
@@ -778,3 +778,36 @@ def test_post_publish_timeout_poisoned_transport_never_retries() -> None:
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
)
|
||||
assert len(fake.publish_calls) == 1
|
||||
|
||||
|
||||
def test_network_loop_failure_keeps_exact_paho_result_and_phase() -> None:
|
||||
class LoopFailureClient(FakeControlClient):
|
||||
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||
if self.publish_calls and not self.events:
|
||||
return mqtt.MQTT_ERR_CONN_LOST
|
||||
return super().loop(timeout)
|
||||
|
||||
fake = LoopFailureClient()
|
||||
transport = ReviewedApplicationMqttTransport(
|
||||
"192.168.1.20",
|
||||
client_factory=lambda: cast(mqtt.Client, fake),
|
||||
)
|
||||
transport.open()
|
||||
|
||||
with pytest.raises(
|
||||
ApplicationCommandOutcomeUnknown,
|
||||
match=r"phase=post-publish-drain, result=7: The connection was lost",
|
||||
):
|
||||
transport.exchange_batch_once(
|
||||
[_envelope()],
|
||||
required_response_operation_keys={"bootstrap:1:DeviceInfoRequest"},
|
||||
)
|
||||
|
||||
snapshot = transport.snapshot().as_dict()
|
||||
assert snapshot["state"] == "poisoned"
|
||||
assert snapshot["last_loop_result_code"] == int(mqtt.MQTT_ERR_CONN_LOST)
|
||||
assert snapshot["last_loop_result_name"] == mqtt.error_string(
|
||||
int(mqtt.MQTT_ERR_CONN_LOST)
|
||||
)
|
||||
assert snapshot["last_loop_phase"] == "post-publish-drain"
|
||||
assert len(fake.publish_calls) == 1
|
||||
|
||||
@@ -170,6 +170,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
FakeExecutor.records = []
|
||||
scanning_observed = threading.Event()
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
@@ -181,6 +182,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
loader,
|
||||
transport_factory=lambda _host: transport, # type: ignore[arg-type]
|
||||
epoch_seconds=lambda: 1_752_680_000,
|
||||
scanning_observer=scanning_observed.set,
|
||||
)
|
||||
|
||||
session.open(
|
||||
@@ -202,6 +204,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
|
||||
session.request_start(project_name="TEST001", confirmation=_confirmation())
|
||||
_wait_phase(session, "scanning")
|
||||
assert scanning_observed.wait(timeout=1.0)
|
||||
assert FakeExecutor.records[-1] == "wait:stop"
|
||||
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
@@ -210,6 +213,7 @@ def test_canonical_stages_require_operator_events_but_device_standby_does_not(
|
||||
assert completed["pending_operator_action"] is None
|
||||
assert loader.calls == 1
|
||||
assert completed["automatic_retry"] is False
|
||||
assert completed["scanning_observer_errors"] == 0
|
||||
assert completed["scripted_transitions"] is False
|
||||
assert FakeExecutor.records == [
|
||||
"connection:1-6",
|
||||
@@ -562,6 +566,210 @@ def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
|
||||
assert failed["can_open"] is False
|
||||
assert failed["failure"]["modeling_command_attempted"] is True # type: ignore[index]
|
||||
assert failed["failure"]["safe_to_retry"] is False # type: ignore[index]
|
||||
with pytest.raises(
|
||||
session_module.ApplicationAcceptanceError,
|
||||
match="cannot be retired",
|
||||
):
|
||||
session.retire_for_network_change()
|
||||
|
||||
|
||||
def test_acknowledged_stop_disconnect_allows_only_explicit_network_change(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@dataclass
|
||||
class StopDisconnectTransportSnapshot:
|
||||
state: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"publish_attempts": 15,
|
||||
"qos2_completions": 15,
|
||||
"correlated_responses": 13,
|
||||
"ignored_known_responses": 304,
|
||||
"late_known_responses": 0,
|
||||
"device_status_reports": 52,
|
||||
"latest_device_session_state": "scan_stopping",
|
||||
"latest_device_project_bound": True,
|
||||
"latest_system_error_code": None,
|
||||
"last_loop_result_code": 7,
|
||||
"last_loop_result_name": "The connection was lost.",
|
||||
"last_loop_phase": "maintain-open",
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
|
||||
class StopDisconnectTransport(FakeTransport):
|
||||
def snapshot(self) -> StopDisconnectTransportSnapshot:
|
||||
return StopDisconnectTransportSnapshot(self.state)
|
||||
|
||||
class StopDisconnectExecutor(FakeExecutor):
|
||||
def __init__(self, transport: StopDisconnectTransport) -> None:
|
||||
super().__init__(transport)
|
||||
self.stop_attempted = False
|
||||
self.stop_complete = False
|
||||
|
||||
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
|
||||
self.records.append("start:11-14")
|
||||
return object()
|
||||
|
||||
def execute_canonical_stop(self, *_args: object, **_kwargs: object) -> object:
|
||||
self.records.append("stop")
|
||||
self.stop_attempted = True
|
||||
self.stop_complete = True
|
||||
return object()
|
||||
|
||||
def maintain_post_stop_until_standby(self) -> None:
|
||||
self.records.append("wait:device-standby")
|
||||
self.transport.state = "poisoned"
|
||||
raise session_module.ApplicationCommandOutcomeUnknown(
|
||||
"control MQTT network loop returned an error",
|
||||
reason_code="mqtt_network_loop_failed",
|
||||
)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"dialogue_stage": (
|
||||
"stop-acknowledged" if self.stop_complete else "test-stage"
|
||||
),
|
||||
"start_attempted": True,
|
||||
"start_complete": True,
|
||||
"stop_attempted": self.stop_attempted,
|
||||
"stop_complete": self.stop_complete,
|
||||
"response_evidence": [],
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
FakeExecutor.records = []
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
StopDisconnectExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: StopDisconnectTransport(host), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
_wait_phase(session, "connection-ready")
|
||||
session.enter_workspace()
|
||||
_wait_phase(session, "workspace-ready")
|
||||
session.open_project_prompt()
|
||||
_wait_phase(session, "project-ready")
|
||||
session.request_start(project_name="TEST001", confirmation=_confirmation())
|
||||
_wait_phase(session, "scanning")
|
||||
session.request_stop(confirmation=_confirmation())
|
||||
failed = _wait_phase(session, "failed")
|
||||
failed_thread = session._thread # noqa: SLF001
|
||||
assert failed_thread is not None
|
||||
failed_thread.join(timeout=2.0)
|
||||
assert not failed_thread.is_alive()
|
||||
failed = session.snapshot()
|
||||
|
||||
assert failed["can_open"] is False
|
||||
assert failed["outcome_unknown"] is True
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["safe_to_retry"] is False
|
||||
assert failure["network_change_admissible"] is True
|
||||
assert failure["network_change_reconciliation"] == {
|
||||
"device_session_state": "scan_stopping",
|
||||
"device_project_bound": True,
|
||||
"system_error_code": None,
|
||||
"stop_complete": True,
|
||||
"standby_confirmed": False,
|
||||
"decision": "explicit-network-change-only-after-acknowledged-stop",
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
retired = session.retire_for_network_change()
|
||||
assert retired["state"] == "idle"
|
||||
assert retired["failure"] is None
|
||||
assert retired["can_open"] is True
|
||||
|
||||
|
||||
def test_prestart_loop_failure_allows_only_fresh_explicit_retry_after_ready_status(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
@dataclass
|
||||
class ReconciledTransportSnapshot:
|
||||
state: str
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"state": self.state,
|
||||
"publish_attempts": 4,
|
||||
"qos2_completions": 4,
|
||||
"correlated_responses": 3,
|
||||
"ignored_known_responses": 0,
|
||||
"late_known_responses": 0,
|
||||
"device_status_reports": 1,
|
||||
"latest_device_session_state": "ready",
|
||||
"latest_device_project_bound": False,
|
||||
"latest_system_error_code": None,
|
||||
"last_loop_result_code": 7,
|
||||
"last_loop_result_name": "The connection was lost.",
|
||||
"last_loop_phase": "post-publish-drain",
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
|
||||
class ReconciledTransport(FakeTransport):
|
||||
def snapshot(self) -> ReconciledTransportSnapshot:
|
||||
return ReconciledTransportSnapshot(self.state)
|
||||
|
||||
class PrestartLoopFailureExecutor(FakeExecutor):
|
||||
def run_connection_stage(
|
||||
self,
|
||||
_orchestrator: object,
|
||||
) -> LiveDeviceControlBinding:
|
||||
raise session_module.ApplicationCommandOutcomeUnknown(
|
||||
"control MQTT network loop returned an error",
|
||||
reason_code="mqtt_network_loop_failed",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
PrestartLoopFailureExecutor,
|
||||
)
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: ReconciledTransport(host), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
confirmation=_confirmation(),
|
||||
)
|
||||
failed_thread = session._thread # noqa: SLF001
|
||||
assert failed_thread is not None
|
||||
failed_thread.join(timeout=2.0)
|
||||
assert not failed_thread.is_alive()
|
||||
failed = session.snapshot()
|
||||
|
||||
assert failed["state"] == "failed"
|
||||
assert failed["outcome_unknown"] is True
|
||||
assert failed["automatic_retry"] is False
|
||||
assert failed["can_open"] is True
|
||||
failure = failed["failure"]
|
||||
assert isinstance(failure, dict)
|
||||
assert failure["reason_code"] == "mqtt_network_loop_failed"
|
||||
assert failure["modeling_command_attempted"] is False
|
||||
assert failure["safe_to_retry"] is True
|
||||
assert failure["status_reconciliation"] == {
|
||||
"device_session_state": "ready",
|
||||
"device_project_bound": False,
|
||||
"system_error_code": None,
|
||||
"decision": "safe-explicit-prestart-retry",
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
|
||||
def test_unavailable_transport_snapshot_after_publish_blocks_reopen(
|
||||
|
||||
Reference in New Issue
Block a user