fix(k1): gate control dialogue on live state

This commit is contained in:
DCCONSTRUCTIONS
2026-07-18 17:19:21 +03:00
parent a29b38a0d7
commit 36f0c93d2a
14 changed files with 1435 additions and 122 deletions
@@ -5,13 +5,18 @@ import math
import threading
import time
from collections.abc import Callable, Collection, Sequence
from dataclasses import dataclass
from dataclasses import dataclass, field
from typing import Literal, Protocol
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
MODELING_STATUS_RESPONSE_TOPIC,
ApplicationBootstrapError,
ApplicationControlAuthority,
CanonicalPostStartObservation,
LiveDeviceControlBinding,
ShadowApplicationBootstrapOrchestrator,
correlate_application_response,
decode_and_bind_device_info_response,
)
from k1link.device_plugins.xgrids_k1.protocol.application_mqtt import (
MODELING_RESPONSE_TOPIC,
@@ -32,6 +37,9 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
MIN_ACCEPTANCE_PERMIT_SECONDS = 15.0
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
class ApplicationAcceptanceError(RuntimeError):
"""The operator-present physical acceptance contract failed closed."""
@@ -45,6 +53,21 @@ class ApplicationBatchExchange(Protocol):
required_response_topics: Collection[str],
) -> dict[str, bytes]: ...
def maintain_open_for(
self,
duration_seconds: float,
*,
allowed_response_topics: Collection[str] = (),
) -> None: ...
def scan_initialization_complete(self, binding: LiveDeviceControlBinding) -> bool: ...
def pre_start_ready(self, binding: LiveDeviceControlBinding) -> bool: ...
def standby_complete(self, binding: LiveDeviceControlBinding) -> bool: ...
def validate_bound_status(self, binding: LiveDeviceControlBinding) -> None: ...
@dataclass(frozen=True, slots=True)
class PhysicalAcceptanceChecklist:
@@ -71,6 +94,27 @@ class PhysicalAcceptanceChecklist:
raise ApplicationAcceptanceError("acceptance action must be an explicit ModelingAction")
@dataclass(frozen=True, slots=True)
class OperatorDialogueCheckpoint:
"""One explicit LixelGO-equivalent UI transition; never a wall-clock gate."""
event: Literal["workspace-entered", "project-prompt-opened", "start-confirmed"]
operator_initiated: Literal[True]
owner_token: object = field(repr=False, compare=False)
def __post_init__(self) -> None:
if self.event not in {
"workspace-entered",
"project-prompt-opened",
"start-confirmed",
}:
raise ApplicationAcceptanceError("operator dialogue checkpoint is unknown")
if self.operator_initiated is not True:
raise ApplicationAcceptanceError(
"operator dialogue checkpoint must be explicitly initiated"
)
class PhysicalAcceptancePermit:
"""Short, single-action capability that is consumed before MQTT publish."""
@@ -122,17 +166,37 @@ class PhysicalAcceptancePermit:
class PhysicalAcceptanceDialogueExecutor:
"""Drive the recovered barriers and exactly one permitted START or STOP."""
"""Drive only explicitly staged portions of the recovered K1 dialogue.
The legacy implementation ran all ten pre-START reads/mutations in one
tight loop. That preserved topic order but did not preserve the observed
LixelGO lifecycle. The staged methods below intentionally expose the
connection, scan-workspace and project-prompt UI boundaries. They do not
replay wall-clock gaps from the operator capture. The canonical START/STOP
methods keep the same transport owner from operation 1 through the
operator-confirmed post-STOP standby boundary. The generic one-command
entry point remains disabled so START and STOP cannot be emitted by two
unrelated one-shot processes.
"""
def __init__(
self,
transport: ApplicationBatchExchange,
permit: PhysicalAcceptancePermit,
) -> None:
self._transport = transport
self._permit = permit
self._bootstrap_complete = False
self._command_complete = False
self._dialogue_stage = "new"
self._start_complete = False
self._stop_attempted = False
self._stop_complete = False
self._active_authority: ApplicationControlAuthority | None = None
self._active_binding: LiveDeviceControlBinding | None = None
self._prepared_binding: LiveDeviceControlBinding | None = None
self._checkpoint_owner = object()
self._issued_checkpoint: str | None = None
self._start_permit_snapshot: dict[str, object] | None = None
self._stop_permit_snapshot: dict[str, object] | None = None
self._response_evidence: list[dict[str, object]] = []
self._correlation_failure: dict[str, object] | None = None
@@ -140,60 +204,138 @@ class PhysicalAcceptanceDialogueExecutor:
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
) -> LiveDeviceControlBinding:
if self._bootstrap_complete or self._command_complete:
raise ApplicationAcceptanceError("physical acceptance bootstrap was already attempted")
if self._permit.action is not ModelingAction.START:
raise ApplicationAcceptanceError("bootstrap is admitted only by a START permit")
del orchestrator
raise ApplicationAcceptanceError(
"collapsed bootstrap is disabled; use the canonical staged dialogue"
)
while not orchestrator.snapshot().bootstrap_complete:
batch = orchestrator.next_batch()
required_topics = {
request.response_topic 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,
)
for request in batch:
if request.response_required:
payload = responses[request.response_topic]
self._record_response_evidence(
phase="bootstrap",
operation_key=(
f"bootstrap:{request.ordinal}:{request.message_type}"
),
response_topic=request.response_topic,
payload=payload,
)
try:
orchestrator.accept_response(request.response_topic, payload)
except ApplicationBootstrapError as exc:
self._record_correlation_failure(
phase="bootstrap",
operation_key=(
f"bootstrap:{request.ordinal}:{request.message_type}"
),
response_topic=request.response_topic,
reason=str(exc),
)
raise ApplicationAcceptanceError(
"bootstrap response correlation failed for "
f"ordinal {request.ordinal} ({request.message_type}): {exc}"
) from exc
def run_connection_stage(
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
) -> LiveDeviceControlBinding:
"""Emit retained ordinals 1-6 at control-session establishment."""
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):
self._exchange_bootstrap_batch(orchestrator, expected_batch=expected_batch)
binding = orchestrator.binding
if binding is None:
raise ApplicationAcceptanceError("bootstrap completed without a live device binding")
raise ApplicationAcceptanceError("connection stage produced no live device binding")
self._prepared_binding = binding
self._dialogue_stage = "connection-ready"
return binding
def wait_for_operator_checkpoint(
self,
event: Literal["workspace-entered", "project-prompt-opened", "start-confirmed"],
event_observed: Callable[[], bool],
) -> OperatorDialogueCheckpoint:
"""Service the original socket until one exact operator UI event occurs."""
expected = {
"connection-ready": "workspace-entered",
"workspace-ready": "project-prompt-opened",
"project-ready": "start-confirmed",
}.get(self._dialogue_stage)
if event != expected or self._issued_checkpoint is not None:
raise ApplicationAcceptanceError(
"operator checkpoint does not match the canonical dialogue stage"
)
binding = self._prepared_binding
if binding is None:
raise ApplicationAcceptanceError("canonical preparation binding is unavailable")
while not (
self._transport.pre_start_ready(binding) and event_observed()
):
self._transport.maintain_open_for(
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
)
self._transport.validate_bound_status(binding)
self._issued_checkpoint = event
return OperatorDialogueCheckpoint(
event=event,
operator_initiated=True,
owner_token=self._checkpoint_owner,
)
def run_workspace_entry_stage(
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
checkpoint: OperatorDialogueCheckpoint,
) -> LiveDeviceControlBinding:
"""Emit ordinal 7 only for the observed scan-workspace entry action."""
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)
binding = orchestrator.binding
if binding is None:
raise ApplicationAcceptanceError("workspace entry lost the live device binding")
self._dialogue_stage = "workspace-ready"
return binding
def run_project_prompt_stage(
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
checkpoint: OperatorDialogueCheckpoint,
) -> LiveDeviceControlBinding:
"""Emit ordinals 8-10 when the operator opens the project-name prompt."""
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)
if not orchestrator.snapshot().bootstrap_complete:
raise ApplicationAcceptanceError("project prompt did not complete the transcript")
binding = orchestrator.binding
if binding is None:
raise ApplicationAcceptanceError("project prompt lost the live device binding")
self._bootstrap_complete = True
self._dialogue_stage = "project-ready"
return binding
def execute_modeling(self, command: ShadowModelingCommand) -> ModelingResponse:
del command
raise ApplicationAcceptanceError(
"standalone modeling commands are disabled; use one canonical START-to-STOP session"
)
def execute_canonical_start(
self,
command: ShadowModelingCommand,
post_start: CanonicalPostStartObservation,
*,
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
permit: PhysicalAcceptancePermit,
checkpoint: OperatorDialogueCheckpoint,
) -> ModelingResponse:
"""Execute retained operations 11-14 on one continuously serviced socket."""
if command.action is not ModelingAction.START:
raise ApplicationAcceptanceError("canonical START executor requires START")
if self._command_complete:
raise ApplicationAcceptanceError("physical acceptance command was already attempted")
if command.action is ModelingAction.START and not self._bootstrap_complete:
raise ApplicationAcceptanceError("START requires the complete response-gated bootstrap")
self._permit.consume(command.action)
if not self._bootstrap_complete or self._dialogue_stage != "project-ready":
raise ApplicationAcceptanceError("START requires the complete staged preparation")
if [request.ordinal for request in post_start.requests] != [12, 13, 14]:
raise ApplicationAcceptanceError("post-START transcript ordinals are invalid")
self._consume_checkpoint(checkpoint, expected="start-confirmed")
self._require_command_identity(command, authority=authority, binding=binding)
if not self._transport.pre_start_ready(binding):
raise ApplicationAcceptanceError(
"canonical START requires live READY with no bound project"
)
if permit.action is not ModelingAction.START:
raise ApplicationAcceptanceError("canonical START requires a fresh START permit")
permit.consume(ModelingAction.START)
self._start_permit_snapshot = permit.snapshot()
self._command_complete = True
self._dialogue_stage = "start-attempted"
responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_modeling_command(command)],
required_response_topics={MODELING_RESPONSE_TOPIC},
@@ -201,29 +343,218 @@ class PhysicalAcceptanceDialogueExecutor:
payload = responses[MODELING_RESPONSE_TOPIC]
self._record_response_evidence(
phase="modeling",
operation_key=f"modeling:{command.action.name.casefold()}",
operation_key="modeling:start",
response_topic=MODELING_RESPONSE_TOPIC,
payload=payload,
)
try:
return correlate_modeling_response(payload, command.command)
response = correlate_modeling_response(payload, command.command)
except ModelingProtocolError as exc:
self._record_correlation_failure(
phase="modeling",
operation_key=f"modeling:{command.action.name.casefold()}",
operation_key="modeling:start",
response_topic=MODELING_RESPONSE_TOPIC,
reason=str(exc),
)
raise ApplicationAcceptanceError(
f"modeling response correlation failed for {command.action.name}: {exc}"
f"modeling response correlation failed for START: {exc}"
) from exc
immediate = post_start.immediate_modeling_status
self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_dialogue_request(immediate)],
required_response_topics=(),
)
self._dialogue_stage = "initializing"
while not self._transport.scan_initialization_complete(binding):
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}
refresh_responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_dialogue_request(request) for request in refresh],
required_response_topics=required_topics,
)
for request in refresh:
refresh_payload = refresh_responses[request.response_topic]
operation_key = f"dialogue:{request.ordinal}:{request.message_type}"
self._record_response_evidence(
phase="post-start",
operation_key=operation_key,
response_topic=request.response_topic,
payload=refresh_payload,
)
try:
if request.message_type == "DeviceInfoRequest":
observed = decode_and_bind_device_info_response(
refresh_payload,
authority,
expected_session_id=request.session_id,
expected_vendor_device_id=binding.vendor_device_id,
)
if observed.binding != binding:
raise ApplicationBootstrapError(
"live DeviceInfo facts changed after START"
)
else:
correlate_application_response(
refresh_payload,
request,
authority,
live_binding=binding,
)
except ApplicationBootstrapError as exc:
self._record_correlation_failure(
phase="post-start",
operation_key=operation_key,
response_topic=request.response_topic,
reason=str(exc),
)
raise ApplicationAcceptanceError(
f"post-START response correlation failed for {operation_key}: {exc}"
) from exc
self._dialogue_stage = "post-initialization-observed"
self._start_complete = True
self._active_authority = authority
self._active_binding = binding
self._prepared_binding = None
return response
def maintain_active_until_stop_requested(
self,
stop_requested: Callable[[], bool],
) -> None:
"""Continuously service the original socket until the operator requests STOP."""
if self._dialogue_stage != "post-initialization-observed" or not self._start_complete:
raise ApplicationAcceptanceError(
"active control ownership requires the complete post-START dialogue"
)
binding = self._active_binding
if binding is None:
raise ApplicationAcceptanceError("canonical START binding is no longer available")
while not stop_requested():
self._transport.maintain_open_for(
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
)
if not self._transport.scan_initialization_complete(binding):
raise ApplicationAcceptanceError(
"K1 left the bound SCANNING state before canonical STOP"
)
self._dialogue_stage = "stop-requested"
def execute_canonical_stop(
self,
command: ShadowModelingCommand,
permit: PhysicalAcceptancePermit,
) -> ModelingResponse:
"""Emit retained STOP on the same socket and with a separate permit."""
if command.action is not ModelingAction.STOP:
raise ApplicationAcceptanceError("canonical STOP executor requires STOP")
if self._dialogue_stage != "stop-requested" or not self._start_complete:
raise ApplicationAcceptanceError(
"STOP requires continuous ownership from the canonical START session"
)
if self._stop_attempted:
raise ApplicationAcceptanceError("canonical STOP was already attempted")
authority = self._active_authority
binding = self._active_binding
if authority is None or binding is None:
raise ApplicationAcceptanceError("canonical START binding is no longer available")
self._require_command_identity(command, authority=authority, binding=binding)
if not self._transport.scan_initialization_complete(binding):
raise ApplicationAcceptanceError(
"canonical STOP requires the bound K1 to still report SCANNING"
)
if permit.action is not ModelingAction.STOP:
raise ApplicationAcceptanceError("canonical STOP requires a separate STOP permit")
permit.consume(ModelingAction.STOP)
self._stop_permit_snapshot = permit.snapshot()
self._stop_attempted = True
self._dialogue_stage = "stop-attempted"
responses = self._transport.exchange_batch_once(
[OneShotPublishEnvelope.from_modeling_command(command)],
required_response_topics={MODELING_RESPONSE_TOPIC},
)
payload = responses[MODELING_RESPONSE_TOPIC]
self._record_response_evidence(
phase="modeling",
operation_key="modeling:stop",
response_topic=MODELING_RESPONSE_TOPIC,
payload=payload,
)
try:
response = correlate_modeling_response(payload, command.command)
except ModelingProtocolError as exc:
self._record_correlation_failure(
phase="modeling",
operation_key="modeling:stop",
response_topic=MODELING_RESPONSE_TOPIC,
reason=str(exc),
)
raise ApplicationAcceptanceError(
f"modeling response correlation failed for STOP: {exc}"
) from exc
self._stop_complete = True
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.
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.
"""
if self._dialogue_stage != "stop-acknowledged" or not self._stop_complete:
raise ApplicationAcceptanceError(
"post-STOP ownership requires a correlated canonical STOP"
)
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()
):
self._transport.maintain_open_for(
CONTROL_NETWORK_PUMP_QUANTUM_SECONDS,
allowed_response_topics={MODELING_STATUS_RESPONSE_TOPIC},
)
self._dialogue_stage = "standby-confirmed"
self._active_authority = None
self._active_binding = None
def snapshot(self) -> dict[str, object]:
return {
"mode": "physical-acceptance-only",
"bootstrap_complete": self._bootstrap_complete,
"command_complete": self._command_complete,
"permit": self._permit.snapshot(),
"start_attempted": self._command_complete,
"start_complete": self._start_complete,
"stop_attempted": self._stop_attempted,
"stop_complete": self._stop_complete,
"dialogue_stage": self._dialogue_stage,
"start_permit": (
dict(self._start_permit_snapshot)
if self._start_permit_snapshot is not None
else None
),
"stop_permit": (
dict(self._stop_permit_snapshot)
if self._stop_permit_snapshot is not None
else None
),
"response_evidence": tuple(dict(item) for item in self._response_evidence),
"correlation_failure": (
dict(self._correlation_failure)
@@ -233,10 +564,86 @@ class PhysicalAcceptanceDialogueExecutor:
"automatic_retry": False,
}
@staticmethod
def _require_command_identity(
command: ShadowModelingCommand,
*,
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
) -> None:
header = command.command.header
if (
header.device_id != binding.vendor_device_id
or header.openapi_key != authority.openapi_key
):
raise ApplicationAcceptanceError(
"modeling command identity does not match the live canonical session"
)
def _consume_checkpoint(
self,
checkpoint: OperatorDialogueCheckpoint,
*,
expected: str,
) -> None:
if (
checkpoint.owner_token is not self._checkpoint_owner
or checkpoint.event != expected
or self._issued_checkpoint != expected
):
raise ApplicationAcceptanceError(
"operator checkpoint was not issued by this canonical session"
)
self._issued_checkpoint = None
def _exchange_bootstrap_batch(
self,
orchestrator: ShadowApplicationBootstrapOrchestrator,
*,
expected_batch: int,
) -> None:
snapshot = orchestrator.snapshot()
if snapshot.current_batch != expected_batch or snapshot.batch_issued:
raise ApplicationAcceptanceError(
f"canonical bootstrap expected batch {expected_batch}"
)
batch = orchestrator.next_batch()
required_topics = {
request.response_topic 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,
)
for request in batch:
if not request.response_required:
continue
payload = responses[request.response_topic]
operation_key = f"bootstrap:{request.ordinal}:{request.message_type}"
self._record_response_evidence(
phase="bootstrap",
operation_key=operation_key,
response_topic=request.response_topic,
payload=payload,
)
try:
orchestrator.accept_response(request.response_topic, payload)
except ApplicationBootstrapError as exc:
self._record_correlation_failure(
phase="bootstrap",
operation_key=operation_key,
response_topic=request.response_topic,
reason=str(exc),
)
raise ApplicationAcceptanceError(
"bootstrap response correlation failed for "
f"ordinal {request.ordinal} ({request.message_type}): {exc}"
) from exc
def _record_response_evidence(
self,
*,
phase: Literal["bootstrap", "modeling"],
phase: Literal["bootstrap", "modeling", "post-start"],
operation_key: str,
response_topic: str,
payload: bytes,
@@ -254,7 +661,7 @@ class PhysicalAcceptanceDialogueExecutor:
def _record_correlation_failure(
self,
*,
phase: Literal["bootstrap", "modeling"],
phase: Literal["bootstrap", "modeling", "post-start"],
operation_key: str,
response_topic: str,
reason: str,
@@ -129,7 +129,7 @@ class ApplicationRequestHeader:
@dataclass(frozen=True, slots=True)
class EncodedApplicationRequest:
ordinal: int
phase: Literal["identity-discovery", "bound-preparation"]
phase: Literal["identity-discovery", "bound-preparation", "post-start-observation"]
message_type: str
topic: str
response_topic: str
@@ -182,6 +182,21 @@ class ShadowApplicationBootstrap:
}
@dataclass(frozen=True, slots=True)
class CanonicalPostStartObservation:
"""Exact retained operations 12-14 following a successful START publish."""
immediate_modeling_status: EncodedApplicationRequest = field(repr=False)
post_initialization_refresh: tuple[
EncodedApplicationRequest,
EncodedApplicationRequest,
] = field(repr=False)
@property
def requests(self) -> tuple[EncodedApplicationRequest, ...]:
return (self.immediate_modeling_status, *self.post_initialization_refresh)
@dataclass(frozen=True, slots=True)
class DeviceInfoResponse:
binding: LiveDeviceControlBinding = field(repr=False)
@@ -384,6 +399,74 @@ def build_shadow_bootstrap(
return ShadowApplicationBootstrap(tuple(requests))
def build_canonical_post_start_observation(
authority: ApplicationControlAuthority,
binding: LiveDeviceControlBinding,
) -> CanonicalPostStartObservation:
"""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"
)
def build(
ordinal: Literal[12, 13, 14],
message_type: Literal["DeviceInfoRequest", "ModelingStatusRequest"],
topic: str,
response_topic: str,
*,
response_required: bool,
) -> EncodedApplicationRequest:
header = ApplicationRequestHeader(
message_type=message_type,
authority=authority,
binding=binding,
)
payload = _bytes_field(1, _encode_header(header))
_check_payload(payload, "post-START application request")
return EncodedApplicationRequest(
ordinal=ordinal,
phase="post-start-observation",
message_type=message_type,
topic=topic,
response_topic=response_topic,
payload=payload,
payload_sha256=hashlib.sha256(payload).hexdigest(),
payload_bytes=len(payload),
mutates_device=False,
requires_live_binding=True,
response_required=response_required,
session_id=header.session_id,
vendor_device_id=binding.vendor_device_id,
)
immediate = build(
12,
"ModelingStatusRequest",
MODELING_STATUS_REQUEST_TOPIC,
MODELING_STATUS_RESPONSE_TOPIC,
response_required=False,
)
refresh = (
build(
13,
"DeviceInfoRequest",
DEVICE_INFO_REQUEST_TOPIC,
DEVICE_INFO_RESPONSE_TOPIC,
response_required=True,
),
build(
14,
"ModelingStatusRequest",
MODELING_STATUS_REQUEST_TOPIC,
MODELING_STATUS_RESPONSE_TOPIC,
response_required=True,
),
)
return CanonicalPostStartObservation(immediate, refresh)
def decode_and_bind_device_info_response(
payload: bytes,
authority: ApplicationControlAuthority,
@@ -22,11 +22,17 @@ from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
GET_RTK_ADVANCE_RESPONSE_TOPIC,
MODELING_STATUS_RESPONSE_TOPIC,
LiveDeviceControlBinding,
)
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
OneShotPublishEnvelope,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
decode_device_status_report,
decode_system_error_report,
)
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
DEVICE_STATUS_TOPIC,
MODELING_REQUEST_TOPIC,
)
@@ -35,7 +41,9 @@ CONTROL_KEEPALIVE_SECONDS = 60
CONTROL_CONNECT_TIMEOUT_SECONDS = 10.0
CONTROL_EXCHANGE_TIMEOUT_SECONDS = 5.0
CONTROL_LOOP_INTERVAL_SECONDS = 0.05
MAX_CONTROL_MAINTAIN_SECONDS = 30.0
MAX_CONTROL_RESPONSE_BYTES = 64 * 1024
SYSTEM_ERROR_TOPIC = "lixel/application/report/system_error"
# The retained LixelGO control connection issued these three SUBSCRIBE packets
# in this exact order. Point-cloud subscriptions belonged to a separate client.
@@ -47,8 +55,8 @@ CONTROL_SUBSCRIPTION_GROUPS: tuple[tuple[tuple[str, int], ...], ...] = (
("PrePathArray", 0),
("ScanStatus", 0),
("lixel/application/report/lio_pose", 0),
("lixel/application/report/device_status", 0),
("lixel/application/report/system_error", 0),
(DEVICE_STATUS_TOPIC, 0),
(SYSTEM_ERROR_TOPIC, 0),
(DEVICE_INFO_RESPONSE_TOPIC, 0),
),
(
@@ -144,6 +152,10 @@ class ApplicationCommandOutcomeUnknown(RuntimeError):
"""A request may have reached the K1 and must never be retried automatically."""
class ApplicationControlDeviceFault(RuntimeError):
"""Live status made further automatic control inadmissible."""
@dataclass(frozen=True, slots=True)
class ApplicationMqttTransportSnapshot:
state: str
@@ -153,6 +165,14 @@ class ApplicationMqttTransportSnapshot:
qos2_completions: int
correlated_responses: int
ignored_known_responses: int
device_status_reports: int
system_error_reports: int
report_decode_errors: int
latest_device_session_state: str | None
latest_device_project_bound: bool | None
latest_device_init_ready: bool | None
latest_system_error_code: int | None
latest_system_error_state: str | None
operation_keys_consumed: int
clean_session: bool = False
keepalive_seconds: int = CONTROL_KEEPALIVE_SECONDS
@@ -169,6 +189,14 @@ class ApplicationMqttTransportSnapshot:
"qos2_completions": self.qos2_completions,
"correlated_responses": self.correlated_responses,
"ignored_known_responses": self.ignored_known_responses,
"device_status_reports": self.device_status_reports,
"system_error_reports": self.system_error_reports,
"report_decode_errors": self.report_decode_errors,
"latest_device_session_state": self.latest_device_session_state,
"latest_device_project_bound": self.latest_device_project_bound,
"latest_device_init_ready": self.latest_device_init_ready,
"latest_system_error_code": self.latest_system_error_code,
"latest_system_error_state": self.latest_system_error_state,
"operation_keys_consumed": self.operation_keys_consumed,
"clean_session": self.clean_session,
"keepalive_seconds": self.keepalive_seconds,
@@ -230,6 +258,17 @@ class ReviewedApplicationMqttTransport:
self._qos2_completions = 0
self._correlated_responses = 0
self._ignored_known_responses = 0
self._device_status_reports = 0
self._system_error_reports = 0
self._report_decode_errors = 0
self._latest_device_session_state: str | None = None
self._latest_device_project_bound: bool | None = None
self._latest_device_init_ready: bool | None = None
self._latest_device_id: str | None = None
self._latest_device_serial: str | None = None
self._latest_device_fault = False
self._latest_system_error_code: int | None = None
self._latest_system_error_state: str | None = None
def open(self) -> ApplicationMqttTransportSnapshot:
with self._lock:
@@ -267,7 +306,12 @@ class ReviewedApplicationMqttTransport:
required = frozenset(required_response_topics)
if not batch:
raise ValueError("control exchange batch must not be empty")
if not required or not required <= APPLICATION_RESPONSE_TOPICS:
if not required and any(
envelope.topic != "lixel/application/request/modeling_status"
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):
@@ -333,6 +377,43 @@ class ReviewedApplicationMqttTransport:
self._correlated_responses += len(responses)
return responses
def maintain_open_for(
self,
duration_seconds: float,
*,
allowed_response_topics: Collection[str] = (),
) -> None:
"""Continuously service the same control socket across static initialization."""
if (
not isinstance(duration_seconds, (int, float))
or isinstance(duration_seconds, bool)
or not math.isfinite(duration_seconds)
or not 0 < duration_seconds <= MAX_CONTROL_MAINTAIN_SECONDS
):
raise ValueError("control maintain duration is outside the reviewed bound")
allowed = frozenset(allowed_response_topics)
if not allowed <= frozenset({MODELING_STATUS_RESPONSE_TOPIC}):
raise ValueError("control maintain response allowlist exceeds retained dialogue")
with self._lock:
if self._state != "ready" or not self._connected or not self._subscribed:
raise ApplicationMqttTransportError("control transport is not ready")
deadline = self._monotonic() + float(duration_seconds)
client = self._require_client()
while self._monotonic() < deadline:
self._discard_allowed_responses(allowed)
with self._lock:
callback_error = self._callback_error
if callback_error is not None:
self._fail_after_publish(callback_error)
try:
result = client.loop(timeout=CONTROL_LOOP_INTERVAL_SECONDS)
except (OSError, RuntimeError, ValueError) as exc:
self._fail_after_publish("control MQTT network loop failed", exc)
if result != mqtt.MQTT_ERR_SUCCESS:
self._fail_after_publish("control MQTT network loop returned an error")
self._discard_allowed_responses(allowed)
def close(self) -> None:
with self._lock:
if self._state == "closed":
@@ -355,6 +436,43 @@ class ReviewedApplicationMqttTransport:
if self._state not in {"poisoned", "failed"}:
self._state = "closed"
def scan_initialization_complete(self, binding: LiveDeviceControlBinding) -> bool:
"""Return true only for the bound SCANNING/project/init-ready state."""
with self._lock:
self._require_healthy_bound_status_locked(binding)
return (
self._latest_device_session_state == "scanning"
and self._latest_device_project_bound is True
and self._latest_device_init_ready is True
)
def pre_start_ready(self, binding: LiveDeviceControlBinding) -> bool:
"""Return true only when the bound K1 is READY with no current project."""
with self._lock:
self._require_healthy_bound_status_locked(binding)
return (
self._latest_device_session_state == "ready"
and self._latest_device_project_bound is False
)
def standby_complete(self, binding: LiveDeviceControlBinding) -> bool:
"""Return true only when the same K1 reports unbound READY after STOP."""
with self._lock:
self._require_healthy_bound_status_locked(binding)
return (
self._latest_device_session_state == "ready"
and self._latest_device_project_bound is False
)
def validate_bound_status(self, binding: LiveDeviceControlBinding) -> None:
"""Fail closed on report faults or identity drift without advancing state."""
with self._lock:
self._require_healthy_bound_status_locked(binding)
def snapshot(self) -> ApplicationMqttTransportSnapshot:
with self._lock:
return ApplicationMqttTransportSnapshot(
@@ -365,6 +483,14 @@ class ReviewedApplicationMqttTransport:
qos2_completions=self._qos2_completions,
correlated_responses=self._correlated_responses,
ignored_known_responses=self._ignored_known_responses,
device_status_reports=self._device_status_reports,
system_error_reports=self._system_error_reports,
report_decode_errors=self._report_decode_errors,
latest_device_session_state=self._latest_device_session_state,
latest_device_project_bound=self._latest_device_project_bound,
latest_device_init_ready=self._latest_device_init_ready,
latest_system_error_code=self._latest_system_error_code,
latest_system_error_state=self._latest_system_error_state,
operation_keys_consumed=len(self._consumed_operation_keys),
)
@@ -449,10 +575,12 @@ class ReviewedApplicationMqttTransport:
if len(payload) > MAX_CONTROL_RESPONSE_BYTES:
self._set_callback_error("control MQTT response exceeds the reviewed bound")
return
with self._lock:
if message.topic in CONTROL_REPORT_TOPICS:
if message.topic in CONTROL_REPORT_TOPICS:
self._observe_control_report(message.topic, payload)
with self._lock:
self._ignored_known_responses += 1
else:
else:
with self._lock:
self._messages.append((message.topic, payload))
def on_disconnect(
@@ -474,6 +602,77 @@ class ReviewedApplicationMqttTransport:
client.on_message = on_message
client.on_disconnect = on_disconnect
def _observe_control_report(self, topic: str, payload: bytes) -> None:
if topic == DEVICE_STATUS_TOPIC:
try:
device_report = decode_device_status_report(payload)
except ValueError:
with self._lock:
self._report_decode_errors += 1
return
with self._lock:
self._device_status_reports += 1
self._latest_device_session_state = (
device_report.session_state.name.casefold()
if device_report.session_state is not None
else None
)
self._latest_device_project_bound = bool(device_report.project_id)
self._latest_device_init_ready = device_report.init_ready
self._latest_device_id = (
device_report.header.device_id
if device_report.header is not None
else None
)
self._latest_device_serial = device_report.device_sn
self._latest_device_fault = bool(
device_report.session_state is not None
and device_report.session_state.is_fault
)
return
if topic == SYSTEM_ERROR_TOPIC:
try:
system_error_report = decode_system_error_report(payload)
except ValueError:
with self._lock:
self._report_decode_errors += 1
return
with self._lock:
self._system_error_reports += 1
self._latest_system_error_code = system_error_report.error_code
self._latest_system_error_state = (
system_error_report.session_state.name.casefold()
if system_error_report.session_state is not None
else None
)
def _require_healthy_bound_status_locked(
self,
binding: LiveDeviceControlBinding,
) -> None:
if self._report_decode_errors:
raise ApplicationControlDeviceFault(
"K1 control report decoding failed; no further automatic action is admissible"
)
if self._system_error_reports or self._latest_device_fault:
state = self._latest_system_error_state or self._latest_device_session_state
raise ApplicationControlDeviceFault(
f"K1 reported a control fault state: {state or 'unknown'}"
)
if self._device_status_reports == 0:
return
if self._latest_device_id is None or self._latest_device_serial is None:
raise ApplicationControlDeviceFault(
"K1 control status omitted the live device identity"
)
if (
self._latest_device_id != binding.vendor_device_id
or self._latest_device_serial != binding.device_serial
):
raise ApplicationControlDeviceFault(
"K1 control status identity drifted from the live DeviceInfo binding"
)
def _subscribe_next_group(self, client: mqtt.Client) -> None:
with self._lock:
group_index = self._subscription_group_index
@@ -552,6 +751,23 @@ class ReviewedApplicationMqttTransport:
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"
)
break
if ambiguous_message is not None:
self.close()
raise ApplicationCommandOutcomeUnknown(ambiguous_message)
def _set_callback_error(self, message: str) -> None:
with self._lock:
if self._callback_error is None:
@@ -69,6 +69,23 @@ class OneShotPublishEnvelope:
retain=request.retain,
)
@classmethod
def from_dialogue_request(
cls,
request: EncodedApplicationRequest,
) -> OneShotPublishEnvelope:
if request.phase != "post-start-observation" or request.ordinal not in {12, 13, 14}:
raise ValueError("application request is not a retained post-START operation")
return cls(
operation_key=f"dialogue:{request.ordinal}:{request.message_type}",
topic=request.topic,
payload=request.payload,
payload_sha256=request.payload_sha256,
payload_bytes=request.payload_bytes,
qos=request.qos,
retain=request.retain,
)
@classmethod
def from_modeling_command(
cls,
@@ -21,6 +21,7 @@ MODELING_SESSION_SUFFIX = ":ModelingRequest"
OPENAPI_RESULT_BASE = 302_252_032
OPENAPI_SUCCESS = OPENAPI_RESULT_BASE + 1
MODELING_STATE_BASE = 302_252_032
SYSTEM_ERROR_STATE_BASE = 0x3204_0000
class ModelingProtocolError(ValueError):
@@ -202,6 +203,15 @@ class DeviceStatusReport:
rtk_status_present: bool
@dataclass(frozen=True, slots=True)
class SystemErrorReport:
"""Bounded K1 system-error report with identity kept out of repr."""
header: ObservedHeader | None = field(repr=False)
error_code: int
session_state: SessionState | None
def encode_modeling_start(
header: CommandHeaderIdentity,
*,
@@ -391,6 +401,57 @@ def session_state_from_code(code: int) -> SessionState | None:
return None
def decode_system_error_report(
payload: bytes, *, max_payload_bytes: int = MAX_CONTROL_PAYLOAD_BYTES
) -> SystemErrorReport:
"""Decode the observed ``report/system_error`` envelope without guessing text."""
_check_payload_bound(payload, max_payload_bytes, "SystemErrorReport")
header: ObservedHeader | None = None
error_code: int | None = None
seen: set[int] = set()
try:
for proto_field in iter_fields(payload, max_fields=64):
if proto_field.number == 1:
_mark_once(seen, 1, "system_error.header")
header = _decode_observed_header(
_bytes_value(proto_field, "system_error.header"), require_identity=False
)
elif proto_field.number == 15:
_mark_once(seen, 15, "system_error.error")
nested_seen: set[int] = set()
for nested in iter_fields(
_bytes_value(proto_field, "system_error.error"), max_fields=8
):
if nested.number == 1:
_mark_once(nested_seen, 1, "system_error.error.code")
error_code = _uint_value(
nested,
"system_error.error.code",
max_value=0xFFFF_FFFF,
)
except ProtobufWireError as exc:
raise ModelingProtocolError(f"invalid SystemErrorReport: {exc}") from exc
if error_code is None:
raise ModelingProtocolError("SystemErrorReport has no error code")
return SystemErrorReport(
header=header,
error_code=error_code,
session_state=system_error_state_from_code(error_code),
)
def system_error_state_from_code(code: int) -> SessionState | None:
"""Map the observed system-error namespace while preserving unknown codes."""
if code < SYSTEM_ERROR_STATE_BASE:
return None
try:
return SessionState(code - SYSTEM_ERROR_STATE_BASE)
except ValueError:
return None
def _encode_command_header(header: CommandHeaderIdentity) -> bytes:
# seq/stamp/scaler were default-valued and omitted in the reviewed command
# requests. The three identities must be supplied explicitly by a caller.