fix(k1): gate control dialogue on live state
This commit is contained in:
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user