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
+263 -13
View File
@@ -56,6 +56,10 @@ from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
UninstalledApplicationPublishSink,
WriteDisabledOneShotPublisher,
)
from k1link.device_plugins.xgrids_k1.protocol.application_session import (
InteractiveApplicationControlSession,
OperatorPresenceConfirmation,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling import observe_modeling_report
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
LiveModelingControlSafety,
@@ -85,7 +89,7 @@ from k1link.web.plugin_runtime import (
)
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
XGRIDS_K1_PLUGIN_VERSION = "0.4.0"
XGRIDS_K1_PLUGIN_VERSION = "0.5.0"
XGRIDS_K1_MODEL_ID = "xgrids.lixelkity-k1"
XGRIDS_K1_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
@@ -137,6 +141,9 @@ ACTION_VIEWER_SETTINGS_UPDATE = "viewer.settings.update"
ACTION_APPLICATION_CONTROL_SHADOW_STATE = "application-control.shadow-state"
ACTION_APPLICATION_CONTROL_SHADOW_ARM = "application-control.shadow-arm"
ACTION_APPLICATION_CONTROL_SHADOW_DISARM = "application-control.shadow-disarm"
ACTION_APPLICATION_CONTROL_SESSION_OPEN = "application-control.session.open"
ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER = "application-control.workspace.enter"
ACTION_APPLICATION_CONTROL_SESSION_CLOSE = "application-control.session.close"
RequestedStreamId = Literal[
"spatial.point-cloud.live",
@@ -205,6 +212,17 @@ class OperationContextRequest(StrictRequest):
deadline_seconds: float | None = Field(default=None, ge=1.0, le=86_400.0)
class OperatorPresenceRequest(StrictRequest):
operator_present: Literal[True]
owner_controlled_device: Literal[True]
lixelgo_closed: Literal[True]
battery_storage_confirmed: Literal[True]
expected_physical_state_confirmed: Literal[True]
def confirmation(self) -> OperatorPresenceConfirmation:
return OperatorPresenceConfirmation(**self.model_dump())
class PrepareAcquisitionRequest(OperationContextRequest):
project_name: str = Field(min_length=1, max_length=96)
host: str | None = Field(default=None, max_length=15)
@@ -222,12 +240,14 @@ class PrepareAcquisitionRequest(OperationContextRequest):
class StartAcquisitionRequest(OperationContextRequest):
acquisition_id: str | None = Field(default=None, min_length=1, max_length=128)
expected_state_revision: int | None = Field(default=None, ge=1)
physical_acceptance: OperatorPresenceRequest | None = None
class StopAcquisitionRequest(OperationContextRequest):
acquisition_id: str | None = Field(default=None, min_length=1, max_length=128)
mode: Literal["graceful", "capture-only"] = "capture-only"
operator_confirmed: bool = False
physical_acceptance: OperatorPresenceRequest | None = None
class AbortAcquisitionRequest(OperationContextRequest):
@@ -271,6 +291,18 @@ class ShadowApplicationControlArmRequest(StrictRequest):
)
class OpenApplicationControlSessionRequest(OperatorPresenceRequest):
timezone_name: str = Field(
min_length=1,
max_length=64,
pattern=r"^[A-Za-z0-9._+-]+(?:/[A-Za-z0-9._+-]+)*$",
)
class EnterApplicationWorkspaceRequest(StrictRequest):
operator_confirmed: Literal[True]
class XgridsK1CompatibilityService:
"""The proven K1 runtime kept intact behind the plugin facade."""
@@ -313,10 +345,16 @@ class XgridsK1CompatibilityService:
# The host-owned visual runtime receives the vendor normalizer
# explicitly. There is no implicit K1 decoder in the visual layer.
self._modeling_control_safety = LiveModelingControlSafety()
authority_loader = (
application_authority_loader or MacOSKeychainApplicationAuthorityLoader()
)
self._application_control = DormantApplicationControlCoordinator(
application_authority_loader or MacOSKeychainApplicationAuthorityLoader(),
authority_loader,
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
)
self._application_control_session = InteractiveApplicationControlSession(
authority_loader
)
self.runtime = VisualizationRuntime(
normalizer=normalize_k1_message,
message_observer=self._observe_runtime_message,
@@ -329,6 +367,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def state(self) -> dict[str, Any]:
application_control = self._application_control.snapshot().as_dict()
application_control_session = self._application_control_session.snapshot()
runtime = self.runtime.snapshot()
camera_preview = self.camera_preview.snapshot()
self._reconcile_acquisition(runtime, camera_preview)
@@ -389,6 +428,12 @@ class XgridsK1CompatibilityService:
active_profile_id = (
XGRIDS_K1_COMPATIBILITY_PROFILE_ID if compatibility_attestation is not None else None
)
active_control = application_control_session["state"] not in {
"idle",
"completed",
"closed",
"failed",
}
return {
"contract_version": "missioncore.device-plugin-state/v1alpha2",
"phase": phase,
@@ -400,7 +445,11 @@ class XgridsK1CompatibilityService:
"profile_id": active_profile_id,
"decision": "limited" if active_profile_id is not None else "unknown",
"permitted_mode": (
"read-only" if active_profile_id is not None else "evidence-only"
"active-control"
if active_profile_id is not None and active_control
else "read-only"
if active_profile_id is not None
else "evidence-only"
),
"firmware_claim": (
"operator-attested-exact-3.0.2"
@@ -408,7 +457,7 @@ class XgridsK1CompatibilityService:
else "exact-3.0.2-profile-not-attested"
),
"attestation": compatibility_attestation,
"vendor_writes_enabled": False,
"vendor_writes_enabled": active_control,
"camera_preview": (
(
"local-browser-adapter-available"
@@ -421,6 +470,7 @@ class XgridsK1CompatibilityService:
},
"modeling_control_safety": self._modeling_control_safety.snapshot().as_dict(),
"application_control_execution": application_control,
"application_control_session": application_control_session,
"device_ref": (
{
"device_id": device_id,
@@ -521,6 +571,11 @@ class XgridsK1CompatibilityService:
return self.state()
async def connect(self, request: ConnectRequest) -> dict[str, Any]:
control_state = self._application_control_session.snapshot()["state"]
if control_state not in {"idle", "completed", "closed"}:
raise RuntimeError(
"сначала завершите или безопасно закройте текущую control-сессию K1"
)
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
if request.device_id not in known_ids:
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
@@ -714,6 +769,45 @@ class XgridsK1CompatibilityService:
}
return self.state()
@_serialized_acquisition_access
def open_application_control_session(
self,
request: OpenApplicationControlSessionRequest,
) -> dict[str, Any]:
"""Open one continuous canonical dialogue; no later stage is implied."""
with self._lock:
if self._provisioning_active:
raise RuntimeError("нельзя открывать control-сессию во время настройки Wi-Fi")
target = self._k1_ip
attestation = self._compatibility_attestation
acquisition = self._acquisition
if target is None or attestation is None:
raise RuntimeError("сначала подключите и подтвердите exact-profile K1")
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition")
self._application_control.disarm()
self._application_control_session.open(
host=validate_private_ipv4(target),
timezone_name=request.timezone_name,
confirmation=request.confirmation(),
)
return self.state()
@_serialized_acquisition_access
def enter_application_workspace(
self,
request: EnterApplicationWorkspaceRequest,
) -> dict[str, Any]:
del request
self._application_control_session.enter_workspace()
return self.state()
@_serialized_acquisition_access
def close_application_control_session(self) -> dict[str, Any]:
self._application_control_session.close_prestart()
return self.state()
@_serialized_acquisition_access
def arm_application_control_shadow(
self,
@@ -751,6 +845,30 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def prepare_acquisition(self, request: PrepareAcquisitionRequest) -> dict[str, Any]:
self._application_control.disarm()
control_state = str(self._application_control_session.snapshot()["state"])
if control_state == "failed":
raise RuntimeError(
"control-сессия K1 завершилась ошибкой и требует ручной проверки"
)
if control_state in {
"connecting",
"connection-ready",
"workspace-requested",
"project-requested",
"project-ready",
"start-requested",
"initializing",
"scanning",
"stop-requested",
"stopping",
"awaiting-standby-confirmation",
} and control_state != "workspace-ready":
raise RuntimeError(
"каноническая control-сессия должна ожидать сохранения проекта"
)
control_mode: Literal["operator-manual", "plugin-commanded"] = (
"plugin-commanded" if control_state == "workspace-ready" else "operator-manual"
)
requested_streams = _validated_requested_streams(request)
target = request.host or self.state()["k1_ip"]
if not isinstance(target, str) or not target:
@@ -762,6 +880,13 @@ class XgridsK1CompatibilityService:
raise ValueError(
"адрес точки доступа устройства нельзя использовать как direct-LAN target"
)
if control_mode == "plugin-commanded":
with self._lock:
control_target = self._k1_ip
if control_target is None or target != validate_private_ipv4(control_target):
raise ValueError(
"локальный приём должен использовать тот же K1, что и control-сессия"
)
with self._lock:
if self._acquisition_session_lease is not None:
raise RuntimeError("предыдущая evidence-сессия ещё не остановлена и не запечатана")
@@ -824,7 +949,7 @@ class XgridsK1CompatibilityService:
device_id=device_id,
device_session_id=device_session_id,
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
control_mode="operator-manual",
control_mode=control_mode,
requested_streams=requested_streams,
target_host=target,
duration_seconds=request.duration_seconds,
@@ -846,6 +971,8 @@ class XgridsK1CompatibilityService:
self._compatibility_attestation = _attestation_snapshot(
request.compatibility_attestation
)
if control_mode == "plugin-commanded":
self._application_control_session.open_project_prompt()
self._operations.transition(
operation.operation_id,
"succeeded",
@@ -867,6 +994,14 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def start_acquisition(self, request: StartAcquisitionRequest) -> dict[str, Any]:
acquisition = self._require_acquisition(request.acquisition_id)
plugin_commanded = acquisition.control_mode == "plugin-commanded"
if plugin_commanded:
if request.physical_acceptance is None:
raise ValueError(
"START K1 требует явного подтверждения присутствия оператора"
)
if self._application_control_session.snapshot()["state"] != "project-ready":
raise RuntimeError("канонический диалог K1 ещё не готов принять START")
if (
request.expected_state_revision is not None
and request.expected_state_revision != acquisition.state_revision
@@ -877,6 +1012,11 @@ class XgridsK1CompatibilityService:
{
"acquisition_id": acquisition.acquisition_id,
"expected_state_revision": request.expected_state_revision,
"physical_acceptance": (
request.physical_acceptance.model_dump(mode="json")
if request.physical_acceptance is not None
else None
),
},
)
operation, created = self._operations.begin(
@@ -937,6 +1077,12 @@ class XgridsK1CompatibilityService:
project_name=project_name,
)
self._arm_camera_recording(out_dir)
if plugin_commanded:
assert request.physical_acceptance is not None
self._application_control_session.request_start(
project_name=project_name,
confirmation=request.physical_acceptance.confirmation(),
)
except Exception as exc:
if owns_start:
with self._lock:
@@ -975,6 +1121,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def stop_acquisition(self, request: StopAcquisitionRequest) -> dict[str, Any]:
acquisition = self._require_acquisition(request.acquisition_id)
plugin_commanded = acquisition.control_mode == "plugin-commanded"
with self._lock:
acquisition_state = acquisition.state
expected_stop_operation_id = self._acquisition_stop_operation_id
@@ -989,6 +1136,12 @@ class XgridsK1CompatibilityService:
raise ValueError(
"подтверждение остановки должно ссылаться на исходную stop-operation"
)
if plugin_commanded and not self._application_control_session.snapshot()[
"can_confirm_standby"
]:
raise RuntimeError(
"сначала дождитесь READY от K1 и визуально подтвердите зелёный индикатор"
)
elif (
request.mode == "graceful"
and acquisition_state
@@ -996,11 +1149,27 @@ class XgridsK1CompatibilityService:
"acquiring",
"awaiting_external_stop",
}
and not (
plugin_commanded
and acquisition_state in {"starting", "awaiting_external_start"}
and self._application_control_session.snapshot()["state"] == "scanning"
)
and not (acquisition_state in TERMINAL_ACQUISITION_STATES and lease_retained)
):
raise ValueError(
"graceful stop допустим только после подтверждённого потока point cloud"
)
if (
plugin_commanded
and request.mode == "graceful"
and not request.operator_confirmed
):
if request.physical_acceptance is None:
raise ValueError(
"STOP K1 требует явного подтверждения присутствия оператора"
)
if self._application_control_session.snapshot()["state"] != "scanning":
raise RuntimeError("канонический диалог K1 ещё не готов принять STOP")
request_fingerprint = self._request_fingerprint(
ACTION_ACQUISITION_STOP,
@@ -1091,12 +1260,29 @@ class XgridsK1CompatibilityService:
return self.state()
if request.mode == "graceful" and not request.operator_confirmed:
if plugin_commanded:
assert request.physical_acceptance is not None
self._application_control_session.request_stop(
confirmation=request.physical_acceptance.confirmation()
)
with self._lock:
acquisition.transition(
"awaiting_external_stop",
message_code="acquisition.stop.operator_action_required",
message_code=(
"acquisition.stop.device_stopping"
if plugin_commanded
else "acquisition.stop.operator_action_required"
),
operator_instructions=(
"Дважды нажмите физическую кнопку устройства и подтвердите остановку.",
(
"Канонический STOP запрошен один раз. Дождитесь READY, "
"убедитесь, что индикатор постоянно зелёный, и подтвердите это."
if plugin_commanded
else (
"Дважды нажмите физическую кнопку устройства и "
"подтвердите остановку."
)
),
),
)
self._acquisition_stop_operation_id = operation.operation_id
@@ -1104,7 +1290,11 @@ class XgridsK1CompatibilityService:
operation.operation_id,
"operator_action_required",
stage_code="awaiting-external-stop",
message_code="acquisition.stop.operator_action_required",
message_code=(
"acquisition.stop.device_stopping"
if plugin_commanded
else "acquisition.stop.operator_action_required"
),
result={"acquisition_id": acquisition.acquisition_id},
)
return self.state()
@@ -1116,6 +1306,8 @@ class XgridsK1CompatibilityService:
message_code="acquisition.stop.stopping_receiver",
)
try:
if plugin_commanded and request.mode == "graceful" and request.operator_confirmed:
self._application_control_session.confirm_standby()
with self._lock:
acquisition.transition("stopping", message_code="acquisition.stopping")
self._stop_acquisition_sources(
@@ -1190,6 +1382,11 @@ class XgridsK1CompatibilityService:
if not created:
return self.state()
try:
if (
acquisition.control_mode == "plugin-commanded"
and acquisition.state == "prepared"
):
self._application_control_session.close_prestart()
with self._lock:
should_abort = acquisition.state not in TERMINAL_ACQUISITION_STATES
if should_abort:
@@ -1338,6 +1535,7 @@ class XgridsK1CompatibilityService:
@_serialized_acquisition_access
def close(self) -> None:
self._application_control.close()
self._application_control_session.close()
with self._lock:
acquisition = self._acquisition
close_active_acquisition = (
@@ -1613,6 +1811,7 @@ class XgridsK1CompatibilityService:
failed_stop_operation_id: str | None = None
unconfirmed_stop_operation_id: str | None = None
receiver_ready_operation_id: str | None = None
receiver_plugin_commanded = False
acquisition_id: str | None = None
camera_terminal_status: Literal["complete", "failed"] | None = None
camera_failure_code: str | None = None
@@ -1667,11 +1866,22 @@ class XgridsK1CompatibilityService:
completed_operation_id = self._acquisition_start_operation_id
acquisition_id = acquisition.acquisition_id
elif acquisition.state == "starting" and source_ready:
plugin_commanded = acquisition.control_mode == "plugin-commanded"
receiver_plugin_commanded = plugin_commanded
acquisition.transition(
"awaiting_external_start",
message_code="acquisition.start.operator_action_required",
message_code=(
"acquisition.start.device_initializing"
if plugin_commanded
else "acquisition.start.operator_action_required"
),
operator_instructions=(
"Приёмник готов. Дважды нажмите физическую кнопку устройства.",
(
"Приёмник готов. Ожидаем канонический START и калибровку K1; "
"не нажимайте физическую кнопку."
if plugin_commanded
else "Приёмник готов. Дважды нажмите физическую кнопку устройства."
),
),
)
receiver_ready_operation_id = self._acquisition_start_operation_id
@@ -1776,9 +1986,17 @@ class XgridsK1CompatibilityService:
if receiver_ready_operation_id is not None:
self._operations.transition_if_pending(
receiver_ready_operation_id,
"operator_action_required",
stage_code="awaiting-external-start",
message_code="acquisition.start.operator_action_required",
"running" if receiver_plugin_commanded else "operator_action_required",
stage_code=(
"device-initializing"
if receiver_plugin_commanded
else "awaiting-external-start"
),
message_code=(
"acquisition.start.device_initializing"
if receiver_plugin_commanded
else "acquisition.start.operator_action_required"
),
result={"acquisition_id": acquisition_id},
)
if completed_operation_id is not None:
@@ -1856,6 +2074,18 @@ class XgridsK1ServicePort(Protocol):
def verify_connection(self) -> dict[str, Any]: ...
def open_application_control_session(
self,
request: OpenApplicationControlSessionRequest,
) -> dict[str, Any]: ...
def enter_application_workspace(
self,
request: EnterApplicationWorkspaceRequest,
) -> dict[str, Any]: ...
def close_application_control_session(self) -> dict[str, Any]: ...
def arm_application_control_shadow(
self,
request: ShadowApplicationControlArmRequest,
@@ -1917,6 +2147,9 @@ class XgridsK1PluginFacade:
ACTION_APPLICATION_CONTROL_SHADOW_STATE,
ACTION_APPLICATION_CONTROL_SHADOW_ARM,
ACTION_APPLICATION_CONTROL_SHADOW_DISARM,
ACTION_APPLICATION_CONTROL_SESSION_OPEN,
ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER,
ACTION_APPLICATION_CONTROL_SESSION_CLOSE,
}
)
@@ -1975,6 +2208,23 @@ class XgridsK1PluginFacade:
if action_id == ACTION_APPLICATION_CONTROL_SHADOW_DISARM:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(self.service.disarm_application_control_shadow)
if action_id == ACTION_APPLICATION_CONTROL_SESSION_OPEN:
open_request = OpenApplicationControlSessionRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.open_application_control_session,
open_request,
)
if action_id == ACTION_APPLICATION_CONTROL_WORKSPACE_ENTER:
workspace_request = EnterApplicationWorkspaceRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.enter_application_workspace,
workspace_request,
)
if action_id == ACTION_APPLICATION_CONTROL_SESSION_CLOSE:
EmptyRequest.model_validate(payload)
return await asyncio.to_thread(
self.service.close_application_control_session
)
if action_id == ACTION_ACQUISITION_PREPARE:
prepare_request = PrepareAcquisitionRequest.model_validate(payload)
return await asyncio.to_thread(self.service.prepare_acquisition, prepare_request)
@@ -0,0 +1,488 @@
from __future__ import annotations
import threading
import time
from collections.abc import Callable
from dataclasses import dataclass
from typing import Literal
from k1link.device_plugins.xgrids_k1.protocol.application_acceptance import (
ApplicationAcceptanceError,
PhysicalAcceptanceChecklist,
PhysicalAcceptanceDialogueExecutor,
PhysicalAcceptancePermit,
)
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
ApplicationControlAuthority,
LiveDeviceControlBinding,
ShadowApplicationBootstrapOrchestrator,
build_canonical_post_start_observation,
)
from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
ApplicationAuthorityLoader,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
ApplicationCommandOutcomeUnknown,
ReviewedApplicationMqttTransport,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
CommandHeaderIdentity,
ModelingAction,
MountType,
RecordMode,
ScanMode,
encode_modeling_start,
encode_modeling_stop,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
ShadowModelingCommand,
)
ApplicationControlPhase = Literal[
"idle",
"connecting",
"connection-ready",
"workspace-requested",
"workspace-ready",
"project-requested",
"project-ready",
"start-requested",
"initializing",
"scanning",
"stop-requested",
"stopping",
"awaiting-standby-confirmation",
"completed",
"closed",
"failed",
]
TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]
@dataclass(frozen=True, slots=True)
class OperatorPresenceConfirmation:
operator_present: Literal[True]
owner_controlled_device: Literal[True]
lixelgo_closed: Literal[True]
battery_storage_confirmed: Literal[True]
expected_physical_state_confirmed: Literal[True]
def checklist(self, action: ModelingAction) -> PhysicalAcceptanceChecklist:
return PhysicalAcceptanceChecklist(
action=action,
operator_present=self.operator_present,
owner_controlled_device=self.owner_controlled_device,
lixelgo_closed=self.lixelgo_closed,
battery_storage_confirmed=self.battery_storage_confirmed,
expected_physical_state_confirmed=self.expected_physical_state_confirmed,
)
class InteractiveApplicationControlSession:
"""Own one canonical K1 MQTT dialogue across explicit operator UI events.
Only this background thread touches the MQTT client. UI requests merely
release one named checkpoint. No checkpoint is advanced by elapsed time,
and neither START nor STOP has an automatic retry path.
"""
def __init__(
self,
authority_loader: ApplicationAuthorityLoader,
*,
transport_factory: TransportFactory = ReviewedApplicationMqttTransport,
epoch_seconds: Callable[[], int] = lambda: int(time.time()),
) -> None:
self._authority_loader = authority_loader
self._transport_factory = transport_factory
self._epoch_seconds = epoch_seconds
self._lock = threading.RLock()
self._phase: ApplicationControlPhase = "idle"
self._host: str | None = None
self._timezone_name: str | None = None
self._thread: threading.Thread | None = None
self._transport: ReviewedApplicationMqttTransport | None = None
self._workspace_requested = threading.Event()
self._project_requested = threading.Event()
self._start_requested = threading.Event()
self._stop_requested = threading.Event()
self._standby_confirmed = threading.Event()
self._cancel_requested = False
self._start_confirmation: OperatorPresenceConfirmation | None = None
self._stop_confirmation: OperatorPresenceConfirmation | None = None
self._project_name: str | None = None
self._failure: dict[str, object] | None = None
self._dialogue_snapshot: dict[str, object] | None = None
self._transport_snapshot: dict[str, object] | None = None
self._outcome_unknown = False
def open(
self,
*,
host: str,
timezone_name: str,
confirmation: OperatorPresenceConfirmation,
) -> dict[str, object]:
# Validate every explicit confirmation before creating a network owner.
confirmation.checklist(ModelingAction.START)
with self._lock:
if self._phase in {"completed", "closed"} or (
self._phase == "failed"
and self._failure is not None
and self._failure.get("safe_to_retry") is True
):
self._reset_locked()
if self._phase != "idle":
raise ApplicationAcceptanceError(
"application control session is already open or requires manual recovery"
)
self._host = host
self._timezone_name = timezone_name
self._set_phase_locked("connecting")
self._thread = threading.Thread(
target=self._run,
name="xgrids-k1-canonical-control",
daemon=True,
)
self._thread.start()
return self.snapshot()
def enter_workspace(self) -> dict[str, object]:
with self._lock:
self._require_phase_locked("connection-ready")
self._set_phase_locked("workspace-requested")
self._workspace_requested.set()
return self.snapshot()
def open_project_prompt(self) -> dict[str, object]:
with self._lock:
self._require_phase_locked("workspace-ready")
self._set_phase_locked("project-requested")
self._project_requested.set()
return self.snapshot()
def request_start(
self,
*,
project_name: str,
confirmation: OperatorPresenceConfirmation,
) -> dict[str, object]:
with self._lock:
self._require_phase_locked("project-ready")
self._project_name = project_name
self._start_confirmation = confirmation
self._set_phase_locked("start-requested")
self._start_requested.set()
return self.snapshot()
def request_stop(
self,
*,
confirmation: OperatorPresenceConfirmation,
) -> dict[str, object]:
with self._lock:
self._require_phase_locked("scanning")
self._stop_confirmation = confirmation
self._set_phase_locked("stop-requested")
self._stop_requested.set()
return self.snapshot()
def confirm_standby(self) -> dict[str, object]:
with self._lock:
self._require_phase_locked("awaiting-standby-confirmation")
if not self._device_ready_for_standby_confirmation_locked():
raise ApplicationAcceptanceError(
"K1 has not reported unbound READY after canonical STOP"
)
self._standby_confirmed.set()
return self.snapshot()
def close_prestart(self) -> dict[str, object]:
with self._lock:
if self._phase not in {
"connection-ready",
"workspace-ready",
"project-ready",
}:
raise ApplicationAcceptanceError(
"control session can be closed safely only between pre-START checkpoints"
)
self._cancel_requested = True
transport = self._transport
if transport is not None:
transport.close()
return self.snapshot()
def close(self) -> None:
"""Stop the process-owned socket without ever inventing a device STOP."""
with self._lock:
self._cancel_requested = True
transport = self._transport
thread = self._thread
if transport is not None:
transport.close()
if thread is not None and thread is not threading.current_thread():
thread.join(timeout=2.0)
def snapshot(self) -> dict[str, object]:
with self._lock:
transport_snapshot = self._live_transport_snapshot_locked()
phase = self._phase
return {
"mode": "interactive-canonical",
"state": phase,
"control_socket_open": phase
not in {"idle", "completed", "closed", "failed"},
"can_open": phase in {"idle", "completed", "closed"}
or (
phase == "failed"
and self._failure is not None
and self._failure.get("safe_to_retry") is True
),
"can_enter_workspace": phase == "connection-ready",
"can_prepare_project": phase == "workspace-ready",
"can_start": phase == "project-ready",
"can_stop": phase == "scanning",
"can_confirm_standby": self._device_ready_for_standby_confirmation_locked(),
"pending_operator_action": self._pending_operator_action_locked(),
"scripted_transitions": False,
"automatic_retry": False,
"outcome_unknown": self._outcome_unknown,
"failure": dict(self._failure) if self._failure is not None else None,
"dialogue": (
dict(self._dialogue_snapshot)
if self._dialogue_snapshot is not None
else None
),
"transport": transport_snapshot,
}
def _run(self) -> None:
executor: PhysicalAcceptanceDialogueExecutor | None = None
transport: ReviewedApplicationMqttTransport | None = None
try:
with self._lock:
host = self._host
timezone_name = self._timezone_name
if host is None or timezone_name is None:
raise ApplicationAcceptanceError("control session inputs are unavailable")
authority = self._authority_loader.load()
transport = self._transport_factory(host)
with self._lock:
self._transport = transport
transport.open()
orchestrator = ShadowApplicationBootstrapOrchestrator(
authority,
epoch_seconds=self._epoch_seconds(),
timezone_name=timezone_name,
)
executor = PhysicalAcceptanceDialogueExecutor(transport)
binding = executor.run_connection_stage(orchestrator)
self._set_phase("connection-ready")
workspace = executor.wait_for_operator_checkpoint(
"workspace-entered",
self._workspace_requested.is_set,
)
executor.run_workspace_entry_stage(orchestrator, workspace)
self._set_phase("workspace-ready")
project = executor.wait_for_operator_checkpoint(
"project-prompt-opened",
self._project_requested.is_set,
)
binding = executor.run_project_prompt_stage(orchestrator, project)
self._set_phase("project-ready")
start_checkpoint = executor.wait_for_operator_checkpoint(
"start-confirmed",
self._start_requested.is_set,
)
project_name, start_confirmation = self._start_request()
start_permit = PhysicalAcceptancePermit(
start_confirmation.checklist(ModelingAction.START)
)
self._set_phase("initializing")
executor.execute_canonical_start(
self._start_command(authority, binding, project_name),
build_canonical_post_start_observation(authority, binding),
authority=authority,
binding=binding,
permit=start_permit,
checkpoint=start_checkpoint,
)
self._set_phase("scanning")
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
stop_confirmation = self._stop_request()
stop_permit = PhysicalAcceptancePermit(
stop_confirmation.checklist(ModelingAction.STOP)
)
self._set_phase("stopping")
executor.execute_canonical_stop(
self._stop_command(authority, binding),
stop_permit,
)
self._set_phase("awaiting-standby-confirmation")
executor.maintain_post_stop_until_standby_confirmed(
self._standby_confirmed.is_set
)
self._set_phase("completed")
except Exception as exc:
with self._lock:
if self._cancel_requested:
self._set_phase_locked("closed")
else:
publish_attempts = 0
if transport is not None:
observed_attempts = transport.snapshot().as_dict().get(
"publish_attempts",
0,
)
if isinstance(observed_attempts, int) and not isinstance(
observed_attempts,
bool,
):
publish_attempts = observed_attempts
outcome_unknown = isinstance(
exc, ApplicationCommandOutcomeUnknown
) or self._phase in {
"start-requested",
"initializing",
"scanning",
"stop-requested",
"stopping",
"awaiting-standby-confirmation",
}
self._failure = {
"code": type(exc).__name__,
"message": str(exc),
"safe_to_retry": publish_attempts == 0 and not outcome_unknown,
}
self._outcome_unknown = outcome_unknown
self._set_phase_locked("failed")
finally:
if executor is not None:
with self._lock:
self._dialogue_snapshot = executor.snapshot()
if transport is not None:
try:
with self._lock:
self._transport_snapshot = transport.snapshot().as_dict()
finally:
transport.close()
with self._lock:
self._transport = None
@staticmethod
def _start_command(
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
project_name: str,
) -> ShadowModelingCommand:
return ShadowModelingCommand.from_command(
encode_modeling_start(
CommandHeaderIdentity(
device_id=binding.vendor_device_id,
openapi_key=authority.openapi_key,
),
project_name=project_name,
record_mode=RecordMode.RECORD_AND_CALCULATE,
scan_mode=ScanMode.LCC,
mount_type=MountType.HANDHELD,
)
)
@staticmethod
def _stop_command(
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
) -> ShadowModelingCommand:
return ShadowModelingCommand.from_command(
encode_modeling_stop(
CommandHeaderIdentity(
device_id=binding.vendor_device_id,
openapi_key=authority.openapi_key,
)
)
)
def _start_request(self) -> tuple[str, OperatorPresenceConfirmation]:
with self._lock:
if self._project_name is None or self._start_confirmation is None:
raise ApplicationAcceptanceError("START request is incomplete")
return self._project_name, self._start_confirmation
def _stop_request(self) -> OperatorPresenceConfirmation:
with self._lock:
if self._stop_confirmation is None:
raise ApplicationAcceptanceError("STOP request is incomplete")
return self._stop_confirmation
def _set_phase(self, phase: ApplicationControlPhase) -> None:
with self._lock:
self._set_phase_locked(phase)
def _set_phase_locked(self, phase: ApplicationControlPhase) -> None:
self._phase = phase
def _require_phase_locked(self, expected: ApplicationControlPhase) -> None:
if self._phase != expected:
raise ApplicationAcceptanceError(
f"control action requires {expected}; current state is {self._phase}"
)
def _device_ready_for_standby_confirmation_locked(self) -> bool:
if self._phase != "awaiting-standby-confirmation":
return False
snapshot = self._live_transport_snapshot_locked()
return (
snapshot.get("latest_device_session_state") == "ready"
and snapshot.get("latest_device_project_bound") is False
)
def _live_transport_snapshot_locked(self) -> dict[str, object]:
if self._transport is not None:
return self._transport.snapshot().as_dict()
return (
dict(self._transport_snapshot)
if self._transport_snapshot is not None
else {
"state": "not-opened",
"automatic_retry": False,
"automatic_reconnect": False,
}
)
def _pending_operator_action_locked(self) -> str | None:
return {
"connection-ready": "enter-workspace",
"workspace-ready": "prepare-project",
"project-ready": "start",
"scanning": "stop",
"awaiting-standby-confirmation": "confirm-steady-green",
}.get(self._phase)
def _reset_locked(self) -> None:
self._phase = "idle"
self._host = None
self._timezone_name = None
self._thread = None
self._transport = None
self._workspace_requested = threading.Event()
self._project_requested = threading.Event()
self._start_requested = threading.Event()
self._stop_requested = threading.Event()
self._standby_confirmed = threading.Event()
self._cancel_requested = False
self._start_confirmation = None
self._stop_confirmation = None
self._project_name = None
self._failure = None
self._dialogue_snapshot = None
self._transport_snapshot = None
self._outcome_unknown = False