feat(k1): complete canonical control lifecycle
This commit is contained in:
@@ -90,6 +90,7 @@ class XgridsK1CameraGateway:
|
||||
self._target_host: str | None = None
|
||||
self._producer: _CameraProducer | None = None
|
||||
self._recording_root: Path | None = None
|
||||
self._expected_source_end_generation: int | None = None
|
||||
self._archive_summaries: list[dict[str, Any]] = []
|
||||
self._error: dict[str, str] | None = None
|
||||
self._closed = False
|
||||
@@ -124,6 +125,9 @@ class XgridsK1CameraGateway:
|
||||
},
|
||||
"recording": {
|
||||
"active": self._recording_root is not None,
|
||||
"source_end_expected": (
|
||||
self._expected_source_end_generation is not None
|
||||
),
|
||||
"session": (
|
||||
self._recording_root.name if self._recording_root is not None else None
|
||||
),
|
||||
@@ -175,6 +179,7 @@ class XgridsK1CameraGateway:
|
||||
self._revision += 1
|
||||
self._source_id = source_id
|
||||
self._target_host = target
|
||||
self._expected_source_end_generation = None
|
||||
self._phase = "selected"
|
||||
self._error = None
|
||||
recording_active = self._recording_root is not None
|
||||
@@ -221,6 +226,7 @@ class XgridsK1CameraGateway:
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._recording_root = None
|
||||
self._expected_source_end_generation = None
|
||||
self._error = None
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
@@ -248,6 +254,7 @@ class XgridsK1CameraGateway:
|
||||
raise RuntimeError("для camera gateway уже активна другая acquisition-сессия")
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
self._recording_root = root
|
||||
self._expected_source_end_generation = None
|
||||
self._archive_summaries = []
|
||||
selected = self._source_id is not None
|
||||
if selected:
|
||||
@@ -266,6 +273,29 @@ class XgridsK1CameraGateway:
|
||||
self._spawn_selected_producer()
|
||||
return self.snapshot()
|
||||
|
||||
def expect_source_end_for_device_stop(self) -> dict[str, Any]:
|
||||
"""Classify one clean FFmpeg EOF as the camera tail of canonical STOP."""
|
||||
|
||||
with self._lock:
|
||||
producer = self._producer
|
||||
if (
|
||||
self._recording_root is not None
|
||||
and producer is not None
|
||||
and producer.archive is not None
|
||||
):
|
||||
self._expected_source_end_generation = producer.generation
|
||||
self._revision += 1
|
||||
return self.snapshot()
|
||||
|
||||
def cancel_expected_source_end(self) -> dict[str, Any]:
|
||||
"""Revoke a STOP expectation when the control checkpoint was not released."""
|
||||
|
||||
with self._lock:
|
||||
if self._expected_source_end_generation is not None:
|
||||
self._expected_source_end_generation = None
|
||||
self._revision += 1
|
||||
return self.snapshot()
|
||||
|
||||
def stop_recording(
|
||||
self,
|
||||
*,
|
||||
@@ -279,6 +309,7 @@ class XgridsK1CameraGateway:
|
||||
producer, delivery = self._detach_producer_locked()
|
||||
recording_was_active = self._recording_root is not None
|
||||
self._recording_root = None
|
||||
self._expected_source_end_generation = None
|
||||
if self._source_id is not None and self._phase != "error":
|
||||
self._phase = "selected"
|
||||
if recording_was_active or producer is not None:
|
||||
@@ -361,6 +392,7 @@ class XgridsK1CameraGateway:
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._recording_root = None
|
||||
self._expected_source_end_generation = None
|
||||
self._error = None
|
||||
self._shutdown_producer(
|
||||
producer,
|
||||
@@ -559,29 +591,49 @@ class XgridsK1CameraGateway:
|
||||
delivery = producer.delivery
|
||||
producer.delivery = None
|
||||
owns_shutdown = self._producer is producer and not producer.stop_requested
|
||||
expected_clean_end = (
|
||||
owns_shutdown
|
||||
and producer.failure_code is None
|
||||
and self._expected_source_end_generation == producer.generation
|
||||
)
|
||||
if owns_shutdown:
|
||||
self._producer = None
|
||||
self._expected_source_end_generation = None
|
||||
self._revision += 1
|
||||
if producer.failure_code is None:
|
||||
if expected_clean_end:
|
||||
self._phase = "idle"
|
||||
self._source_id = None
|
||||
self._target_host = None
|
||||
self._error = None
|
||||
elif producer.failure_code is None:
|
||||
producer.failure_code = "camera-source-ended"
|
||||
self._set_error_locked(
|
||||
"camera-source-ended",
|
||||
_safe_ffmpeg_message(producer.stderr_tail),
|
||||
)
|
||||
if delivery is not None:
|
||||
delivery.failure_code = producer.failure_code or "camera-source-ended"
|
||||
delivery.failure_code = (
|
||||
None
|
||||
if expected_clean_end
|
||||
else producer.failure_code or "camera-source-ended"
|
||||
)
|
||||
_close_segment_queue(delivery)
|
||||
if not owns_shutdown:
|
||||
return
|
||||
_terminate_process(producer.process)
|
||||
status: CameraArchiveStatus = (
|
||||
"interrupted"
|
||||
if producer.failure_code
|
||||
in {"camera-source-ended", "incomplete-fmp4-fragment"}
|
||||
"complete"
|
||||
if expected_clean_end
|
||||
else "interrupted"
|
||||
if producer.failure_code in {"camera-source-ended", "incomplete-fmp4-fragment"}
|
||||
else "failed"
|
||||
)
|
||||
try:
|
||||
self._finalize_archive(producer, status, producer.failure_code)
|
||||
self._finalize_archive(
|
||||
producer,
|
||||
status,
|
||||
None if expected_clean_end else producer.failure_code,
|
||||
)
|
||||
except CameraArchiveError:
|
||||
with self._lock:
|
||||
self._set_error_locked(
|
||||
|
||||
@@ -251,7 +251,7 @@ def serve_console(
|
||||
] = 8000,
|
||||
) -> None:
|
||||
"""Serve the built Mission Core Control Station and loopback control API."""
|
||||
frontend = Path(__file__).resolve().parents[2] / "apps" / "control-station" / "dist"
|
||||
frontend = Path(__file__).resolve().parents[4] / "apps" / "control-station" / "dist"
|
||||
if not frontend.is_dir():
|
||||
console.print(
|
||||
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
|
||||
|
||||
@@ -6,6 +6,7 @@ import hmac
|
||||
import importlib.util
|
||||
import json
|
||||
import secrets
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
import unicodedata
|
||||
@@ -178,17 +179,18 @@ class BleScanRequest(StrictRequest):
|
||||
|
||||
|
||||
class CompatibilityAttestationRequest(StrictRequest):
|
||||
"""Explicit operator claim required before using the exact lab profile."""
|
||||
"""Selected profile whose facts must be verified from live DeviceInfo."""
|
||||
|
||||
firmware_version: Literal["3.0.2"]
|
||||
topology: Literal["direct-lan"]
|
||||
operator_confirmed: Literal[True]
|
||||
verification: Literal["live-device-info"]
|
||||
|
||||
|
||||
class ConnectRequest(StrictRequest):
|
||||
device_id: str = Field(min_length=1, max_length=128)
|
||||
ssid: str = Field(min_length=1, max_length=128)
|
||||
password: SecretStr = Field(min_length=1, max_length=256)
|
||||
connection_mode: Literal["bridge"] = "bridge"
|
||||
compatibility_attestation: CompatibilityAttestationRequest
|
||||
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
idempotency_key: str | None = Field(default=None, min_length=1, max_length=160)
|
||||
@@ -234,6 +236,8 @@ class OperatorPresenceRequest(StrictRequest):
|
||||
|
||||
class PrepareAcquisitionRequest(OperationContextRequest):
|
||||
project_name: str = Field(min_length=1, max_length=96)
|
||||
mount_type: Literal["handheld"] = "handheld"
|
||||
gnss_mode: Literal["none"] = "none"
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||
requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS
|
||||
@@ -331,6 +335,7 @@ class XgridsK1CompatibilityService:
|
||||
self._devices: list[dict[str, Any]] = []
|
||||
self._selected_device_id: str | None = None
|
||||
self._k1_ip: str | None = None
|
||||
self._connection_mode: Literal["bridge"] | None = None
|
||||
self._device_ids_by_transport_ref: dict[str, str] = {}
|
||||
self._device_id: str | None = None
|
||||
self._device_session_id: str | None = None
|
||||
@@ -347,6 +352,8 @@ class XgridsK1CompatibilityService:
|
||||
self._operations = OperationJournal()
|
||||
self._acquisition: AcquisitionRecord | None = None
|
||||
self._acquisition_project_name: str | None = None
|
||||
self._acquisition_mount_type: Literal["handheld"] | None = None
|
||||
self._acquisition_gnss_mode: Literal["none"] | None = None
|
||||
self._acquisition_out_dir: Path | None = None
|
||||
self._acquisition_start_operation_id: str | None = None
|
||||
self._acquisition_stop_operation_id: str | None = None
|
||||
@@ -379,7 +386,8 @@ class XgridsK1CompatibilityService:
|
||||
application_control_session = self._application_control_session.snapshot()
|
||||
runtime = self.runtime.snapshot()
|
||||
camera_preview = self.camera_preview.snapshot()
|
||||
self._reconcile_acquisition(runtime, camera_preview)
|
||||
self._reconcile_acquisition(runtime, camera_preview, application_control_session)
|
||||
runtime = self.runtime.snapshot()
|
||||
camera_preview = self.camera_preview.snapshot()
|
||||
metrics = runtime["metrics"]
|
||||
with self._lock:
|
||||
@@ -388,6 +396,7 @@ class XgridsK1CompatibilityService:
|
||||
devices = list(self._devices)
|
||||
selected_device_id = self._selected_device_id
|
||||
k1_ip = self._k1_ip
|
||||
connection_mode = self._connection_mode
|
||||
device_id = self._device_id
|
||||
device_session_id = self._device_session_id
|
||||
device_session_opened_at = self._device_session_opened_at
|
||||
@@ -400,6 +409,8 @@ class XgridsK1CompatibilityService:
|
||||
acquisition = self._acquisition.as_dict() if self._acquisition is not None else None
|
||||
if acquisition is not None:
|
||||
acquisition["project_name"] = self._acquisition_project_name
|
||||
acquisition["mount_type"] = self._acquisition_mount_type
|
||||
acquisition["gnss_mode"] = self._acquisition_gnss_mode
|
||||
acquisition["cleanup_pending"] = (
|
||||
self._acquisition_session_lease is not None
|
||||
and acquisition["state"] in TERMINAL_ACQUISITION_STATES
|
||||
@@ -450,6 +461,7 @@ class XgridsK1CompatibilityService:
|
||||
"devices": devices,
|
||||
"selected_device_id": selected_device_id,
|
||||
"k1_ip": k1_ip,
|
||||
"connection_mode": connection_mode,
|
||||
"compatibility": {
|
||||
"profile_id": active_profile_id,
|
||||
"decision": "limited" if active_profile_id is not None else "unknown",
|
||||
@@ -461,9 +473,9 @@ class XgridsK1CompatibilityService:
|
||||
else "evidence-only"
|
||||
),
|
||||
"firmware_claim": (
|
||||
"operator-attested-exact-3.0.2"
|
||||
"live-device-info-verification-required"
|
||||
if compatibility_attestation is not None
|
||||
else "exact-3.0.2-profile-not-attested"
|
||||
else "exact-3.0.2-profile-not-selected"
|
||||
),
|
||||
"attestation": compatibility_attestation,
|
||||
"vendor_writes_enabled": active_control,
|
||||
@@ -598,6 +610,7 @@ class XgridsK1CompatibilityService:
|
||||
"device_id": request.device_id,
|
||||
"ssid": request.ssid,
|
||||
"password": password,
|
||||
"connection_mode": request.connection_mode,
|
||||
"compatibility_attestation": request.compatibility_attestation.model_dump(
|
||||
mode="json"
|
||||
),
|
||||
@@ -678,6 +691,7 @@ class XgridsK1CompatibilityService:
|
||||
)
|
||||
write_json_atomic(session_dir / "provisioning.sensitive.json", result)
|
||||
ipv4 = _provisioned_ipv4(result)
|
||||
local_address_conflict = ipv4 is not None and _target_is_local_ipv4(ipv4)
|
||||
write_json_atomic(
|
||||
session_dir / "manifest.redacted.json",
|
||||
{
|
||||
@@ -688,6 +702,9 @@ class XgridsK1CompatibilityService:
|
||||
"profile_id": result["profile_id"],
|
||||
"outcome": result["outcome"],
|
||||
"k1_lan_address_observed": ipv4 is not None,
|
||||
"k1_lan_address_admitted": ipv4 is not None
|
||||
and not local_address_conflict,
|
||||
"local_address_conflict": local_address_conflict,
|
||||
"credentials_persisted_by_connector": False,
|
||||
},
|
||||
)
|
||||
@@ -695,9 +712,15 @@ class XgridsK1CompatibilityService:
|
||||
raise RuntimeError(
|
||||
"Устройство не сообщило адрес в локальной сети; автоматического повтора не было"
|
||||
)
|
||||
if local_address_conflict:
|
||||
raise RuntimeError(
|
||||
"Устройство сообщило IPv4-адрес, который уже принадлежит этому компьютеру; "
|
||||
"адрес K1 не принят и автоматического повтора не было"
|
||||
)
|
||||
with self._lock:
|
||||
self._selected_device_id = request.device_id
|
||||
self._k1_ip = ipv4
|
||||
self._connection_mode = request.connection_mode
|
||||
self._device_id = self._device_ids_by_transport_ref.setdefault(
|
||||
request.device_id,
|
||||
new_device_id(),
|
||||
@@ -792,7 +815,7 @@ class XgridsK1CompatibilityService:
|
||||
attestation = self._compatibility_attestation
|
||||
acquisition = self._acquisition
|
||||
if target is None or attestation is None:
|
||||
raise RuntimeError("сначала подключите и подтвердите exact-profile K1")
|
||||
raise RuntimeError("сначала подключите K1 и выберите exact-profile")
|
||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
raise RuntimeError("control-сессия должна быть открыта до подготовки acquisition")
|
||||
self._application_control.disarm()
|
||||
@@ -830,7 +853,7 @@ class XgridsK1CompatibilityService:
|
||||
if self._k1_ip is None or self._selected_device_id is None:
|
||||
raise RuntimeError("сначала выберите и подключите K1 через BLE/Wi-Fi")
|
||||
if self._compatibility_attestation is None:
|
||||
raise RuntimeError("exact FW 3.0.2 direct-LAN profile не подтверждён")
|
||||
raise RuntimeError("exact FW 3.0.2 direct-LAN profile не выбран")
|
||||
acquisition = self._acquisition
|
||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
raise RuntimeError(
|
||||
@@ -914,6 +937,8 @@ class XgridsK1CompatibilityService:
|
||||
"requested_streams": requested_streams,
|
||||
"evidence_policy": request.evidence_policy,
|
||||
"project_name": request.project_name,
|
||||
"mount_type": request.mount_type,
|
||||
"gnss_mode": request.gnss_mode,
|
||||
"compatibility_attestation": request.compatibility_attestation.model_dump(
|
||||
mode="json"
|
||||
),
|
||||
@@ -974,6 +999,8 @@ class XgridsK1CompatibilityService:
|
||||
)
|
||||
self._acquisition = acquisition
|
||||
self._acquisition_project_name = request.project_name
|
||||
self._acquisition_mount_type = request.mount_type
|
||||
self._acquisition_gnss_mode = request.gnss_mode
|
||||
self._acquisition_out_dir = new_live_session_dir(self.evidence_root)
|
||||
self._acquisition_start_operation_id = None
|
||||
self._acquisition_stop_operation_id = None
|
||||
@@ -1136,20 +1163,27 @@ class XgridsK1CompatibilityService:
|
||||
expected_stop_operation_id = self._acquisition_stop_operation_id
|
||||
lease_retained = self._acquisition_session_lease is not None
|
||||
|
||||
terminal_manual_stop_recovery = (
|
||||
request.operator_confirmed
|
||||
and not plugin_commanded
|
||||
and acquisition_state in TERMINAL_ACQUISITION_STATES
|
||||
)
|
||||
|
||||
if request.operator_confirmed:
|
||||
if request.mode != "graceful":
|
||||
raise ValueError("operator_confirmed допустим только для graceful stop")
|
||||
if acquisition_state != "awaiting_external_stop":
|
||||
if (
|
||||
acquisition_state != "awaiting_external_stop"
|
||||
and not terminal_manual_stop_recovery
|
||||
):
|
||||
raise ValueError("acquisition не ожидает подтверждения физической остановки")
|
||||
if request.operation_id is None or request.operation_id != expected_stop_operation_id:
|
||||
raise ValueError(
|
||||
"подтверждение остановки должно ссылаться на исходную stop-operation"
|
||||
)
|
||||
if plugin_commanded and not self._application_control_session.snapshot()[
|
||||
"can_confirm_standby"
|
||||
]:
|
||||
raise RuntimeError(
|
||||
"сначала дождитесь READY от K1 и визуально подтвердите зелёный индикатор"
|
||||
if plugin_commanded:
|
||||
raise ValueError(
|
||||
"канонический STOP завершается автоматически по READY от K1"
|
||||
)
|
||||
elif (
|
||||
request.mode == "graceful"
|
||||
@@ -1180,6 +1214,14 @@ class XgridsK1CompatibilityService:
|
||||
if self._application_control_session.snapshot()["state"] != "scanning":
|
||||
raise RuntimeError("канонический диалог K1 ещё не готов принять STOP")
|
||||
|
||||
if terminal_manual_stop_recovery:
|
||||
if lease_retained:
|
||||
self._stop_acquisition_sources(
|
||||
camera_status="failed",
|
||||
camera_failure_code="terminal-local-failure-after-device-stop",
|
||||
)
|
||||
return self.state()
|
||||
|
||||
request_fingerprint = self._request_fingerprint(
|
||||
ACTION_ACQUISITION_STOP,
|
||||
{
|
||||
@@ -1271,9 +1313,14 @@ class XgridsK1CompatibilityService:
|
||||
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()
|
||||
)
|
||||
self.camera_preview.expect_source_end_for_device_stop()
|
||||
try:
|
||||
self._application_control_session.request_stop(
|
||||
confirmation=request.physical_acceptance.confirmation()
|
||||
)
|
||||
except Exception:
|
||||
self.camera_preview.cancel_expected_source_end()
|
||||
raise
|
||||
with self._lock:
|
||||
acquisition.transition(
|
||||
"awaiting_external_stop",
|
||||
@@ -1282,22 +1329,17 @@ class XgridsK1CompatibilityService:
|
||||
if plugin_commanded
|
||||
else "acquisition.stop.operator_action_required"
|
||||
),
|
||||
operator_instructions=(
|
||||
(
|
||||
"Канонический STOP запрошен один раз. Дождитесь READY, "
|
||||
"убедитесь, что индикатор постоянно зелёный, и подтвердите это."
|
||||
if plugin_commanded
|
||||
else (
|
||||
"Дважды нажмите физическую кнопку устройства и "
|
||||
"подтвердите остановку."
|
||||
)
|
||||
),
|
||||
operator_instructions=()
|
||||
if plugin_commanded
|
||||
else (
|
||||
"Дважды нажмите физическую кнопку устройства и "
|
||||
"подтвердите остановку.",
|
||||
),
|
||||
)
|
||||
self._acquisition_stop_operation_id = operation.operation_id
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"operator_action_required",
|
||||
"running" if plugin_commanded else "operator_action_required",
|
||||
stage_code="awaiting-external-stop",
|
||||
message_code=(
|
||||
"acquisition.stop.device_stopping"
|
||||
@@ -1315,8 +1357,6 @@ 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(
|
||||
@@ -1814,18 +1854,51 @@ class XgridsK1CompatibilityService:
|
||||
self,
|
||||
runtime: Mapping[str, Any],
|
||||
camera: Mapping[str, Any],
|
||||
application_control_session: Mapping[str, Any],
|
||||
) -> None:
|
||||
with self._lock:
|
||||
current = self._acquisition
|
||||
terminal_canonical_cleanup = (
|
||||
current is not None
|
||||
and current.state in TERMINAL_ACQUISITION_STATES
|
||||
and current.control_mode == "plugin-commanded"
|
||||
and application_control_session.get("state") == "completed"
|
||||
and self._acquisition_session_lease is not None
|
||||
)
|
||||
terminal_camera_status: Literal["complete", "failed"] = (
|
||||
"complete"
|
||||
if current is not None and current.state == "completed"
|
||||
else "failed"
|
||||
)
|
||||
if terminal_canonical_cleanup:
|
||||
self._stop_acquisition_sources(
|
||||
camera_status=terminal_camera_status,
|
||||
camera_failure_code=(
|
||||
None
|
||||
if terminal_camera_status == "complete"
|
||||
else "terminal-local-failure-after-device-standby"
|
||||
),
|
||||
)
|
||||
return
|
||||
|
||||
completed_operation_id: str | None = None
|
||||
failed_operation_id: str | None = None
|
||||
failed_stop_operation_id: str | None = None
|
||||
unconfirmed_stop_operation_id: str | None = None
|
||||
completed_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
|
||||
stop_runtime_for_camera_failure = False
|
||||
stop_runtime_for_canonical_completion = False
|
||||
complete_after_seal = False
|
||||
completion_message_code = "acquisition.receiver_completed"
|
||||
completion_result: dict[str, Any] = {
|
||||
"receiver_stopped": True,
|
||||
"device_state": "unknown",
|
||||
}
|
||||
with self._lock:
|
||||
acquisition = self._acquisition
|
||||
if acquisition is None or acquisition.state in TERMINAL_ACQUISITION_STATES:
|
||||
@@ -1870,6 +1943,25 @@ class XgridsK1CompatibilityService:
|
||||
failed_stop_operation_id = self._acquisition_stop_operation_id
|
||||
camera_terminal_status = "failed"
|
||||
stop_runtime_for_camera_failure = True
|
||||
elif (
|
||||
acquisition.state == "awaiting_external_stop"
|
||||
and acquisition.control_mode == "plugin-commanded"
|
||||
and application_control_session.get("state") == "completed"
|
||||
):
|
||||
acquisition.transition(
|
||||
"finalizing",
|
||||
message_code="acquisition.stop.device_standby_confirmed",
|
||||
)
|
||||
camera_terminal_status = "complete"
|
||||
stop_runtime_for_canonical_completion = True
|
||||
complete_after_seal = True
|
||||
completed_stop_operation_id = self._acquisition_stop_operation_id
|
||||
completion_message_code = "acquisition.stop.completed"
|
||||
completion_result = {
|
||||
"receiver_stopped": True,
|
||||
"device_state": "ready",
|
||||
"device_stop": "protocol-confirmed",
|
||||
}
|
||||
elif acquisition.state in {"starting", "awaiting_external_start"} and point_frames > 0:
|
||||
acquisition.transition("acquiring", message_code="acquisition.acquiring")
|
||||
completed_operation_id = self._acquisition_start_operation_id
|
||||
@@ -1954,7 +2046,7 @@ class XgridsK1CompatibilityService:
|
||||
)
|
||||
except Exception as exc:
|
||||
terminal_error = exc
|
||||
if stop_runtime_for_camera_failure:
|
||||
if stop_runtime_for_camera_failure or stop_runtime_for_canonical_completion:
|
||||
try:
|
||||
self.runtime.stop()
|
||||
except Exception as exc:
|
||||
@@ -1973,11 +2065,8 @@ class XgridsK1CompatibilityService:
|
||||
if acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
acquisition.transition(
|
||||
"completed",
|
||||
message_code="acquisition.receiver_completed",
|
||||
result={
|
||||
"receiver_stopped": True,
|
||||
"device_state": "unknown",
|
||||
},
|
||||
message_code=completion_message_code,
|
||||
result=completion_result,
|
||||
)
|
||||
except Exception as exc:
|
||||
reconciliation_error = exc
|
||||
@@ -2058,6 +2147,32 @@ class XgridsK1CompatibilityService:
|
||||
"side_effect_status": "unknown",
|
||||
},
|
||||
)
|
||||
if completed_stop_operation_id is not None:
|
||||
if reconciliation_error is None:
|
||||
self._operations.transition_if_pending(
|
||||
completed_stop_operation_id,
|
||||
"succeeded",
|
||||
stage_code="device-standby-confirmed",
|
||||
message_code="acquisition.stop.completed",
|
||||
result={
|
||||
"acquisition_id": acquisition.acquisition_id,
|
||||
"confirmation": "device-status-ready-unbound",
|
||||
},
|
||||
)
|
||||
else:
|
||||
self._operations.transition_if_pending(
|
||||
completed_stop_operation_id,
|
||||
"failed",
|
||||
stage_code="local-finalization-failed",
|
||||
message_code="acquisition.stop.local_finalization_failed",
|
||||
error={
|
||||
"category": "stream",
|
||||
"code": "local-finalization-failed-after-device-standby",
|
||||
"retryable": True,
|
||||
"safe_to_retry": True,
|
||||
"side_effect_status": "succeeded",
|
||||
},
|
||||
)
|
||||
with self._lock:
|
||||
if self._acquisition_start_operation_id in {
|
||||
completed_operation_id,
|
||||
@@ -2068,6 +2183,8 @@ class XgridsK1CompatibilityService:
|
||||
self._acquisition_stop_operation_id = None
|
||||
if self._acquisition_stop_operation_id == unconfirmed_stop_operation_id:
|
||||
self._acquisition_stop_operation_id = None
|
||||
if self._acquisition_stop_operation_id == completed_stop_operation_id:
|
||||
self._acquisition_stop_operation_id = None
|
||||
if reconciliation_error is not None:
|
||||
raise reconciliation_error
|
||||
|
||||
@@ -2345,7 +2462,8 @@ def _attestation_snapshot(
|
||||
return {
|
||||
"firmware_version": attestation.firmware_version,
|
||||
"topology": attestation.topology,
|
||||
"basis": "operator-attested",
|
||||
"verification": attestation.verification,
|
||||
"basis": "selected-profile-live-device-info-required",
|
||||
"observed_at": _utc_now_iso(),
|
||||
}
|
||||
|
||||
@@ -2394,7 +2512,7 @@ def _sensor_catalog(
|
||||
else (
|
||||
"local-runtime-dependency-missing"
|
||||
if camera_profile_active
|
||||
else "profile-not-attested"
|
||||
else "profile-not-selected"
|
||||
)
|
||||
),
|
||||
"endpoint_label": f"RTSP · {side}",
|
||||
@@ -2506,6 +2624,24 @@ def _provisioned_ipv4(result: Mapping[str, Any]) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _target_is_local_ipv4(target: str) -> bool:
|
||||
"""Reject a BLE-reported target when the host route resolves back to itself.
|
||||
|
||||
Connecting a UDP socket only asks the kernel to select a route and source
|
||||
address; it does not transmit a datagram. This keeps provisioning admission
|
||||
free of a speculative device probe while preventing a host address from
|
||||
being mistaken for the K1 endpoint.
|
||||
"""
|
||||
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as route_socket:
|
||||
route_socket.connect((validate_private_ipv4(target), 9))
|
||||
source_address = str(route_socket.getsockname()[0])
|
||||
except OSError as exc:
|
||||
raise RuntimeError("не удалось безопасно проверить маршрут к адресу K1") from exc
|
||||
return source_address == target
|
||||
|
||||
|
||||
def _validate_installed_compatibility_profile(repository_root: Path) -> None:
|
||||
"""Run the plugin-owned, fail-closed profile validator before activation."""
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Literal, Protocol
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
MODELING_STATUS_RESPONSE_TOPIC,
|
||||
ApplicationBootstrapError,
|
||||
ApplicationCompatibilityProfileError,
|
||||
ApplicationControlAuthority,
|
||||
CanonicalPostStartObservation,
|
||||
LiveDeviceControlBinding,
|
||||
@@ -39,18 +40,31 @@ MAX_ACCEPTANCE_PERMIT_SECONDS = 120.0
|
||||
|
||||
# Socket-pump quantum only. It is never used to advance a K1 dialogue stage.
|
||||
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS = 1.0
|
||||
SCAN_INITIALIZATION_TIMEOUT_SECONDS = 120.0
|
||||
|
||||
|
||||
class ApplicationAcceptanceError(RuntimeError):
|
||||
"""The operator-present physical acceptance contract failed closed."""
|
||||
|
||||
|
||||
class ScanInitializationTimeout(ApplicationAcceptanceError):
|
||||
"""START was acknowledged but the reviewed SCANNING gate never completed."""
|
||||
|
||||
reason_code = "scan_initialization_timeout"
|
||||
|
||||
|
||||
class ApplicationProfileMismatchError(ApplicationAcceptanceError):
|
||||
"""A correlated live DeviceInfo response rejected the selected profile."""
|
||||
|
||||
reason_code = "compatibility_profile_mismatch"
|
||||
|
||||
|
||||
class ApplicationBatchExchange(Protocol):
|
||||
def exchange_batch_once(
|
||||
self,
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_topics: Collection[str],
|
||||
required_response_operation_keys: Collection[str],
|
||||
) -> dict[str, bytes]: ...
|
||||
|
||||
def maintain_open_for(
|
||||
@@ -182,8 +196,24 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
def __init__(
|
||||
self,
|
||||
transport: ApplicationBatchExchange,
|
||||
*,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
scan_initialization_timeout_seconds: float = SCAN_INITIALIZATION_TIMEOUT_SECONDS,
|
||||
) -> None:
|
||||
if (
|
||||
not isinstance(scan_initialization_timeout_seconds, (int, float))
|
||||
or isinstance(scan_initialization_timeout_seconds, bool)
|
||||
or not math.isfinite(scan_initialization_timeout_seconds)
|
||||
or scan_initialization_timeout_seconds <= 0
|
||||
):
|
||||
raise ApplicationAcceptanceError(
|
||||
"scan initialization timeout must be a positive finite number"
|
||||
)
|
||||
self._transport = transport
|
||||
self._monotonic = monotonic
|
||||
self._scan_initialization_timeout_seconds = float(
|
||||
scan_initialization_timeout_seconds
|
||||
)
|
||||
self._bootstrap_complete = False
|
||||
self._command_complete = False
|
||||
self._dialogue_stage = "new"
|
||||
@@ -199,6 +229,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
self._stop_permit_snapshot: dict[str, object] | None = None
|
||||
self._response_evidence: list[dict[str, object]] = []
|
||||
self._correlation_failure: dict[str, object] | None = None
|
||||
self._compatibility_failure: dict[str, object] | None = None
|
||||
|
||||
def run_bootstrap(
|
||||
self,
|
||||
@@ -217,7 +248,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
|
||||
if self._dialogue_stage != "new" or self._bootstrap_complete or self._command_complete:
|
||||
raise ApplicationAcceptanceError("connection stage is not admissible now")
|
||||
for expected_batch in (1, 2, 3):
|
||||
for expected_batch in (1, 2):
|
||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=expected_batch)
|
||||
binding = orchestrator.binding
|
||||
if binding is None:
|
||||
@@ -270,7 +301,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
if self._dialogue_stage != "connection-ready":
|
||||
raise ApplicationAcceptanceError("workspace entry requires the connection stage")
|
||||
self._consume_checkpoint(checkpoint, expected="workspace-entered")
|
||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=4)
|
||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=3)
|
||||
binding = orchestrator.binding
|
||||
if binding is None:
|
||||
raise ApplicationAcceptanceError("workspace entry lost the live device binding")
|
||||
@@ -287,7 +318,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
if self._dialogue_stage != "workspace-ready":
|
||||
raise ApplicationAcceptanceError("project prompt requires workspace entry")
|
||||
self._consume_checkpoint(checkpoint, expected="project-prompt-opened")
|
||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=5)
|
||||
self._exchange_bootstrap_batch(orchestrator, expected_batch=4)
|
||||
if not orchestrator.snapshot().bootstrap_complete:
|
||||
raise ApplicationAcceptanceError("project prompt did not complete the transcript")
|
||||
binding = orchestrator.binding
|
||||
@@ -338,9 +369,9 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
self._dialogue_stage = "start-attempted"
|
||||
responses = self._transport.exchange_batch_once(
|
||||
[OneShotPublishEnvelope.from_modeling_command(command)],
|
||||
required_response_topics={MODELING_RESPONSE_TOPIC},
|
||||
required_response_operation_keys={"modeling:start"},
|
||||
)
|
||||
payload = responses[MODELING_RESPONSE_TOPIC]
|
||||
payload = responses["modeling:start"]
|
||||
self._record_response_evidence(
|
||||
phase="modeling",
|
||||
operation_key="modeling:start",
|
||||
@@ -363,24 +394,33 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
immediate = post_start.immediate_modeling_status
|
||||
self._transport.exchange_batch_once(
|
||||
[OneShotPublishEnvelope.from_dialogue_request(immediate)],
|
||||
required_response_topics=(),
|
||||
required_response_operation_keys=(),
|
||||
)
|
||||
self._dialogue_stage = "initializing"
|
||||
initialization_deadline = (
|
||||
self._monotonic() + self._scan_initialization_timeout_seconds
|
||||
)
|
||||
while not self._transport.scan_initialization_complete(binding):
|
||||
if self._monotonic() >= initialization_deadline:
|
||||
raise ScanInitializationTimeout(
|
||||
"K1 did not confirm bound SCANNING initialization before the safety deadline"
|
||||
)
|
||||
self._transport.maintain_open_for(
|
||||
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||
)
|
||||
|
||||
refresh = post_start.post_initialization_refresh
|
||||
required_topics = {request.response_topic for request in refresh}
|
||||
required_operations = {
|
||||
f"dialogue:{request.ordinal}:{request.message_type}" for request in refresh
|
||||
}
|
||||
refresh_responses = self._transport.exchange_batch_once(
|
||||
[OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh],
|
||||
required_response_topics=required_topics,
|
||||
required_response_operation_keys=required_operations,
|
||||
)
|
||||
for request in refresh:
|
||||
refresh_payload = refresh_responses[request.response_topic]
|
||||
operation_key = f"dialogue:{request.ordinal}:{request.message_type}"
|
||||
refresh_payload = refresh_responses[operation_key]
|
||||
self._record_response_evidence(
|
||||
phase="post-start",
|
||||
operation_key=operation_key,
|
||||
@@ -480,9 +520,9 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
self._dialogue_stage = "stop-attempted"
|
||||
responses = self._transport.exchange_batch_once(
|
||||
[OneShotPublishEnvelope.from_modeling_command(command)],
|
||||
required_response_topics={MODELING_RESPONSE_TOPIC},
|
||||
required_response_operation_keys={"modeling:stop"},
|
||||
)
|
||||
payload = responses[MODELING_RESPONSE_TOPIC]
|
||||
payload = responses["modeling:stop"]
|
||||
self._record_response_evidence(
|
||||
phase="modeling",
|
||||
operation_key="modeling:stop",
|
||||
@@ -505,16 +545,13 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
self._dialogue_stage = "stop-acknowledged"
|
||||
return response
|
||||
|
||||
def maintain_post_stop_until_standby_confirmed(
|
||||
self,
|
||||
standby_confirmed: Callable[[], bool],
|
||||
) -> None:
|
||||
"""Keep servicing control reports through save and physical standby.
|
||||
def maintain_post_stop_until_standby(self) -> None:
|
||||
"""Keep servicing control reports through save and protocol standby.
|
||||
|
||||
The retained capture does not expose a protocol timer that proves save
|
||||
completion. The session therefore remains owned until an explicit
|
||||
operator/status gate confirms standby and never closes itself merely
|
||||
because a captured wall-clock duration elapsed.
|
||||
There is no wall-clock shortcut after STOP. The dialogue remains owned
|
||||
until the bound K1 itself reports READY with no project attached. That
|
||||
live protocol state is the canonical completion evidence; a second
|
||||
operator acknowledgement would add no independent information.
|
||||
"""
|
||||
|
||||
if self._dialogue_stage != "stop-acknowledged" or not self._stop_complete:
|
||||
@@ -524,9 +561,7 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
binding = self._active_binding
|
||||
if binding is None:
|
||||
raise ApplicationAcceptanceError("canonical START binding is no longer available")
|
||||
while not (
|
||||
self._transport.standby_complete(binding) and standby_confirmed()
|
||||
):
|
||||
while not self._transport.standby_complete(binding):
|
||||
self._transport.maintain_open_for(
|
||||
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
|
||||
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
|
||||
@@ -555,12 +590,19 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
if self._stop_permit_snapshot is not None
|
||||
else None
|
||||
),
|
||||
"response_evidence": tuple(dict(item) for item in self._response_evidence),
|
||||
# This snapshot crosses the plugin SDK boundary, whose JsonValue
|
||||
# contract intentionally rejects Python-only container types.
|
||||
"response_evidence": [dict(item) for item in self._response_evidence],
|
||||
"correlation_failure": (
|
||||
dict(self._correlation_failure)
|
||||
if self._correlation_failure is not None
|
||||
else None
|
||||
),
|
||||
"compatibility_failure": (
|
||||
dict(self._compatibility_failure)
|
||||
if self._compatibility_failure is not None
|
||||
else None
|
||||
),
|
||||
"automatic_retry": False,
|
||||
}
|
||||
|
||||
@@ -608,18 +650,20 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
f"canonical bootstrap expected batch {expected_batch}"
|
||||
)
|
||||
batch = orchestrator.next_batch()
|
||||
required_topics = {
|
||||
request.response_topic for request in batch if request.response_required
|
||||
required_operations = {
|
||||
f"bootstrap:{request.ordinal}:{request.message_type}"
|
||||
for request in batch
|
||||
if request.response_required
|
||||
}
|
||||
responses = self._transport.exchange_batch_once(
|
||||
[OneShotPublishEnvelope.from_bootstrap_request(request) for request in batch],
|
||||
required_response_topics=required_topics,
|
||||
required_response_operation_keys=required_operations,
|
||||
)
|
||||
for request in batch:
|
||||
if not request.response_required:
|
||||
continue
|
||||
payload = responses[request.response_topic]
|
||||
operation_key = f"bootstrap:{request.ordinal}:{request.message_type}"
|
||||
payload = responses[operation_key]
|
||||
self._record_response_evidence(
|
||||
phase="bootstrap",
|
||||
operation_key=operation_key,
|
||||
@@ -627,7 +671,25 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
payload=payload,
|
||||
)
|
||||
try:
|
||||
orchestrator.accept_response(request.response_topic, payload)
|
||||
orchestrator.accept_response(operation_key, payload)
|
||||
except ApplicationCompatibilityProfileError as exc:
|
||||
self._compatibility_failure = {
|
||||
"phase": "bootstrap",
|
||||
"operation_key": operation_key,
|
||||
"response_topic": request.response_topic,
|
||||
"reason_code": exc.reason_code,
|
||||
"expected": {
|
||||
"device_model": "LixelKity K1",
|
||||
"platform_type": "A4",
|
||||
"firmware": "3.0.2",
|
||||
"is_activated": True,
|
||||
},
|
||||
"observed": dict(exc.observed_facts),
|
||||
}
|
||||
raise ApplicationProfileMismatchError(
|
||||
"live DeviceInfo is incompatible with the selected "
|
||||
"LixelKity K1 / A4 / FW 3.0.2 profile"
|
||||
) from exc
|
||||
except ApplicationBootstrapError as exc:
|
||||
self._record_correlation_failure(
|
||||
phase="bootstrap",
|
||||
@@ -670,5 +732,23 @@ class PhysicalAcceptanceDialogueExecutor:
|
||||
"phase": phase,
|
||||
"operation_key": operation_key,
|
||||
"response_topic": response_topic,
|
||||
"reason_code": self._correlation_reason_code(reason),
|
||||
"reason": reason,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _correlation_reason_code(reason: str) -> str:
|
||||
reason_fragments = (
|
||||
("session correlation failed", "response_session_mismatch"),
|
||||
("vendor identity mismatch", "response_device_identity_mismatch"),
|
||||
("authority mismatch", "response_authority_mismatch"),
|
||||
("result code", "response_rejected"),
|
||||
("facts changed", "response_device_facts_changed"),
|
||||
("invalid", "response_decode_failed"),
|
||||
("missing", "response_decode_failed"),
|
||||
("wrong wire type", "response_decode_failed"),
|
||||
)
|
||||
return next(
|
||||
(code for fragment, code in reason_fragments if fragment in reason),
|
||||
"response_correlation_failed",
|
||||
)
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from typing import Protocol
|
||||
from typing import Any, Protocol, cast
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationBootstrapError,
|
||||
@@ -33,6 +34,10 @@ class CommandRunner(Protocol):
|
||||
) -> subprocess.CompletedProcess[bytes]: ...
|
||||
|
||||
|
||||
class KeychainSecretReader(Protocol):
|
||||
def __call__(self, *, service: str, account: str) -> bytes: ...
|
||||
|
||||
|
||||
class InteractiveCommandRunner(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
@@ -43,6 +48,63 @@ class InteractiveCommandRunner(Protocol):
|
||||
) -> subprocess.CompletedProcess[bytes]: ...
|
||||
|
||||
|
||||
def _read_keychain_secret_via_security_framework(*, service: str, account: str) -> bytes:
|
||||
"""Read one fixed generic-password item without spawning a UI/CLI process."""
|
||||
|
||||
try:
|
||||
objc = importlib.import_module("objc")
|
||||
foundation = importlib.import_module("Foundation")
|
||||
bundle = foundation.NSBundle.bundleWithPath_(
|
||||
"/System/Library/Frameworks/Security.framework"
|
||||
)
|
||||
if bundle is None or not bundle.load():
|
||||
raise RuntimeError("Security.framework is unavailable")
|
||||
|
||||
symbols: dict[str, Any] = {}
|
||||
objc.loadBundleVariables(
|
||||
bundle,
|
||||
symbols,
|
||||
[
|
||||
("kSecClass", b"@"),
|
||||
("kSecClassGenericPassword", b"@"),
|
||||
("kSecAttrService", b"@"),
|
||||
("kSecAttrAccount", b"@"),
|
||||
("kSecReturnData", b"@"),
|
||||
("kSecMatchLimit", b"@"),
|
||||
("kSecMatchLimitOne", b"@"),
|
||||
],
|
||||
)
|
||||
functions: dict[str, Any] = {}
|
||||
objc.loadBundleFunctions(
|
||||
bundle,
|
||||
functions,
|
||||
[("SecItemCopyMatching", b"i@o^@")],
|
||||
)
|
||||
query = {
|
||||
symbols["kSecClass"]: symbols["kSecClassGenericPassword"],
|
||||
symbols["kSecAttrService"]: service,
|
||||
symbols["kSecAttrAccount"]: account,
|
||||
symbols["kSecReturnData"]: True,
|
||||
symbols["kSecMatchLimit"]: symbols["kSecMatchLimitOne"],
|
||||
}
|
||||
copy_matching = cast(Any, functions["SecItemCopyMatching"])
|
||||
result = copy_matching(query, None)
|
||||
except ApplicationAuthorityLoadError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
||||
|
||||
if not isinstance(result, tuple) or len(result) != 2:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed")
|
||||
status, secret_data = result
|
||||
if int(status) != 0 or secret_data is None:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
||||
try:
|
||||
return bytes(secret_data)
|
||||
except Exception as exc:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationAuthoritySourceSnapshot:
|
||||
provider: str = "macos-keychain"
|
||||
@@ -69,12 +131,19 @@ class MacOSKeychainApplicationAuthorityLoader:
|
||||
"""Read the exact-profile authority from the current user's Keychain.
|
||||
|
||||
There is deliberately no environment, plaintext-file, browser or API
|
||||
fallback. The secret is requested through the absolute ``security`` binary
|
||||
and is never interpolated into an exception, repr or subprocess argument.
|
||||
fallback. Runtime reads call Apple's Security.framework in-process and
|
||||
never launch Keychain Access or the ``security`` CLI. The optional command
|
||||
runner exists only as a compatibility seam for the reviewed test harness.
|
||||
"""
|
||||
|
||||
def __init__(self, *, runner: CommandRunner = subprocess.run) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
runner: CommandRunner | None = None,
|
||||
framework_reader: KeychainSecretReader = _read_keychain_secret_via_security_framework,
|
||||
) -> None:
|
||||
self._runner = runner
|
||||
self._framework_reader = framework_reader
|
||||
|
||||
def snapshot(self) -> ApplicationAuthoritySourceSnapshot:
|
||||
return ApplicationAuthoritySourceSnapshot()
|
||||
@@ -84,32 +153,48 @@ class MacOSKeychainApplicationAuthorityLoader:
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"application authority loading is supported only through macOS Keychain"
|
||||
)
|
||||
security = shutil.which("security")
|
||||
if security != "/usr/bin/security":
|
||||
raise ApplicationAuthorityLoadError("trusted macOS security binary is unavailable")
|
||||
if self._runner is None:
|
||||
try:
|
||||
secret_bytes = self._framework_reader(
|
||||
service=KEYCHAIN_SERVICE,
|
||||
account=KEYCHAIN_ACCOUNT,
|
||||
)
|
||||
except ApplicationAuthorityLoadError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"macOS Keychain authority lookup failed"
|
||||
) from exc
|
||||
else:
|
||||
security = shutil.which("security")
|
||||
if security != "/usr/bin/security":
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"trusted macOS security binary is unavailable"
|
||||
)
|
||||
try:
|
||||
completed = self._runner(
|
||||
[
|
||||
security,
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-w",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=KEYCHAIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ApplicationAuthorityLoadError(
|
||||
"macOS Keychain authority lookup failed"
|
||||
) from exc
|
||||
if completed.returncode != 0:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
||||
secret_bytes = completed.stdout
|
||||
|
||||
try:
|
||||
completed = self._runner(
|
||||
[
|
||||
security,
|
||||
"find-generic-password",
|
||||
"-s",
|
||||
KEYCHAIN_SERVICE,
|
||||
"-a",
|
||||
KEYCHAIN_ACCOUNT,
|
||||
"-w",
|
||||
],
|
||||
capture_output=True,
|
||||
check=False,
|
||||
timeout=KEYCHAIN_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority lookup failed") from exc
|
||||
|
||||
if completed.returncode != 0:
|
||||
raise ApplicationAuthorityLoadError("macOS Keychain authority is unavailable")
|
||||
|
||||
secret_buffer = bytearray(completed.stdout)
|
||||
secret_buffer = bytearray(secret_bytes)
|
||||
try:
|
||||
while secret_buffer.endswith((b"\n", b"\r")):
|
||||
secret_buffer.pop()
|
||||
|
||||
@@ -11,6 +11,7 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SU
|
||||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||
ProtobufWireError,
|
||||
ProtoField,
|
||||
encode_zigzag64,
|
||||
iter_fields,
|
||||
)
|
||||
|
||||
@@ -19,6 +20,12 @@ MAX_HEADER_BYTES = 4 * 1024
|
||||
MAX_TEXT_BYTES = 4 * 1024
|
||||
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
|
||||
COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
REVIEWED_DEVICE_MODEL = "LixelKity K1"
|
||||
REVIEWED_DEVICE_TYPE = "A4"
|
||||
REVIEWED_PROFILE_ATTESTATION_FAILURE = (
|
||||
"live DeviceInfo does not attest the reviewed activated "
|
||||
"LixelKity K1 (platform type A4) FW 3.0.2 profile"
|
||||
)
|
||||
DEVICE_CONFIG_TIME_CONTEXT = "Publish_Proto_DeviceConfig_SetTime"
|
||||
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
|
||||
|
||||
@@ -40,6 +47,22 @@ class ApplicationBootstrapError(ValueError):
|
||||
"""The recovered K1 application bootstrap contract was violated."""
|
||||
|
||||
|
||||
class ApplicationCompatibilityProfileError(ApplicationBootstrapError):
|
||||
"""A correlated DeviceInfo response does not match the selected profile."""
|
||||
|
||||
reason_code = "compatibility_profile_mismatch"
|
||||
|
||||
def __init__(self, binding: LiveDeviceControlBinding) -> None:
|
||||
self.observed_facts = {
|
||||
"device_model": binding.device_model,
|
||||
"platform_type": binding.device_type,
|
||||
"software_version": binding.software_version,
|
||||
"system_version": binding.system_version,
|
||||
"is_activated": binding.is_activated,
|
||||
}
|
||||
super().__init__(REVIEWED_PROFILE_ATTESTATION_FAILURE)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationControlAuthority:
|
||||
"""Private authority belonging to the reviewed LixelGO application profile.
|
||||
@@ -98,9 +121,22 @@ class LiveDeviceControlBinding:
|
||||
self.system_version, "3.0.2"
|
||||
)
|
||||
|
||||
@property
|
||||
def matches_reviewed_device(self) -> bool:
|
||||
"""Match the exact model/type strings retained from the reviewed K1."""
|
||||
|
||||
return (
|
||||
self.device_model == REVIEWED_DEVICE_MODEL
|
||||
and self.device_type == REVIEWED_DEVICE_TYPE
|
||||
)
|
||||
|
||||
@property
|
||||
def ready_for_reviewed_profile(self) -> bool:
|
||||
return self.is_activated and self.matches_reviewed_firmware
|
||||
return (
|
||||
self.is_activated
|
||||
and self.matches_reviewed_device
|
||||
and self.matches_reviewed_firmware
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -213,6 +249,15 @@ class ApplicationResponse:
|
||||
result_code: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationMessageIdentity:
|
||||
"""Redacted identity used to route one protobuf request/response safely."""
|
||||
|
||||
vendor_device_id: str | None = field(repr=False)
|
||||
session_id: str = field(repr=False)
|
||||
openapi_key: str = field(repr=False)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationBootstrapSnapshot:
|
||||
current_batch: int
|
||||
@@ -253,9 +298,7 @@ def build_shadow_bootstrap(
|
||||
"""
|
||||
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise ApplicationBootstrapError(
|
||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||
)
|
||||
raise ApplicationCompatibilityProfileError(binding)
|
||||
if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool):
|
||||
raise ApplicationBootstrapError("epoch_seconds must be an integer")
|
||||
if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF:
|
||||
@@ -373,7 +416,9 @@ def build_shadow_bootstrap(
|
||||
)
|
||||
body = b""
|
||||
if message_type == "DeviceConfigRequest":
|
||||
time_config = _varint_field(1, epoch_seconds) + _text_field(2, timezone_name)
|
||||
time_config = _varint_field(1, encode_zigzag64(epoch_seconds)) + _text_field(
|
||||
2, timezone_name
|
||||
)
|
||||
body = _bytes_field(4, time_config)
|
||||
payload = _bytes_field(1, _encode_header(header)) + body
|
||||
_check_payload(payload, "application request")
|
||||
@@ -406,9 +451,7 @@ def build_canonical_post_start_observation(
|
||||
"""Build retained operations 12-14 without granting publish authority."""
|
||||
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise ApplicationBootstrapError(
|
||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||
)
|
||||
raise ApplicationCompatibilityProfileError(binding)
|
||||
|
||||
def build(
|
||||
ordinal: Literal[12, 13, 14],
|
||||
@@ -577,15 +620,43 @@ def correlate_application_response(
|
||||
return ApplicationResponse(session_id, device_id, openapi_key, result_code)
|
||||
|
||||
|
||||
def decode_application_message_identity(payload: bytes) -> ApplicationMessageIdentity:
|
||||
"""Decode only the common header identity without exposing message bodies."""
|
||||
|
||||
_check_payload(payload, "application message")
|
||||
top = _selected_unique_fields(
|
||||
payload,
|
||||
"application message",
|
||||
max_fields=64,
|
||||
selected={1},
|
||||
)
|
||||
header = _selected_unique_fields(
|
||||
_required_bytes(top, 1, "message.header"),
|
||||
"header",
|
||||
max_fields=32,
|
||||
selected={4, 5, 6},
|
||||
)
|
||||
return ApplicationMessageIdentity(
|
||||
vendor_device_id=(
|
||||
_identity_field(header, 4, "header.device_id") if 4 in header else None
|
||||
),
|
||||
session_id=_identity_field(header, 5, "header.session_id"),
|
||||
openapi_key=_identity_field(header, 6, "header.openapi_key"),
|
||||
)
|
||||
|
||||
|
||||
class ShadowApplicationBootstrapOrchestrator:
|
||||
"""Advance the retained dialogue only across observed response barriers.
|
||||
|
||||
The clean cycle emitted five batches. ``ModelingStatusRequest`` in the
|
||||
second batch had no required synchronous response before preparation
|
||||
continued; readiness remains a separate live DeviceStatus gate.
|
||||
DeviceInfo is the sole identity-binding barrier. After it succeeds, the
|
||||
retained captures publish ordinals 2-6 in order without inventing a barrier
|
||||
between unbound ordinal 3 and bound ordinals 4-6. Their exact responses
|
||||
are correlated by operation/session identity before the operator may
|
||||
advance. ``ModelingStatusRequest`` ordinal 2 remains response-free;
|
||||
readiness is a separate live DeviceStatus gate.
|
||||
"""
|
||||
|
||||
_BATCH_ORDINALS = ((1,), (2, 3), (4, 5, 6), (7,), (8, 9, 10))
|
||||
_BATCH_ORDINALS = ((1,), (2, 3, 4, 5, 6), (7,), (8, 9, 10))
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -612,7 +683,9 @@ class ShadowApplicationBootstrapOrchestrator:
|
||||
current_batch=min(self._batch_index + 1, len(self._BATCH_ORDINALS)),
|
||||
total_batches=len(self._BATCH_ORDINALS),
|
||||
batch_issued=self._batch_issued,
|
||||
pending_response_topics=tuple(sorted(self._pending)),
|
||||
pending_response_topics=tuple(
|
||||
sorted(request.response_topic for request in self._pending.values())
|
||||
),
|
||||
live_binding_observed=self._binding is not None,
|
||||
bootstrap_complete=self._complete,
|
||||
)
|
||||
@@ -643,25 +716,23 @@ class ShadowApplicationBootstrapOrchestrator:
|
||||
for ordinal in self._BATCH_ORDINALS[self._batch_index]
|
||||
)
|
||||
pending = {
|
||||
request.response_topic: request for request in requests if request.response_required
|
||||
self._operation_key(request): request
|
||||
for request in requests
|
||||
if request.response_required
|
||||
}
|
||||
if len(pending) != sum(request.response_required for request in requests):
|
||||
raise ApplicationBootstrapError(
|
||||
"bootstrap batch contains ambiguous required response topics"
|
||||
)
|
||||
if not pending:
|
||||
raise ApplicationBootstrapError("bootstrap batch has no recovered response barrier")
|
||||
self._pending = pending
|
||||
self._batch_issued = True
|
||||
return requests
|
||||
|
||||
def accept_response(self, topic: str, payload: bytes) -> None:
|
||||
def accept_response(self, operation_key: str, payload: bytes) -> None:
|
||||
"""Correlate one required response and unlock only the next batch."""
|
||||
|
||||
with self._lock:
|
||||
if not self._batch_issued:
|
||||
raise ApplicationBootstrapError("no application bootstrap batch is in flight")
|
||||
expected = self._pending.get(topic)
|
||||
expected = self._pending.get(operation_key)
|
||||
if expected is None:
|
||||
raise ApplicationBootstrapError(
|
||||
"application response is not required by the current batch"
|
||||
@@ -704,7 +775,7 @@ class ShadowApplicationBootstrapOrchestrator:
|
||||
live_binding=self._binding,
|
||||
)
|
||||
|
||||
del self._pending[topic]
|
||||
del self._pending[operation_key]
|
||||
if self._pending:
|
||||
return
|
||||
self._batch_issued = False
|
||||
@@ -712,6 +783,10 @@ class ShadowApplicationBootstrapOrchestrator:
|
||||
if self._batch_index == len(self._BATCH_ORDINALS):
|
||||
self._complete = True
|
||||
|
||||
@staticmethod
|
||||
def _operation_key(request: EncodedApplicationRequest) -> str:
|
||||
return f"bootstrap:{request.ordinal}:{request.message_type}"
|
||||
|
||||
|
||||
def _build_initial_device_info_request(
|
||||
authority: ApplicationControlAuthority,
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import math
|
||||
import secrets
|
||||
import threading
|
||||
@@ -16,19 +17,31 @@ from paho.mqtt.reasoncodes import ReasonCode
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import AP_FALLBACK_IPV4
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
DEVICE_CONFIG_REQUEST_TOPIC,
|
||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
GET_CLOUD_CONFIG_REQUEST_TOPIC,
|
||||
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||
GET_NTRIP_PROFILE_REQUEST_TOPIC,
|
||||
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
MODELING_STATUS_REQUEST_TOPIC,
|
||||
MODELING_STATUS_RESPONSE_TOPIC,
|
||||
ApplicationBootstrapError,
|
||||
ApplicationMessageIdentity,
|
||||
LiveDeviceControlBinding,
|
||||
decode_application_message_identity,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
ModelingAction,
|
||||
ModelingProtocolError,
|
||||
decode_device_status_report,
|
||||
decode_modeling_response,
|
||||
decode_system_error_report,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
@@ -45,6 +58,16 @@ MAX_CONTROL_MAINTAIN_SECONDS = 30.0
|
||||
MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
|
||||
SYSTEM_ERROR_TOPIC = "lixel/application/report/system_error"
|
||||
|
||||
APPLICATION_RESPONSE_TOPIC_BY_REQUEST_TOPIC = {
|
||||
DEVICE_INFO_REQUEST_TOPIC: DEVICE_INFO_RESPONSE_TOPIC,
|
||||
MODELING_STATUS_REQUEST_TOPIC: MODELING_STATUS_RESPONSE_TOPIC,
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC: GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
DEVICE_CONFIG_REQUEST_TOPIC: DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
GET_NTRIP_PROFILE_REQUEST_TOPIC: GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||
GET_CLOUD_CONFIG_REQUEST_TOPIC: GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||
MODELING_REQUEST_TOPIC: MODELING_RESPONSE_TOPIC,
|
||||
}
|
||||
|
||||
# The retained LixelGO control connection issued these three SUBSCRIBE packets
|
||||
# in this exact order. Point-cloud subscriptions belonged to a separate client.
|
||||
CONTROL_SUBSCRIPTION_GROUPS: tuple[tuple[tuple[str, int], ...], ...] = (
|
||||
@@ -147,14 +170,60 @@ APPLICATION_REQUEST_ALLOWLIST = frozenset(
|
||||
class ApplicationMqttTransportError(RuntimeError):
|
||||
"""The reviewed control transport failed before a request was attempted."""
|
||||
|
||||
def __init__(self, message: str, *, reason_code: str = "transport_failure") -> None:
|
||||
super().__init__(message)
|
||||
self.reason_code = reason_code
|
||||
|
||||
|
||||
class ApplicationCommandOutcomeUnknown(RuntimeError):
|
||||
"""A request may have reached the K1 and must never be retried automatically."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
reason_code: str = "command_outcome_unknown",
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.reason_code = reason_code
|
||||
|
||||
|
||||
class ApplicationControlDeviceFault(RuntimeError):
|
||||
"""Live status made further automatic control inadmissible."""
|
||||
|
||||
def __init__(self, message: str, *, reason_code: str = "device_status_fault") -> None:
|
||||
super().__init__(message)
|
||||
self.reason_code = reason_code
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _ApplicationResponseExpectation:
|
||||
operation_key: str
|
||||
topic: str
|
||||
identity: ApplicationMessageIdentity
|
||||
modeling_action: ModelingAction | None = None
|
||||
|
||||
@property
|
||||
def correlation_key(self) -> tuple[str, str, ModelingAction | None]:
|
||||
return (self.topic, self.identity.session_id, self.modeling_action)
|
||||
|
||||
def matches(
|
||||
self,
|
||||
observed: ApplicationMessageIdentity,
|
||||
observed_modeling_action: ModelingAction | None,
|
||||
) -> bool:
|
||||
if self.modeling_action is not observed_modeling_action:
|
||||
return False
|
||||
if not hmac.compare_digest(self.identity.session_id, observed.session_id):
|
||||
return False
|
||||
if not hmac.compare_digest(self.identity.openapi_key, observed.openapi_key):
|
||||
return False
|
||||
expected_device_id = self.identity.vendor_device_id
|
||||
return expected_device_id is None or (
|
||||
observed.vendor_device_id is not None
|
||||
and hmac.compare_digest(expected_device_id, observed.vendor_device_id)
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationMqttTransportSnapshot:
|
||||
@@ -165,6 +234,7 @@ class ApplicationMqttTransportSnapshot:
|
||||
qos2_completions: int
|
||||
correlated_responses: int
|
||||
ignored_known_responses: int
|
||||
late_known_responses: int
|
||||
device_status_reports: int
|
||||
system_error_reports: int
|
||||
report_decode_errors: int
|
||||
@@ -189,6 +259,7 @@ class ApplicationMqttTransportSnapshot:
|
||||
"qos2_completions": self.qos2_completions,
|
||||
"correlated_responses": self.correlated_responses,
|
||||
"ignored_known_responses": self.ignored_known_responses,
|
||||
"late_known_responses": self.late_known_responses,
|
||||
"device_status_reports": self.device_status_reports,
|
||||
"system_error_reports": self.system_error_reports,
|
||||
"report_decode_errors": self.report_decode_errors,
|
||||
@@ -208,10 +279,10 @@ class ApplicationMqttTransportSnapshot:
|
||||
class ReviewedApplicationMqttTransport:
|
||||
"""Exact-profile MQTT exchange for an operator-present physical acceptance.
|
||||
|
||||
This type is deliberately not installed in the facade or plugin runtime.
|
||||
It performs one connection attempt, never reconnects, consumes every
|
||||
operation key before calling ``publish``, and permanently poisons itself
|
||||
after any unknown post-publish outcome.
|
||||
Plugin v0.5.0 installs this type only behind the operator-driven continuous
|
||||
session owner. It performs one connection attempt, never reconnects,
|
||||
consumes every operation key before calling ``publish``, and permanently
|
||||
poisons itself after any unknown post-publish outcome.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -252,12 +323,19 @@ class ReviewedApplicationMqttTransport:
|
||||
self._messages: deque[tuple[str, bytes]] = deque()
|
||||
self._callback_error: str | None = None
|
||||
self._consumed_operation_keys: set[str] = set()
|
||||
self._known_response_expectations: dict[str, _ApplicationResponseExpectation] = {}
|
||||
self._response_operations_by_correlation: dict[
|
||||
tuple[str, str, ModelingAction | None], list[str]
|
||||
] = {}
|
||||
self._optional_response_operations: set[str] = set()
|
||||
self._observed_response_operations: set[str] = set()
|
||||
self._connect_attempts = 0
|
||||
self._subscribe_attempts = 0
|
||||
self._publish_attempts = 0
|
||||
self._qos2_completions = 0
|
||||
self._correlated_responses = 0
|
||||
self._ignored_known_responses = 0
|
||||
self._late_known_responses = 0
|
||||
self._device_status_reports = 0
|
||||
self._system_error_reports = 0
|
||||
self._report_decode_errors = 0
|
||||
@@ -273,10 +351,14 @@ class ReviewedApplicationMqttTransport:
|
||||
def open(self) -> ApplicationMqttTransportSnapshot:
|
||||
with self._lock:
|
||||
if self._state != "new":
|
||||
raise ApplicationMqttTransportError("control transport can be opened only once")
|
||||
raise ApplicationMqttTransportError(
|
||||
"control transport can be opened only once",
|
||||
reason_code="transport_already_opened",
|
||||
)
|
||||
self._state = "connecting"
|
||||
self._connect_attempts = 1
|
||||
client = self._new_client()
|
||||
client.connect_timeout = self._connect_timeout_seconds
|
||||
self._install_callbacks(client)
|
||||
self._client = client
|
||||
try:
|
||||
@@ -300,10 +382,10 @@ class ReviewedApplicationMqttTransport:
|
||||
self,
|
||||
envelopes: Sequence[OneShotPublishEnvelope],
|
||||
*,
|
||||
required_response_topics: Collection[str],
|
||||
required_response_operation_keys: Collection[str],
|
||||
) -> dict[str, bytes]:
|
||||
batch = tuple(envelopes)
|
||||
required = frozenset(required_response_topics)
|
||||
required = frozenset(required_response_operation_keys)
|
||||
if not batch:
|
||||
raise ValueError("control exchange batch must not be empty")
|
||||
if not required and any(
|
||||
@@ -311,37 +393,100 @@ class ReviewedApplicationMqttTransport:
|
||||
for envelope in batch
|
||||
):
|
||||
raise ValueError("response-free exchange is limited to retained ModelingStatus reads")
|
||||
if not required <= APPLICATION_RESPONSE_TOPICS:
|
||||
raise ValueError("required response topics exceed the reviewed allowlist")
|
||||
operation_keys = tuple(envelope.operation_key for envelope in batch)
|
||||
if len(set(operation_keys)) != len(operation_keys):
|
||||
raise ValueError("control exchange batch repeats an operation key")
|
||||
if not required <= frozenset(operation_keys):
|
||||
raise ValueError("required responses exceed the issued operation keys")
|
||||
if any(envelope.topic not in APPLICATION_REQUEST_ALLOWLIST for envelope in batch):
|
||||
raise ValueError("control exchange batch exceeds the reviewed request allowlist")
|
||||
allowed_non_barrier_responses = (
|
||||
frozenset({MODELING_STATUS_RESPONSE_TOPIC})
|
||||
if any(
|
||||
envelope.topic == "lixel/application/request/modeling_status" for envelope in batch
|
||||
expectations = tuple(self._expectation_for_envelope(envelope) for envelope in batch)
|
||||
pending = {
|
||||
expectation.correlation_key: expectation
|
||||
for expectation in expectations
|
||||
if expectation.operation_key in required
|
||||
}
|
||||
if len(pending) != len(required):
|
||||
raise ValueError(
|
||||
"required responses do not identify one exact batch operation each"
|
||||
)
|
||||
else frozenset()
|
||||
)
|
||||
|
||||
# Classify packets already delivered by the manual Paho loop before
|
||||
# consuming this batch. An exact response to an earlier issued
|
||||
# operation is harmless; an unknown session/identity remains fatal.
|
||||
self._drain_responses({}, {})
|
||||
if self._consumed_operation_keys:
|
||||
# Service packets already readable on the retained socket before
|
||||
# admitting another operation. This is a zero-wait network pump,
|
||||
# not a timer or retry; it prevents a queued protocol deviation
|
||||
# from being hidden behind the next explicit operator action.
|
||||
self._service_once(post_publish=True)
|
||||
self._drain_responses({}, {})
|
||||
|
||||
with self._lock:
|
||||
if self._consumed_operation_keys.intersection(operation_keys):
|
||||
raise ApplicationCommandOutcomeUnknown(
|
||||
"control operation key was already consumed; retry is forbidden"
|
||||
"control operation key was already consumed; retry is forbidden",
|
||||
reason_code="operation_reuse_forbidden",
|
||||
)
|
||||
if self._state != "ready" or not self._connected or not self._subscribed:
|
||||
raise ApplicationMqttTransportError("control transport is not ready")
|
||||
stale_response = bool(self._messages)
|
||||
if stale_response:
|
||||
self._poison_locked("stale response preceded the request batch")
|
||||
raise ApplicationMqttTransportError(
|
||||
"control transport is not ready",
|
||||
reason_code="transport_not_ready",
|
||||
)
|
||||
for expectation in expectations:
|
||||
existing_operations = self._response_operations_by_correlation.get(
|
||||
expectation.correlation_key,
|
||||
[],
|
||||
)
|
||||
if any(
|
||||
not self._known_response_expectations[operation_key].matches(
|
||||
expectation.identity,
|
||||
expectation.modeling_action,
|
||||
)
|
||||
for operation_key in existing_operations
|
||||
):
|
||||
raise ApplicationCommandOutcomeUnknown(
|
||||
"control response identity changed for an issued session",
|
||||
reason_code="response_identity_changed",
|
||||
)
|
||||
if expectation.operation_key in required:
|
||||
# A response-free read owns its correlation only until the
|
||||
# next exact same-identity operation is admitted. We have
|
||||
# already drained callbacks and given the retained socket
|
||||
# one zero-wait service turn above, so any response already
|
||||
# available for the optional operation has been observed.
|
||||
# Once the newer required operation is issued, the first
|
||||
# response after that issuance belongs to the newer
|
||||
# operation; the unanswered optional read must not reserve
|
||||
# FIFO ownership and swallow it.
|
||||
superseded_optional = {
|
||||
operation_key
|
||||
for operation_key in existing_operations
|
||||
if operation_key in self._optional_response_operations
|
||||
and operation_key not in self._observed_response_operations
|
||||
}
|
||||
if superseded_optional:
|
||||
existing_operations[:] = [
|
||||
operation_key
|
||||
for operation_key in existing_operations
|
||||
if operation_key not in superseded_optional
|
||||
]
|
||||
self._optional_response_operations.difference_update(
|
||||
superseded_optional
|
||||
)
|
||||
for operation_key in superseded_optional:
|
||||
del self._known_response_expectations[operation_key]
|
||||
self._known_response_expectations[
|
||||
expectation.operation_key
|
||||
] = expectation
|
||||
self._response_operations_by_correlation.setdefault(
|
||||
expectation.correlation_key,
|
||||
[],
|
||||
).append(expectation.operation_key)
|
||||
if expectation.operation_key not in required:
|
||||
self._optional_response_operations.add(expectation.operation_key)
|
||||
self._consumed_operation_keys.update(operation_keys)
|
||||
if stale_response:
|
||||
self.close()
|
||||
raise ApplicationCommandOutcomeUnknown(
|
||||
"stale control response makes the next command outcome ambiguous"
|
||||
)
|
||||
|
||||
client = self._require_client()
|
||||
publish_mids: set[int] = set()
|
||||
@@ -367,12 +512,17 @@ class ReviewedApplicationMqttTransport:
|
||||
deadline = self._monotonic() + self._exchange_timeout_seconds
|
||||
|
||||
def complete() -> bool:
|
||||
self._drain_responses(required, allowed_non_barrier_responses, responses)
|
||||
self._drain_responses(pending, responses)
|
||||
with self._lock:
|
||||
return publish_mids <= self._completed_publish_mids and required <= responses.keys()
|
||||
|
||||
self._drive_until(complete, deadline, post_publish=True)
|
||||
self._drain_responses(required, allowed_non_barrier_responses, responses)
|
||||
# The completion predicate can become true immediately after the first
|
||||
# required response callback. Give an already-readable second packet
|
||||
# one zero-wait service turn so a duplicate application response fails
|
||||
# closed instead of leaking into the next operator checkpoint.
|
||||
self._service_once(post_publish=True)
|
||||
self._drain_responses(pending, responses)
|
||||
with self._lock:
|
||||
self._correlated_responses += len(responses)
|
||||
return responses
|
||||
@@ -422,9 +572,15 @@ class ReviewedApplicationMqttTransport:
|
||||
client = self._client
|
||||
if client is not None:
|
||||
try:
|
||||
if self._subscribed:
|
||||
with self._lock:
|
||||
accepted_group_count = self._subscription_group_index
|
||||
if accepted_group_count:
|
||||
client.unsubscribe(
|
||||
[topic for group in CONTROL_SUBSCRIPTION_GROUPS for topic, _qos in group]
|
||||
[
|
||||
topic
|
||||
for group in CONTROL_SUBSCRIPTION_GROUPS[:accepted_group_count]
|
||||
for topic, _qos in group
|
||||
]
|
||||
)
|
||||
client.disconnect()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
@@ -483,6 +639,7 @@ class ReviewedApplicationMqttTransport:
|
||||
qos2_completions=self._qos2_completions,
|
||||
correlated_responses=self._correlated_responses,
|
||||
ignored_known_responses=self._ignored_known_responses,
|
||||
late_known_responses=self._late_known_responses,
|
||||
device_status_reports=self._device_status_reports,
|
||||
system_error_reports=self._system_error_reports,
|
||||
report_decode_errors=self._report_decode_errors,
|
||||
@@ -725,48 +882,144 @@ class ReviewedApplicationMqttTransport:
|
||||
self._fail_after_publish("control MQTT network loop returned an error")
|
||||
self._fail_before_publish("control MQTT network loop returned an error")
|
||||
|
||||
def _service_once(self, *, post_publish: bool) -> None:
|
||||
with self._lock:
|
||||
callback_error = self._callback_error
|
||||
if callback_error is not None:
|
||||
if post_publish:
|
||||
self._fail_after_publish(callback_error)
|
||||
self._fail_before_publish(callback_error)
|
||||
client = self._require_client()
|
||||
try:
|
||||
result = client.loop(timeout=0.0)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
if post_publish:
|
||||
self._fail_after_publish("control MQTT network loop failed", exc)
|
||||
self._fail_before_publish("control MQTT network loop failed", exc)
|
||||
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||
if post_publish:
|
||||
self._fail_after_publish("control MQTT network loop returned an error")
|
||||
self._fail_before_publish("control MQTT network loop returned an error")
|
||||
|
||||
def _drain_responses(
|
||||
self,
|
||||
required: frozenset[str],
|
||||
allowed_non_barrier: frozenset[str],
|
||||
pending: dict[
|
||||
tuple[str, str, ModelingAction | None],
|
||||
_ApplicationResponseExpectation,
|
||||
],
|
||||
responses: dict[str, bytes],
|
||||
) -> None:
|
||||
ambiguous_message: str | None = None
|
||||
ambiguous_reason_code: str | None = None
|
||||
with self._lock:
|
||||
while self._messages:
|
||||
topic, payload = self._messages.popleft()
|
||||
if topic not in required:
|
||||
if topic in allowed_non_barrier:
|
||||
self._ignored_known_responses += 1
|
||||
continue
|
||||
self._poison_locked("unexpected response made correlation ambiguous")
|
||||
ambiguous_message = "unexpected control response made command outcome ambiguous"
|
||||
try:
|
||||
observed = decode_application_message_identity(payload)
|
||||
except ApplicationBootstrapError:
|
||||
self._poison_locked("response identity decoding failed")
|
||||
ambiguous_message = (
|
||||
"control response identity could not be decoded safely"
|
||||
)
|
||||
ambiguous_reason_code = "response_identity_decode_failed"
|
||||
break
|
||||
if topic in responses:
|
||||
self._poison_locked("duplicate required response made correlation ambiguous")
|
||||
ambiguous_message = "duplicate control response made command outcome ambiguous"
|
||||
break
|
||||
responses[topic] = payload
|
||||
if ambiguous_message is not None:
|
||||
self.close()
|
||||
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
|
||||
|
||||
def _discard_allowed_responses(self, allowed: frozenset[str]) -> None:
|
||||
ambiguous_message: str | None = None
|
||||
with self._lock:
|
||||
while self._messages:
|
||||
topic, _payload = self._messages.popleft()
|
||||
if topic in allowed:
|
||||
self._ignored_known_responses += 1
|
||||
continue
|
||||
self._poison_locked("unexpected response during retained control hold")
|
||||
ambiguous_message = (
|
||||
"unexpected control response during retained control-session hold"
|
||||
observed_modeling_action: ModelingAction | None = None
|
||||
if topic == MODELING_RESPONSE_TOPIC:
|
||||
try:
|
||||
observed_modeling_action = decode_modeling_response(
|
||||
payload
|
||||
).action
|
||||
except ModelingProtocolError:
|
||||
self._poison_locked("modeling response action decoding failed")
|
||||
ambiguous_message = (
|
||||
"control modeling response could not be decoded safely"
|
||||
)
|
||||
ambiguous_reason_code = "modeling_response_decode_failed"
|
||||
break
|
||||
key = (topic, observed.session_id, observed_modeling_action)
|
||||
expectation = pending.get(key)
|
||||
known_operations = self._response_operations_by_correlation.get(key, [])
|
||||
matching_operations = [
|
||||
operation_key
|
||||
for operation_key in known_operations
|
||||
if self._known_response_expectations[operation_key].matches(
|
||||
observed,
|
||||
observed_modeling_action,
|
||||
)
|
||||
]
|
||||
next_unobserved = next(
|
||||
(
|
||||
operation_key
|
||||
for operation_key in matching_operations
|
||||
if operation_key not in self._observed_response_operations
|
||||
),
|
||||
None,
|
||||
)
|
||||
if next_unobserved is not None:
|
||||
self._observed_response_operations.add(next_unobserved)
|
||||
if (
|
||||
expectation is not None
|
||||
and expectation.operation_key == next_unobserved
|
||||
):
|
||||
responses[next_unobserved] = payload
|
||||
continue
|
||||
self._ignored_known_responses += 1
|
||||
self._late_known_responses += 1
|
||||
continue
|
||||
if matching_operations:
|
||||
self._poison_locked("duplicate application response")
|
||||
ambiguous_message = (
|
||||
"duplicate application response made command outcome ambiguous"
|
||||
)
|
||||
ambiguous_reason_code = "duplicate_application_response"
|
||||
break
|
||||
self._poison_locked("unexpected response identity made correlation ambiguous")
|
||||
ambiguous_message = (
|
||||
"unexpected control response session or identity made command outcome ambiguous"
|
||||
)
|
||||
ambiguous_reason_code = "unexpected_response_identity"
|
||||
if expectation is not None or known_operations:
|
||||
ambiguous_message = (
|
||||
"control response identity mismatch made command outcome ambiguous"
|
||||
)
|
||||
ambiguous_reason_code = "response_identity_mismatch"
|
||||
break
|
||||
if ambiguous_message is not None:
|
||||
self.close()
|
||||
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
|
||||
raise ApplicationCommandOutcomeUnknown(
|
||||
ambiguous_message,
|
||||
reason_code=ambiguous_reason_code or "response_correlation_failed",
|
||||
)
|
||||
|
||||
def _discard_allowed_responses(self, allowed: frozenset[str]) -> None:
|
||||
del allowed
|
||||
self._drain_responses({}, {})
|
||||
|
||||
@staticmethod
|
||||
def _expectation_for_envelope(
|
||||
envelope: OneShotPublishEnvelope,
|
||||
) -> _ApplicationResponseExpectation:
|
||||
response_topic = APPLICATION_RESPONSE_TOPIC_BY_REQUEST_TOPIC.get(envelope.topic)
|
||||
if response_topic is None:
|
||||
raise ValueError("control request has no reviewed response topic")
|
||||
try:
|
||||
identity = decode_application_message_identity(envelope.payload)
|
||||
except ApplicationBootstrapError as exc:
|
||||
raise ValueError("control request identity is not decodable") from exc
|
||||
modeling_action: ModelingAction | None = None
|
||||
if response_topic == MODELING_RESPONSE_TOPIC:
|
||||
if envelope.operation_key == "modeling:start":
|
||||
modeling_action = ModelingAction.START
|
||||
elif envelope.operation_key == "modeling:stop":
|
||||
modeling_action = ModelingAction.STOP
|
||||
else:
|
||||
raise ValueError("modeling request operation key has no exact action")
|
||||
return _ApplicationResponseExpectation(
|
||||
operation_key=envelope.operation_key,
|
||||
topic=response_topic,
|
||||
identity=identity,
|
||||
modeling_action=modeling_action,
|
||||
)
|
||||
|
||||
def _set_callback_error(self, message: str) -> None:
|
||||
with self._lock:
|
||||
@@ -777,7 +1030,10 @@ class ReviewedApplicationMqttTransport:
|
||||
with self._lock:
|
||||
self._state = "failed"
|
||||
self.close()
|
||||
error = ApplicationMqttTransportError(message)
|
||||
error = ApplicationMqttTransportError(
|
||||
message,
|
||||
reason_code=self._transport_failure_reason_code(message),
|
||||
)
|
||||
if cause is not None:
|
||||
raise error from cause
|
||||
raise error
|
||||
@@ -787,7 +1043,8 @@ class ReviewedApplicationMqttTransport:
|
||||
self._poison_locked(message)
|
||||
self.close()
|
||||
error = ApplicationCommandOutcomeUnknown(
|
||||
f"{message}; automatic retry is forbidden until physical/status reconciliation"
|
||||
f"{message}; automatic retry is forbidden until physical/status reconciliation",
|
||||
reason_code=self._transport_failure_reason_code(message),
|
||||
)
|
||||
if cause is not None:
|
||||
raise error from cause
|
||||
@@ -799,5 +1056,33 @@ class ReviewedApplicationMqttTransport:
|
||||
def _require_client(self) -> mqtt.Client:
|
||||
client = self._client
|
||||
if client is None:
|
||||
raise ApplicationMqttTransportError("control MQTT client is not installed")
|
||||
raise ApplicationMqttTransportError(
|
||||
"control MQTT client is not installed",
|
||||
reason_code="mqtt_client_unavailable",
|
||||
)
|
||||
return client
|
||||
|
||||
@staticmethod
|
||||
def _transport_failure_reason_code(message: str) -> str:
|
||||
reason_fragments = (
|
||||
("connect call failed", "mqtt_connect_call_failed"),
|
||||
("connect call was rejected", "mqtt_connect_rejected"),
|
||||
("broker rejected connection", "mqtt_broker_rejected_connection"),
|
||||
("connection/subscription timed out", "mqtt_connection_timeout"),
|
||||
("response subscription", "mqtt_subscription_failed"),
|
||||
("SUBACK", "mqtt_subscription_protocol_error"),
|
||||
("publish call failed", "mqtt_publish_call_failed"),
|
||||
("publish call returned", "mqtt_publish_result_unsafe"),
|
||||
("reused a packet identifier", "mqtt_packet_identifier_reused"),
|
||||
("QoS2 transaction failed", "mqtt_qos2_failed"),
|
||||
("response barrier timed out", "mqtt_response_timeout"),
|
||||
("network loop failed", "mqtt_network_loop_failed"),
|
||||
("network loop returned", "mqtt_network_loop_failed"),
|
||||
("connection ended unexpectedly", "mqtt_connection_ended"),
|
||||
("unreviewed subscribed topic", "mqtt_unreviewed_topic"),
|
||||
("response exceeds", "mqtt_response_too_large"),
|
||||
)
|
||||
return next(
|
||||
(code for fragment, code in reason_fragments if fragment in message),
|
||||
"mqtt_transport_failure",
|
||||
)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
@@ -57,6 +58,8 @@ ApplicationControlPhase = Literal[
|
||||
"failed",
|
||||
]
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
TransportFactory = Callable[[str], ReviewedApplicationMqttTransport]
|
||||
|
||||
@@ -108,7 +111,6 @@ class InteractiveApplicationControlSession:
|
||||
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
|
||||
@@ -117,6 +119,7 @@ class InteractiveApplicationControlSession:
|
||||
self._dialogue_snapshot: dict[str, object] | None = None
|
||||
self._transport_snapshot: dict[str, object] | None = None
|
||||
self._outcome_unknown = False
|
||||
self._run_generation = 0
|
||||
|
||||
def open(
|
||||
self,
|
||||
@@ -128,21 +131,28 @@ class InteractiveApplicationControlSession:
|
||||
# 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
|
||||
):
|
||||
if self._phase != "idle" and self._can_open_locked():
|
||||
self._reset_locked()
|
||||
if self._phase != "idle":
|
||||
raise ApplicationAcceptanceError(
|
||||
"application control session is already open or requires manual recovery"
|
||||
)
|
||||
# A previous worker may already have published a terminal phase but
|
||||
# still own its local MQTT socket while its ``finally`` block runs.
|
||||
# Never create the next network owner until that thread has exited
|
||||
# and cleared the closed transport.
|
||||
if not self._worker_retired_locked():
|
||||
raise ApplicationAcceptanceError(
|
||||
"previous application control worker is still retiring"
|
||||
)
|
||||
self._host = host
|
||||
self._timezone_name = timezone_name
|
||||
self._set_phase_locked("connecting")
|
||||
self._run_generation += 1
|
||||
generation = self._run_generation
|
||||
self._thread = threading.Thread(
|
||||
target=self._run,
|
||||
args=(generation,),
|
||||
name="xgrids-k1-canonical-control",
|
||||
daemon=True,
|
||||
)
|
||||
@@ -189,16 +199,6 @@ class InteractiveApplicationControlSession:
|
||||
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 {
|
||||
@@ -234,33 +234,27 @@ class InteractiveApplicationControlSession:
|
||||
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
|
||||
),
|
||||
"control_socket_open": phase not in {"idle", "completed", "closed", "failed"},
|
||||
"can_open": self._can_open_locked(),
|
||||
"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(),
|
||||
# Retained for wire compatibility with the v1alpha2 snapshot.
|
||||
# Standby is now concluded solely from live K1 protocol state.
|
||||
"can_confirm_standby": False,
|
||||
"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
|
||||
dict(self._dialogue_snapshot) if self._dialogue_snapshot is not None else None
|
||||
),
|
||||
"transport": transport_snapshot,
|
||||
}
|
||||
|
||||
def _run(self) -> None:
|
||||
def _run(self, generation: int) -> None:
|
||||
executor: PhysicalAcceptanceDialogueExecutor | None = None
|
||||
transport: ReviewedApplicationMqttTransport | None = None
|
||||
try:
|
||||
@@ -319,64 +313,195 @@ class InteractiveApplicationControlSession:
|
||||
|
||||
executor.maintain_active_until_stop_requested(self._stop_requested.is_set)
|
||||
stop_confirmation = self._stop_request()
|
||||
stop_permit = PhysicalAcceptancePermit(
|
||||
stop_confirmation.checklist(ModelingAction.STOP)
|
||||
)
|
||||
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
|
||||
)
|
||||
executor.maintain_post_stop_until_standby()
|
||||
self._set_phase("completed")
|
||||
except Exception as exc:
|
||||
dialogue_snapshot, dialogue_snapshot_available = self._executor_snapshot_safely(
|
||||
executor
|
||||
)
|
||||
transport_snapshot, transport_snapshot_available = self._transport_snapshot_safely(
|
||||
transport
|
||||
)
|
||||
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,
|
||||
failed_phase = self._phase
|
||||
transport_created = transport is not None
|
||||
dialogue_executor_created = executor is not None
|
||||
publish_attempts = (
|
||||
self._json_int_or_none(transport_snapshot.get("publish_attempts"))
|
||||
if transport_snapshot_available
|
||||
else None
|
||||
)
|
||||
dialogue_stage = self._json_string(dialogue_snapshot.get("dialogue_stage"))
|
||||
correlation_failure = dialogue_snapshot.get("correlation_failure")
|
||||
compatibility_failure = dialogue_snapshot.get("compatibility_failure")
|
||||
failure_reason_code = self._failure_reason_code(
|
||||
exc,
|
||||
correlation_failure=correlation_failure,
|
||||
)
|
||||
modeling_command_attempted: bool | None = (
|
||||
(
|
||||
dialogue_snapshot.get("start_attempted") is True
|
||||
or dialogue_snapshot.get("stop_attempted") is True
|
||||
)
|
||||
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",
|
||||
}
|
||||
if dialogue_snapshot_available
|
||||
else (False if not dialogue_executor_created else None)
|
||||
)
|
||||
diagnostic_snapshot_unavailable = [
|
||||
component
|
||||
for component, created, available in (
|
||||
(
|
||||
"transport",
|
||||
transport_created,
|
||||
transport_snapshot_available,
|
||||
),
|
||||
(
|
||||
"dialogue",
|
||||
dialogue_executor_created,
|
||||
dialogue_snapshot_available,
|
||||
),
|
||||
)
|
||||
if created and not available
|
||||
]
|
||||
diagnostic_evidence_unavailable = [
|
||||
evidence
|
||||
for evidence, unavailable in (
|
||||
(
|
||||
"transport.publish_attempts",
|
||||
transport_created and publish_attempts is None,
|
||||
),
|
||||
(
|
||||
"dialogue.modeling_command_attempted",
|
||||
dialogue_executor_created and modeling_command_attempted is None,
|
||||
),
|
||||
)
|
||||
if unavailable
|
||||
]
|
||||
outcome_unknown = (
|
||||
isinstance(exc, ApplicationCommandOutcomeUnknown)
|
||||
or modeling_command_attempted is True
|
||||
or bool(diagnostic_evidence_unavailable)
|
||||
or failed_phase
|
||||
in {
|
||||
"scanning",
|
||||
"stop-requested",
|
||||
"stopping",
|
||||
"awaiting-standby-confirmation",
|
||||
}
|
||||
)
|
||||
# A correlated ordinal-1 DeviceInfo profile mismatch is a
|
||||
# completed read-only exchange. It cannot have started or
|
||||
# stopped modeling, so a later explicit operator click may
|
||||
# open a fresh dialogue after the software/profile issue is
|
||||
# corrected. No other post-publish failure is promoted.
|
||||
correlated_read_only_profile_mismatch = (
|
||||
failure_reason_code == "compatibility_profile_mismatch"
|
||||
and modeling_command_attempted is False
|
||||
and publish_attempts == 1
|
||||
and self._json_int_or_none(
|
||||
transport_snapshot.get("correlated_responses")
|
||||
)
|
||||
== 1
|
||||
)
|
||||
safe_to_retry = not outcome_unknown and (
|
||||
not transport_created
|
||||
or (transport_snapshot_available and publish_attempts == 0)
|
||||
or correlated_read_only_profile_mismatch
|
||||
)
|
||||
self._failure = {
|
||||
"code": type(exc).__name__,
|
||||
"reason_code": failure_reason_code,
|
||||
"message": str(exc),
|
||||
"safe_to_retry": publish_attempts == 0 and not outcome_unknown,
|
||||
"failed_phase": failed_phase,
|
||||
"dialogue_stage": dialogue_stage,
|
||||
"transport_state": self._json_string(transport_snapshot.get("state")),
|
||||
"publish_attempts": publish_attempts,
|
||||
"qos2_completions": self._json_int_or_none(
|
||||
transport_snapshot.get("qos2_completions")
|
||||
),
|
||||
"correlated_responses": self._json_int_or_none(
|
||||
transport_snapshot.get("correlated_responses")
|
||||
),
|
||||
"ignored_known_responses": self._json_int_or_none(
|
||||
transport_snapshot.get("ignored_known_responses")
|
||||
),
|
||||
"late_known_responses": self._json_int_or_none(
|
||||
transport_snapshot.get("late_known_responses")
|
||||
),
|
||||
"modeling_command_attempted": modeling_command_attempted,
|
||||
"diagnostic_snapshot_unavailable": (diagnostic_snapshot_unavailable),
|
||||
"diagnostic_evidence_unavailable": (diagnostic_evidence_unavailable),
|
||||
"correlation_failure": (
|
||||
dict(correlation_failure)
|
||||
if isinstance(correlation_failure, dict)
|
||||
else None
|
||||
),
|
||||
"compatibility_failure": (
|
||||
dict(compatibility_failure)
|
||||
if isinstance(compatibility_failure, dict)
|
||||
else None
|
||||
),
|
||||
"safe_to_retry": safe_to_retry,
|
||||
}
|
||||
self._outcome_unknown = outcome_unknown
|
||||
self._dialogue_snapshot = dialogue_snapshot or None
|
||||
self._transport_snapshot = transport_snapshot or None
|
||||
self._set_phase_locked("failed")
|
||||
logger.error(
|
||||
"K1 application control session failed: code=%s reason_code=%s "
|
||||
"phase=%s dialogue_stage=%s transport_state=%s "
|
||||
"publish_attempts=%s modeling_command_attempted=%s "
|
||||
"diagnostic_snapshot_unavailable=%s "
|
||||
"diagnostic_evidence_unavailable=%s outcome_unknown=%s "
|
||||
"safe_to_retry=%s",
|
||||
type(exc).__name__,
|
||||
failure_reason_code,
|
||||
failed_phase,
|
||||
dialogue_stage,
|
||||
self._json_string(transport_snapshot.get("state")),
|
||||
publish_attempts,
|
||||
modeling_command_attempted,
|
||||
diagnostic_snapshot_unavailable,
|
||||
diagnostic_evidence_unavailable,
|
||||
outcome_unknown,
|
||||
safe_to_retry,
|
||||
)
|
||||
finally:
|
||||
final_dialogue_snapshot: dict[str, object] | None = None
|
||||
final_transport_snapshot: dict[str, object] | None = None
|
||||
if executor is not None:
|
||||
with self._lock:
|
||||
self._dialogue_snapshot = executor.snapshot()
|
||||
captured_dialogue, dialogue_available = self._executor_snapshot_safely(executor)
|
||||
if dialogue_available:
|
||||
final_dialogue_snapshot = captured_dialogue
|
||||
if transport is not None:
|
||||
try:
|
||||
with self._lock:
|
||||
self._transport_snapshot = transport.snapshot().as_dict()
|
||||
captured_transport, transport_available = self._transport_snapshot_safely(
|
||||
transport
|
||||
)
|
||||
if transport_available:
|
||||
final_transport_snapshot = captured_transport
|
||||
finally:
|
||||
transport.close()
|
||||
with self._lock:
|
||||
self._transport = None
|
||||
# The generation guard remains defense in depth for shutdown
|
||||
# races, although open() also requires the prior worker to be
|
||||
# fully retired before a new generation can be created.
|
||||
if generation == self._run_generation:
|
||||
if final_dialogue_snapshot is not None:
|
||||
self._dialogue_snapshot = final_dialogue_snapshot
|
||||
if final_transport_snapshot is not None:
|
||||
self._transport_snapshot = final_transport_snapshot
|
||||
if self._transport is transport:
|
||||
self._transport = None
|
||||
|
||||
@staticmethod
|
||||
def _start_command(
|
||||
@@ -427,6 +552,62 @@ class InteractiveApplicationControlSession:
|
||||
with self._lock:
|
||||
self._set_phase_locked(phase)
|
||||
|
||||
@staticmethod
|
||||
def _executor_snapshot_safely(
|
||||
executor: PhysicalAcceptanceDialogueExecutor | None,
|
||||
) -> tuple[dict[str, object], bool]:
|
||||
if executor is None:
|
||||
return {}, False
|
||||
try:
|
||||
return dict(executor.snapshot()), True
|
||||
except Exception:
|
||||
logger.error("K1 application dialogue snapshot failed")
|
||||
return {}, False
|
||||
|
||||
@staticmethod
|
||||
def _transport_snapshot_safely(
|
||||
transport: ReviewedApplicationMqttTransport | None,
|
||||
) -> tuple[dict[str, object], bool]:
|
||||
if transport is None:
|
||||
return {}, False
|
||||
try:
|
||||
return dict(transport.snapshot().as_dict()), True
|
||||
except Exception:
|
||||
logger.error("K1 application transport snapshot failed")
|
||||
return {}, False
|
||||
|
||||
@staticmethod
|
||||
def _json_int_or_none(value: object) -> int | None:
|
||||
return value if isinstance(value, int) and not isinstance(value, bool) else None
|
||||
|
||||
@staticmethod
|
||||
def _json_string(value: object) -> str | None:
|
||||
return value if isinstance(value, str) else None
|
||||
|
||||
@staticmethod
|
||||
def _failure_reason_code(
|
||||
exc: Exception,
|
||||
*,
|
||||
correlation_failure: object,
|
||||
) -> str:
|
||||
explicit = getattr(exc, "reason_code", None)
|
||||
if isinstance(explicit, str) and explicit:
|
||||
return explicit
|
||||
if isinstance(correlation_failure, dict):
|
||||
correlated = correlation_failure.get("reason_code")
|
||||
if isinstance(correlated, str) and correlated:
|
||||
return correlated
|
||||
exception_name = type(exc).__name__
|
||||
if "Authority" in exception_name or "Keychain" in exception_name:
|
||||
return "application_authority_unavailable"
|
||||
if isinstance(exc, TimeoutError):
|
||||
return "control_checkpoint_timeout"
|
||||
if isinstance(exc, ApplicationAcceptanceError):
|
||||
return "application_acceptance_failed"
|
||||
if isinstance(exc, RuntimeError):
|
||||
return "unexpected_runtime_error"
|
||||
return "internal_control_error"
|
||||
|
||||
def _set_phase_locked(self, phase: ApplicationControlPhase) -> None:
|
||||
self._phase = phase
|
||||
|
||||
@@ -436,18 +617,17 @@ class InteractiveApplicationControlSession:
|
||||
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()
|
||||
snapshot, available = self._transport_snapshot_safely(self._transport)
|
||||
if available:
|
||||
return snapshot
|
||||
return {
|
||||
"state": "snapshot-unavailable",
|
||||
"diagnostic_snapshot_available": False,
|
||||
"automatic_retry": False,
|
||||
"automatic_reconnect": False,
|
||||
}
|
||||
return (
|
||||
dict(self._transport_snapshot)
|
||||
if self._transport_snapshot is not None
|
||||
@@ -464,9 +644,20 @@ class InteractiveApplicationControlSession:
|
||||
"workspace-ready": "prepare-project",
|
||||
"project-ready": "start",
|
||||
"scanning": "stop",
|
||||
"awaiting-standby-confirmation": "confirm-steady-green",
|
||||
}.get(self._phase)
|
||||
|
||||
def _can_open_locked(self) -> bool:
|
||||
reopenable_phase = self._phase in {"idle", "completed", "closed"} or (
|
||||
self._phase == "failed"
|
||||
and self._failure is not None
|
||||
and self._failure.get("safe_to_retry") is True
|
||||
)
|
||||
return reopenable_phase and self._worker_retired_locked()
|
||||
|
||||
def _worker_retired_locked(self) -> bool:
|
||||
thread = self._thread
|
||||
return (thread is None or not thread.is_alive()) and self._transport is None
|
||||
|
||||
def _reset_locked(self) -> None:
|
||||
self._phase = "idle"
|
||||
self._host = None
|
||||
@@ -477,7 +668,6 @@ class InteractiveApplicationControlSession:
|
||||
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
|
||||
|
||||
@@ -6,6 +6,7 @@ from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
REVIEWED_PROFILE_ATTESTATION_FAILURE,
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
)
|
||||
@@ -217,9 +218,7 @@ class LiveModelingControlSafety:
|
||||
"DeviceInfo binding does not match the live K1 status stream"
|
||||
)
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise ModelingControlSafetyError(
|
||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||
)
|
||||
raise ModelingControlSafetyError(REVIEWED_PROFILE_ATTESTATION_FAILURE)
|
||||
if report.session_state is not required_state:
|
||||
raise ModelingControlSafetyError(
|
||||
f"live K1 must be {required_state.name.casefold()} for this shadow plan"
|
||||
|
||||
@@ -38,6 +38,13 @@ def decode_zigzag64(value: int) -> int:
|
||||
return (value >> 1) ^ -(value & 1)
|
||||
|
||||
|
||||
def encode_zigzag64(value: int) -> int:
|
||||
"""Encode a signed 64-bit integer as protobuf sint64 ZigZag."""
|
||||
if value < -(1 << 63) or value > (1 << 63) - 1:
|
||||
raise ProtobufWireError("ZigZag input is outside int64")
|
||||
return ((value << 1) ^ (value >> 63)) & 0xFFFFFFFFFFFFFFFF
|
||||
|
||||
|
||||
def iter_fields(data: bytes, *, max_fields: int = 1_000_000) -> Iterator[ProtoField]:
|
||||
"""Iterate supported protobuf fields without recursion or unbounded allocation."""
|
||||
if max_fields < 1:
|
||||
|
||||
Reference in New Issue
Block a user