feat(k1): wire canonical control session to UI

This commit is contained in:
DCCONSTRUCTIONS
2026-07-18 17:53:55 +03:00
parent 36f0c93d2a
commit 7b7b5d6cad
24 changed files with 1591 additions and 184 deletions
+6 -3
View File
@@ -113,7 +113,7 @@ def test_xgrids_frontend_uses_semantic_acquisition_actions_and_stable_identity()
assert 'id: "xgrids-k1-rerun-live"' not in runtime_source
def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner() -> None:
def test_xgrids_live_copy_exposes_only_explicit_canonical_control_steps() -> None:
repository_root = Path(__file__).resolve().parents[1]
connection_source = (
repository_root
@@ -125,8 +125,11 @@ def test_xgrids_live_copy_does_not_claim_software_controls_the_physical_scanner(
/ "K1AcquisitionPipeline.tsx"
).read_text("utf-8")
assert "Подготовить локальный приём данных" in connection_source
assert "Программная команда запуска на K1 не отправляется" in connection_source
assert "Подключить управление K1" in connection_source
assert "Открыть рабочее пространство K1" in connection_source
assert "Сохранить проект и подготовить локальный приём" in connection_source
assert "Запустить сканирование K1" in connection_source
assert "никаких временных автопереходов и повторов нет" in connection_source
assert "Остановить локальный приём" in connection_source
assert "Физическое состояние сканера остаётся неизвестным" in connection_source
+5 -2
View File
@@ -88,7 +88,7 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
item for item in plugins if item["metadata"]["id"] == "nodedc.device.xgrids-lixelkity-k1"
)
assert plugin["apiVersion"] == "missioncore.nodedc/v1alpha2"
assert plugin["metadata"]["version"] == "0.4.0"
assert plugin["metadata"]["version"] == "0.5.0"
assert plugin["spec"]["hostApiRange"] == "v1alpha2"
assert plugin["spec"]["compatibilityProfiles"] == [
{
@@ -119,10 +119,13 @@ def test_repository_catalog_exposes_xgrids_model() -> None:
"application-control.shadow-state",
"application-control.shadow-arm",
"application-control.shadow-disarm",
"application-control.session.open",
"application-control.workspace.enter",
"application-control.session.close",
} <= action_ids
assert next(item for item in models if item["id"] == "xgrids.lixelkity-k1") == {
"pluginId": "nodedc.device.xgrids-lixelkity-k1",
"pluginVersion": "0.4.0",
"pluginVersion": "0.5.0",
"id": "xgrids.lixelkity-k1",
"vendor": "XGRIDS",
"displayName": "XGRIDS LixelKity K1",
+118
View File
@@ -16,6 +16,7 @@ from k1link.device_plugins.xgrids_k1.facade import (
CameraPreviewSelectRequest,
CompatibilityAttestationRequest,
ConnectRequest,
OperatorPresenceRequest,
PrepareAcquisitionRequest,
ShadowApplicationControlArmRequest,
StartAcquisitionRequest,
@@ -113,6 +114,61 @@ class FakeVisualizationRuntime:
self.stop()
class FakeInteractiveControlSession:
def __init__(self) -> None:
self.state = "workspace-ready"
self.start_projects: list[str] = []
self.stop_calls = 0
self.confirm_calls = 0
def snapshot(self) -> dict[str, object]:
return {
"state": self.state,
"can_confirm_standby": self.state == "awaiting-standby-confirmation",
}
def open_project_prompt(self) -> dict[str, object]:
assert self.state == "workspace-ready"
self.state = "project-ready"
return self.snapshot()
def request_start(self, *, project_name: str, confirmation: object) -> dict[str, object]:
assert confirmation is not None
assert self.state == "project-ready"
self.start_projects.append(project_name)
self.state = "scanning"
return self.snapshot()
def request_stop(self, *, confirmation: object) -> dict[str, object]:
assert confirmation is not None
assert self.state == "scanning"
self.stop_calls += 1
self.state = "awaiting-standby-confirmation"
return self.snapshot()
def confirm_standby(self) -> dict[str, object]:
assert self.state == "awaiting-standby-confirmation"
self.confirm_calls += 1
self.state = "completed"
return self.snapshot()
def close_prestart(self) -> dict[str, object]:
self.state = "closed"
return self.snapshot()
def close(self) -> None:
self.state = "closed"
PHYSICAL_ACCEPTANCE = OperatorPresenceRequest(
operator_present=True,
owner_controlled_device=True,
lixelgo_closed=True,
battery_storage_confirmed=True,
expected_physical_state_confirmed=True,
)
def service_with_fake_runtime(
tmp_path: Path,
) -> tuple[XgridsK1CompatibilityService, FakeVisualizationRuntime]:
@@ -291,6 +347,68 @@ def test_operator_manual_start_is_confirmed_only_by_real_point_data(tmp_path: Pa
assert acquiring["last_operation"]["result"]["confirmation"] == "point-frame"
def test_plugin_commanded_acquisition_keeps_start_and_stop_as_explicit_actions(
tmp_path: Path,
) -> 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"]
assert prepared["acquisition"]["control_mode"] == "plugin-commanded"
assert control.state == "project-ready"
assert runtime.start_calls == []
with pytest.raises(ValueError, match="подтверждения присутствия"):
service.start_acquisition(StartAcquisitionRequest(acquisition_id=acquisition_id))
assert control.start_projects == []
service.start_acquisition(
StartAcquisitionRequest(
acquisition_id=acquisition_id,
physical_acceptance=PHYSICAL_ACCEPTANCE,
)
)
assert control.start_projects == ["TEST001"]
runtime.mark_ready()
runtime.pcl_frames = 1
acquiring = service.state()
assert acquiring["acquisition"]["state"] == "acquiring"
stopping = service.stop_acquisition(
StopAcquisitionRequest(
acquisition_id=acquisition_id,
mode="graceful",
physical_acceptance=PHYSICAL_ACCEPTANCE,
)
)
stop_operation = stopping["last_operation"]
assert control.stop_calls == 1
assert stopping["acquisition"]["state"] == "awaiting_external_stop"
completed = service.stop_acquisition(
StopAcquisitionRequest(
acquisition_id=acquisition_id,
mode="graceful",
operator_confirmed=True,
operation_id=stop_operation["operation_id"],
)
)
assert control.stop_calls == 1
assert control.confirm_calls == 1
assert completed["acquisition"]["state"] == "completed"
assert runtime.stop_calls == 1
def test_second_start_conflict_does_not_tear_down_start_owner(tmp_path: Path) -> None:
service, runtime = service_with_fake_runtime(tmp_path)
prepared = service.prepare_acquisition(
+218
View File
@@ -0,0 +1,218 @@
from __future__ import annotations
import threading
import time
from dataclasses import dataclass
from typing import Any
import pytest
from k1link.device_plugins.xgrids_k1.protocol import application_session as session_module
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
LiveDeviceControlBinding,
)
from k1link.device_plugins.xgrids_k1.protocol.application_session import (
InteractiveApplicationControlSession,
OperatorPresenceConfirmation,
)
APPLICATION_KEY = "00000000-0000-0000-0000-000000000000"
class FakeAuthorityLoader:
def __init__(self) -> None:
self.calls = 0
def load(self) -> ApplicationControlAuthority:
self.calls += 1
return ApplicationControlAuthority(openapi_key=APPLICATION_KEY)
@dataclass
class FakeTransportSnapshot:
state: str
ready: bool
def as_dict(self) -> dict[str, object]:
return {
"state": self.state,
"latest_device_session_state": "ready" if self.ready else "scanning",
"latest_device_project_bound": not self.ready,
"automatic_retry": False,
"automatic_reconnect": False,
}
class FakeTransport:
def __init__(self, host: str) -> None:
self.host = host
self.state = "new"
self.ready = True
def open(self) -> FakeTransportSnapshot:
self.state = "ready"
return self.snapshot()
def close(self) -> None:
self.state = "closed"
def snapshot(self) -> FakeTransportSnapshot:
return FakeTransportSnapshot(self.state, self.ready)
class FakeExecutor:
records: list[str] = []
def __init__(self, transport: FakeTransport) -> None:
self.transport = transport
self.binding = LiveDeviceControlBinding(
vendor_device_id="device-id",
device_serial="serial-id",
software_version="3.0.2",
system_version="3.0.2",
device_model="K1",
device_type="scanner",
is_activated=True,
)
def run_connection_stage(self, _orchestrator: object) -> LiveDeviceControlBinding:
self.records.append("connection:1-6")
return self.binding
def wait_for_operator_checkpoint(
self,
event: str,
observed: Any,
) -> str:
self.records.append(f"wait:{event}")
deadline = time.monotonic() + 2.0
while not observed():
if time.monotonic() >= deadline:
raise TimeoutError(f"test did not release {event}")
threading.Event().wait(0.005)
return event
def run_workspace_entry_stage(
self,
_orchestrator: object,
_checkpoint: object,
) -> LiveDeviceControlBinding:
self.records.append("workspace:7")
return self.binding
def run_project_prompt_stage(
self,
_orchestrator: object,
_checkpoint: object,
) -> LiveDeviceControlBinding:
self.records.append("project:8-10")
return self.binding
def execute_canonical_start(self, *_args: object, **_kwargs: object) -> object:
self.records.append("start:11-14")
self.transport.ready = False
return object()
def maintain_active_until_stop_requested(self, observed: Any) -> None:
self.records.append("wait:stop")
while not observed():
threading.Event().wait(0.005)
def execute_canonical_stop(self, *_args: object, **_kwargs: object) -> object:
self.records.append("stop")
self.transport.ready = True
return object()
def maintain_post_stop_until_standby_confirmed(self, observed: Any) -> None:
self.records.append("wait:steady-green")
while not observed():
threading.Event().wait(0.005)
def snapshot(self) -> dict[str, object]:
return {"records": tuple(self.records), "automatic_retry": False}
def _confirmation() -> OperatorPresenceConfirmation:
return OperatorPresenceConfirmation(
operator_present=True,
owner_controlled_device=True,
lixelgo_closed=True,
battery_storage_confirmed=True,
expected_physical_state_confirmed=True,
)
def _wait_phase(
session: InteractiveApplicationControlSession,
expected: str,
) -> dict[str, object]:
deadline = time.monotonic() + 2.0
while time.monotonic() < deadline:
snapshot = session.snapshot()
if snapshot["state"] == expected:
return snapshot
threading.Event().wait(0.005)
raise AssertionError(f"session did not reach {expected}: {session.snapshot()}")
def test_every_canonical_stage_requires_a_separate_operator_event(
monkeypatch: pytest.MonkeyPatch,
) -> None:
FakeExecutor.records = []
monkeypatch.setattr(
session_module,
"PhysicalAcceptanceDialogueExecutor",
FakeExecutor,
)
loader = FakeAuthorityLoader()
transport = FakeTransport("192.168.1.20")
session = InteractiveApplicationControlSession(
loader,
transport_factory=lambda _host: transport, # type: ignore[arg-type]
epoch_seconds=lambda: 1_752_680_000,
)
session.open(
host="192.168.1.20",
timezone_name="Europe/Moscow",
confirmation=_confirmation(),
)
_wait_phase(session, "connection-ready")
threading.Event().wait(0.02)
assert FakeExecutor.records == ["connection:1-6", "wait:workspace-entered"]
session.enter_workspace()
_wait_phase(session, "workspace-ready")
assert FakeExecutor.records[-1] == "wait:project-prompt-opened"
session.open_project_prompt()
_wait_phase(session, "project-ready")
assert FakeExecutor.records[-1] == "wait:start-confirmed"
session.request_start(project_name="TEST001", confirmation=_confirmation())
_wait_phase(session, "scanning")
assert FakeExecutor.records[-1] == "wait:stop"
session.request_stop(confirmation=_confirmation())
standby = _wait_phase(session, "awaiting-standby-confirmation")
assert standby["can_confirm_standby"] is True
assert FakeExecutor.records[-1] == "wait:steady-green"
session.confirm_standby()
completed = _wait_phase(session, "completed")
assert loader.calls == 1
assert completed["automatic_retry"] is False
assert completed["scripted_transitions"] is False
assert FakeExecutor.records == [
"connection:1-6",
"wait:workspace-entered",
"workspace:7",
"wait:project-prompt-opened",
"project:8-10",
"wait:start-confirmed",
"start:11-14",
"wait:stop",
"stop",
"wait:steady-green",
]