feat(k1): complete canonical control lifecycle

This commit is contained in:
DCCONSTRUCTIONS
2026-07-19 01:07:02 +03:00
parent d7a2c22faf
commit ffffee1879
42 changed files with 3577 additions and 556 deletions
+58 -6
View File
@@ -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(
+1 -1
View File
@@ -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 "
+173 -37
View File
@@ -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: