feat(k1): wire canonical control session to UI
This commit is contained in:
@@ -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
|
||||
Reference in New Issue
Block a user