Стабилизация подключения K1 после сна и ожидания Bluetooth
This commit is contained in:
@@ -15417,6 +15417,56 @@ def test_control_first_loss_freezes_topology_before_ephemeral_retirement(
|
||||
assert service._physical_command_coordinator.snapshot() is physical # noqa: SLF001
|
||||
|
||||
|
||||
def test_post_start_refresh_timeout_wakes_existing_read_only_recovery(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
service, runtime = service_with_fake_runtime(tmp_path)
|
||||
control, physical = _install_composite_active_recovery_fixture(
|
||||
service,
|
||||
runtime,
|
||||
monkeypatch,
|
||||
runtime_phase="live",
|
||||
camera_phase="streaming",
|
||||
)
|
||||
with service._lock: # noqa: SLF001
|
||||
acquisition = service._acquisition # noqa: SLF001
|
||||
assert acquisition is not None
|
||||
acquisition.state = "awaiting_external_start"
|
||||
acquisition.message_code = "acquisition.start.device_initializing"
|
||||
acquisition.state_revision += 1
|
||||
control.state = "failed"
|
||||
control.outcome_unknown = True
|
||||
control.failure = {
|
||||
"reason_code": "mqtt_response_timeout",
|
||||
"failed_phase": "initializing",
|
||||
"dialogue_stage": "start-active-observed",
|
||||
"modeling_command_attempted": True,
|
||||
"stop_command_attempted": False,
|
||||
"safe_to_retry": False,
|
||||
}
|
||||
start_projects_before = list(control.start_projects)
|
||||
stop_calls_before = control.stop_calls
|
||||
|
||||
state = service.state()
|
||||
|
||||
assert runtime.recovery_requests == [
|
||||
("mqtt_response_timeout", runtime.producer_generation),
|
||||
]
|
||||
assert state["phase"] == "reconnecting"
|
||||
assert state["connection_recovery"]["automatic_read_only_rebind"] is True
|
||||
assert state["acquisition"]["state"] == "awaiting_external_start"
|
||||
assert state["acquisition"]["cleanup_pending"] is False
|
||||
assert state["acquisition"]["result"] is None
|
||||
assert state["last_operation"]["status"] == "running"
|
||||
assert state["selected_device_id"] == "test-ble-transport"
|
||||
assert state["source_mode"] == "live"
|
||||
assert runtime.stop_calls == 0
|
||||
assert control.start_projects == start_projects_before
|
||||
assert control.stop_calls == stop_calls_before
|
||||
assert service._physical_command_coordinator.snapshot() is physical # noqa: SLF001
|
||||
|
||||
|
||||
def test_incident_host_route_streak_wakes_recovery_before_binding_retirement(
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
@@ -20311,7 +20361,7 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
lambda _target: True,
|
||||
)
|
||||
_set_scanned_devices(service, [{"device_id": "k1-a", "name": "XGR-TEST-A"}])
|
||||
activation_calls: list[tuple[str, str]] = []
|
||||
activation_calls: list[tuple[str, str, float, float]] = []
|
||||
association_calls: list[tuple[Path, str, str]] = []
|
||||
ble_session_open = False
|
||||
|
||||
@@ -20331,12 +20381,21 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
async def fake_activation_session(
|
||||
device_id: str,
|
||||
*,
|
||||
timeout_seconds: float,
|
||||
connect_timeout_seconds: float,
|
||||
write_mode: str,
|
||||
on_write_dispatch: Callable[[dict[str, Any], str], None] | None = None,
|
||||
**_: object,
|
||||
) -> AsyncIterator[dict[str, Any]]:
|
||||
nonlocal ble_session_open
|
||||
activation_calls.append((device_id, write_mode))
|
||||
activation_calls.append(
|
||||
(
|
||||
device_id,
|
||||
write_mode,
|
||||
timeout_seconds,
|
||||
connect_timeout_seconds,
|
||||
)
|
||||
)
|
||||
_dispatch_test_network_write(on_write_dispatch)
|
||||
ble_session_open = True
|
||||
try:
|
||||
@@ -20396,7 +20455,14 @@ def test_quick_connect_activates_the_device_ap_then_associates_the_host(
|
||||
)
|
||||
)
|
||||
|
||||
assert activation_calls == [("k1-a", "with_response")]
|
||||
assert activation_calls == [
|
||||
(
|
||||
"k1-a",
|
||||
"with_response",
|
||||
15.0,
|
||||
facade_module.QUICK_CONNECT_BLE_CONNECT_TIMEOUT_SECONDS,
|
||||
)
|
||||
]
|
||||
assert not ble_session_open
|
||||
assert len(association_calls) == 1
|
||||
assert association_calls[0][0].name == "associate_wifi.swift"
|
||||
|
||||
@@ -79,7 +79,7 @@ def test_ap_activation_uses_retained_handle_without_rediscovery(
|
||||
device_id = "synthetic-corebluetooth-uuid"
|
||||
retained_handle = BLEDevice(device_id, "XGR-K1", details=object())
|
||||
rediscovery_calls: list[tuple[str, float]] = []
|
||||
client_calls: list[object] = []
|
||||
client_calls: list[tuple[object, float, bool]] = []
|
||||
|
||||
class SelectedHandleObserved(RuntimeError):
|
||||
pass
|
||||
@@ -89,8 +89,14 @@ def test_ap_activation_uses_retained_handle_without_rediscovery(
|
||||
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)
|
||||
def __init__(
|
||||
self,
|
||||
device: object,
|
||||
*,
|
||||
timeout: float,
|
||||
pair: bool,
|
||||
) -> None:
|
||||
client_calls.append((device, timeout, pair))
|
||||
raise SelectedHandleObserved
|
||||
|
||||
monkeypatch.setattr(scanner_module, "monotonic", lambda: 100.0)
|
||||
@@ -117,6 +123,7 @@ def test_ap_activation_uses_retained_handle_without_rediscovery(
|
||||
await ap_module.activate_device_ap_once(
|
||||
device_id,
|
||||
timeout_seconds=1.0,
|
||||
connect_timeout_seconds=30.0,
|
||||
captured_device=captured,
|
||||
)
|
||||
assert (
|
||||
@@ -131,7 +138,7 @@ def test_ap_activation_uses_retained_handle_without_rediscovery(
|
||||
error = asyncio.run(scenario())
|
||||
|
||||
assert rediscovery_calls == []
|
||||
assert client_calls == [retained_handle]
|
||||
assert client_calls == [(retained_handle, 30.0, False)]
|
||||
assert error.operation_stage == "connect" # 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]
|
||||
|
||||
@@ -545,6 +545,97 @@ def test_start_initialization_wait_has_fail_closed_watchdog() -> None:
|
||||
assert executor.snapshot()["start_complete"] is False
|
||||
|
||||
|
||||
def test_initialized_scanning_is_observed_before_post_start_refresh_timeout() -> None:
|
||||
class PostStartRefreshTimeoutTransport(SyntheticAcceptanceTransport):
|
||||
def exchange_batch_once(
|
||||
self,
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_operation_keys: Collection[str],
|
||||
dispatch_admission_deadline_reached: Callable[[], bool] | None = None,
|
||||
dispatch_admission_commit: Callable[[], None] | None = None,
|
||||
) -> dict[str, bytes]:
|
||||
operation_keys = tuple(envelope.operation_key for envelope in envelopes)
|
||||
if operation_keys == (
|
||||
"dialogue:13:DeviceInfoRequest",
|
||||
"dialogue:14:ModelingStatusRequest",
|
||||
):
|
||||
self.batches.append(operation_keys)
|
||||
raise ApplicationMqttTransportError(
|
||||
"post-START read-only response timed out after host wake",
|
||||
reason_code="mqtt_response_timeout",
|
||||
)
|
||||
return super().exchange_batch_once(
|
||||
envelopes,
|
||||
required_response_operation_keys=required_response_operation_keys,
|
||||
dispatch_admission_deadline_reached=(
|
||||
dispatch_admission_deadline_reached
|
||||
),
|
||||
dispatch_admission_commit=dispatch_admission_commit,
|
||||
)
|
||||
|
||||
transport = PostStartRefreshTimeoutTransport()
|
||||
executor = PhysicalAcceptanceDialogueExecutor(transport)
|
||||
authority = ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
|
||||
orchestrator = ShadowApplicationBootstrapOrchestrator(
|
||||
authority,
|
||||
epoch_seconds=1_752_680_000,
|
||||
timezone_name="Europe/Moscow",
|
||||
)
|
||||
binding = executor.run_connection_stage(orchestrator)
|
||||
executor.run_workspace_entry_stage(
|
||||
orchestrator,
|
||||
executor.wait_for_operator_checkpoint("workspace-entered", lambda: True),
|
||||
)
|
||||
executor.run_project_prompt_stage(
|
||||
orchestrator,
|
||||
executor.wait_for_operator_checkpoint("project-prompt-opened", lambda: True),
|
||||
)
|
||||
command = ShadowModelingCommand.from_command(
|
||||
encode_modeling_start(
|
||||
CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=APPLICATION_KEY,
|
||||
),
|
||||
project_name="SLEEP_WAKE",
|
||||
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||
scan_mode=ScanMode.LCC,
|
||||
mount_type=MountType.HANDHELD,
|
||||
)
|
||||
)
|
||||
observed_stages: list[str] = []
|
||||
|
||||
with pytest.raises(ApplicationMqttTransportError) as raised:
|
||||
executor.execute_canonical_start(
|
||||
command,
|
||||
build_canonical_post_start_observation(authority, binding),
|
||||
authority=authority,
|
||||
binding=binding,
|
||||
permit=PhysicalAcceptancePermit(_checklist(ModelingAction.START)),
|
||||
checkpoint=executor.wait_for_operator_checkpoint(
|
||||
"start-confirmed",
|
||||
lambda: True,
|
||||
),
|
||||
start_active_observer=lambda: observed_stages.append(
|
||||
str(executor.snapshot()["dialogue_stage"])
|
||||
),
|
||||
)
|
||||
|
||||
assert raised.value.reason_code == "mqtt_response_timeout"
|
||||
assert observed_stages == ["start-active-observed"]
|
||||
assert transport.batches[-3:] == [
|
||||
("modeling:start",),
|
||||
("dialogue:12:ModelingStatusRequest",),
|
||||
(
|
||||
"dialogue:13:DeviceInfoRequest",
|
||||
"dialogue:14:ModelingStatusRequest",
|
||||
),
|
||||
]
|
||||
assert executor.snapshot()["start_active_confirmed"] is True
|
||||
assert executor.snapshot()["start_complete"] is False
|
||||
assert transport.stop_emitted is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_model", "device_type"),
|
||||
(
|
||||
|
||||
@@ -2222,6 +2222,90 @@ def test_start_outcome_unknown_blocks_reopen_even_when_transport_is_closed(
|
||||
session.retire_for_network_change()
|
||||
|
||||
|
||||
def test_post_start_read_only_timeout_preserves_durable_active_proof_without_stop_authority(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
class PostStartRefreshFailureExecutor(FakeExecutor):
|
||||
start_attempted = False
|
||||
start_active_confirmed = False
|
||||
|
||||
def execute_canonical_start(self, *_args: object, **kwargs: object) -> object:
|
||||
type(self).start_attempted = True
|
||||
self.records.append("start:11-12")
|
||||
self.transport.ready = False
|
||||
observer = kwargs.get("start_active_observer")
|
||||
assert callable(observer)
|
||||
observer()
|
||||
type(self).start_active_confirmed = True
|
||||
self.records.append("refresh:13-14-timeout")
|
||||
raise ApplicationMqttTransportError(
|
||||
"post-START read-only response timed out after host wake",
|
||||
reason_code="mqtt_response_timeout",
|
||||
)
|
||||
|
||||
def snapshot(self) -> dict[str, object]:
|
||||
return {
|
||||
"dialogue_stage": (
|
||||
"start-active-observed"
|
||||
if type(self).start_active_confirmed
|
||||
else "start-attempted"
|
||||
),
|
||||
"start_attempted": type(self).start_attempted,
|
||||
"start_active_confirmed": type(self).start_active_confirmed,
|
||||
"start_complete": False,
|
||||
"stop_attempted": False,
|
||||
"response_evidence": [],
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
FakeExecutor.records = []
|
||||
PostStartRefreshFailureExecutor.start_attempted = False
|
||||
PostStartRefreshFailureExecutor.start_active_confirmed = False
|
||||
monkeypatch.setattr(
|
||||
session_module,
|
||||
"PhysicalAcceptanceDialogueExecutor",
|
||||
PostStartRefreshFailureExecutor,
|
||||
)
|
||||
coordinator = FakePhysicalCommandCoordinator()
|
||||
scanning_observed = threading.Event()
|
||||
session = InteractiveApplicationControlSession(
|
||||
FakeAuthorityLoader(),
|
||||
transport_factory=lambda host: FakeTransport(host), # type: ignore[arg-type]
|
||||
physical_command_coordinator=coordinator, # type: ignore[arg-type]
|
||||
scanning_observer=scanning_observed.set,
|
||||
)
|
||||
coordinator.phase_probe = lambda: str(session.snapshot()["state"])
|
||||
|
||||
session.open(
|
||||
host="192.168.1.20",
|
||||
timezone_name="Europe/Moscow",
|
||||
connection_binding=_connection_binding(),
|
||||
)
|
||||
_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="SLEEP_WAKE",
|
||||
confirmation=_confirmation(),
|
||||
command_context=_physical_context("start"),
|
||||
preparation_checkpoint_observer=_successful_start_checkpoint_observer, # type: ignore[arg-type]
|
||||
)
|
||||
failed = _wait_phase(session, "failed")
|
||||
|
||||
assert coordinator.resolutions == [("start", "initializing")]
|
||||
assert scanning_observed.is_set()
|
||||
assert failed["can_stop"] is False
|
||||
assert failed["outcome_unknown"] is True
|
||||
assert failed["failure"]["reason_code"] == "mqtt_response_timeout" # type: ignore[index]
|
||||
assert failed["dialogue"]["start_active_confirmed"] is True # type: ignore[index]
|
||||
assert FakeExecutor.records[-2:] == [
|
||||
"start:11-12",
|
||||
"refresh:13-14-timeout",
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("disconnect_error", "expected_reason_code"),
|
||||
[
|
||||
|
||||
Reference in New Issue
Block a user