Стабилизация подключения K1 после сна и ожидания Bluetooth

This commit is contained in:
DCCONSTRUCTIONS
2026-08-23 16:24:05 +03:00
parent 5ebc9fc27c
commit c041a569f6
9 changed files with 393 additions and 29 deletions
+69 -3
View File
@@ -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"
+11 -4
View File
@@ -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"),
(
+84
View File
@@ -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"),
[