feat(k1): add device-bound shadow control gate
This commit is contained in:
@@ -46,12 +46,17 @@ from k1link.device_plugins.xgrids_k1.camera import (
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import seal_capture_clock
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling import observe_modeling_report
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
LiveModelingControlSafety,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||
from k1link.device_plugins.xgrids_k1.viewer.runtime import (
|
||||
VisualizationRuntime,
|
||||
new_live_session_dir,
|
||||
)
|
||||
from k1link.sessions import ActiveSessionLease, resolve_missioncore_evidence_dir
|
||||
from k1link.viewer.metrics import BridgeMetrics
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
from k1link.web.device_lifecycle import (
|
||||
TERMINAL_ACQUISITION_STATES,
|
||||
@@ -278,9 +283,10 @@ class XgridsK1CompatibilityService:
|
||||
self._acquisition_session_lease: ActiveSessionLease | None = None
|
||||
# The host-owned visual runtime receives the vendor normalizer
|
||||
# explicitly. There is no implicit K1 decoder in the visual layer.
|
||||
self._modeling_control_safety = LiveModelingControlSafety()
|
||||
self.runtime = VisualizationRuntime(
|
||||
normalizer=normalize_k1_message,
|
||||
message_observer=observe_modeling_report,
|
||||
message_observer=self._observe_runtime_message,
|
||||
)
|
||||
self.camera_preview = XgridsK1CameraGateway(
|
||||
self.repository_root,
|
||||
@@ -379,6 +385,7 @@ class XgridsK1CompatibilityService:
|
||||
else "unverified"
|
||||
),
|
||||
},
|
||||
"modeling_control_safety": self._modeling_control_safety.snapshot().as_dict(),
|
||||
"device_ref": (
|
||||
{
|
||||
"device_id": device_id,
|
||||
@@ -645,6 +652,15 @@ class XgridsK1CompatibilityService:
|
||||
|
||||
return self.state()
|
||||
|
||||
def _observe_runtime_message(
|
||||
self,
|
||||
message: StreamMessage,
|
||||
metrics: BridgeMetrics,
|
||||
) -> bool:
|
||||
if self._modeling_control_safety.observe(message, metrics):
|
||||
return True
|
||||
return observe_modeling_report(message, metrics)
|
||||
|
||||
def verify_connection(self) -> dict[str, Any]:
|
||||
"""Validate the recorded endpoint only; no network packet is emitted."""
|
||||
|
||||
@@ -842,6 +858,7 @@ class XgridsK1CompatibilityService:
|
||||
lease.release()
|
||||
raise RuntimeError("evidence-сессия уже удерживается активным acquisition")
|
||||
self._acquisition_session_lease = lease
|
||||
self._modeling_control_safety.reset()
|
||||
self.runtime.start_live(
|
||||
acquisition.target_host,
|
||||
out_dir,
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
CommandHeaderIdentity,
|
||||
DeviceStatusReport,
|
||||
EncodedModelingCommand,
|
||||
ModelingAction,
|
||||
ModelingEncodeError,
|
||||
MountType,
|
||||
RecordMode,
|
||||
ScanMode,
|
||||
SessionState,
|
||||
decode_device_status_report,
|
||||
encode_modeling_start,
|
||||
encode_modeling_stop,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||
from k1link.viewer.metrics import BridgeMetrics
|
||||
|
||||
DEVICE_STATUS_TOPIC = "lixel/application/report/device_status"
|
||||
MODELING_REQUEST_TOPIC = "lixel/application/request/modeling"
|
||||
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
|
||||
CONTROL_ENROLLMENT_SOURCE = "owner-captured-device-bound"
|
||||
CONTROL_COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
CONTROL_FIRMWARE_VERSION = "3.0.2"
|
||||
CONTROL_TOPOLOGY = "direct-lan"
|
||||
|
||||
|
||||
class ModelingControlSafetyError(RuntimeError):
|
||||
"""A live K1 could not be bound to reviewed device-specific control input."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeviceBoundControlEnrollment:
|
||||
"""Private, device-specific values learned from owner-controlled evidence.
|
||||
|
||||
Mission Core's provisional device UUID, BLE transport UUID and K1 serial are
|
||||
not interchangeable with the vendor header identity. An enrollment binds
|
||||
all three vendor inputs explicitly and keeps them out of repr/API output.
|
||||
"""
|
||||
|
||||
vendor_device_id: str = field(repr=False)
|
||||
device_serial: str = field(repr=False)
|
||||
openapi_key: str = field(repr=False)
|
||||
source: Literal["owner-captured-device-bound"] = "owner-captured-device-bound"
|
||||
compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = (
|
||||
"xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
)
|
||||
firmware_version: Literal["3.0.2"] = "3.0.2"
|
||||
topology: Literal["direct-lan"] = "direct-lan"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
self.source != CONTROL_ENROLLMENT_SOURCE
|
||||
or self.compatibility_profile_id != CONTROL_COMPATIBILITY_PROFILE_ID
|
||||
or self.firmware_version != CONTROL_FIRMWARE_VERSION
|
||||
or self.topology != CONTROL_TOPOLOGY
|
||||
):
|
||||
raise ModelingControlSafetyError(
|
||||
"control enrollment does not match the exact reviewed K1 profile"
|
||||
)
|
||||
CommandHeaderIdentity(
|
||||
device_id=self.vendor_device_id,
|
||||
openapi_key=self.openapi_key,
|
||||
)
|
||||
_validate_device_serial(self.device_serial)
|
||||
|
||||
@property
|
||||
def command_header(self) -> CommandHeaderIdentity:
|
||||
return CommandHeaderIdentity(
|
||||
device_id=self.vendor_device_id,
|
||||
openapi_key=self.openapi_key,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ModelingControlSafetySnapshot:
|
||||
status_reports_observed: int
|
||||
decode_errors: int
|
||||
vendor_identity_observed: bool
|
||||
device_serial_observed: bool
|
||||
identity_conflict: bool
|
||||
session_state: str | None
|
||||
project_bound: bool
|
||||
ready_for_start_shadow: bool
|
||||
ready_for_stop_shadow: bool
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": "shadow-only",
|
||||
"status_reports_observed": self.status_reports_observed,
|
||||
"decode_errors": self.decode_errors,
|
||||
"vendor_identity_observed": self.vendor_identity_observed,
|
||||
"device_serial_observed": self.device_serial_observed,
|
||||
"identity_conflict": self.identity_conflict,
|
||||
"session_state": self.session_state,
|
||||
"project_bound": self.project_bound,
|
||||
"ready_for_start_shadow": self.ready_for_start_shadow,
|
||||
"ready_for_stop_shadow": self.ready_for_stop_shadow,
|
||||
"vendor_writes_enabled": False,
|
||||
"publisher_installed": False,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ShadowModelingCommand:
|
||||
"""A reviewed command plan with no transport or execution authority."""
|
||||
|
||||
action: ModelingAction
|
||||
command: EncodedModelingCommand = field(repr=False)
|
||||
payload_sha256: str
|
||||
payload_bytes: int
|
||||
topic: str = MODELING_REQUEST_TOPIC
|
||||
qos: int = 2
|
||||
retain: bool = False
|
||||
executable: bool = False
|
||||
blockers: tuple[str, ...] = SHADOW_BLOCKERS
|
||||
retry_policy: Literal["never-automatic"] = "never-automatic"
|
||||
|
||||
@classmethod
|
||||
def from_command(cls, command: EncodedModelingCommand) -> ShadowModelingCommand:
|
||||
return cls(
|
||||
action=command.action,
|
||||
command=command,
|
||||
payload_sha256=hashlib.sha256(command.payload).hexdigest(),
|
||||
payload_bytes=len(command.payload),
|
||||
)
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": "shadow-only",
|
||||
"action": self.action.name.casefold(),
|
||||
"topic": self.topic,
|
||||
"qos": self.qos,
|
||||
"retain": self.retain,
|
||||
"payload_sha256": self.payload_sha256,
|
||||
"payload_bytes": self.payload_bytes,
|
||||
"executable": self.executable,
|
||||
"blockers": list(self.blockers),
|
||||
"retry_policy": self.retry_policy,
|
||||
}
|
||||
|
||||
|
||||
class LiveModelingControlSafety:
|
||||
"""Track only live status evidence and fail closed on identity drift."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._lock = threading.Lock()
|
||||
self._status_reports_observed = 0
|
||||
self._decode_errors = 0
|
||||
self._vendor_device_id: str | None = None
|
||||
self._device_serial: str | None = None
|
||||
self._identity_conflict = False
|
||||
self._latest_report: DeviceStatusReport | None = None
|
||||
|
||||
def reset(self) -> None:
|
||||
with self._lock:
|
||||
self._status_reports_observed = 0
|
||||
self._decode_errors = 0
|
||||
self._vendor_device_id = None
|
||||
self._device_serial = None
|
||||
self._identity_conflict = False
|
||||
self._latest_report = None
|
||||
|
||||
def observe(self, message: StreamMessage, _metrics: BridgeMetrics) -> bool:
|
||||
if message.source != "live_mqtt" or message.topic != DEVICE_STATUS_TOPIC:
|
||||
return False
|
||||
try:
|
||||
report = decode_device_status_report(message.payload)
|
||||
except ValueError:
|
||||
with self._lock:
|
||||
self._decode_errors += 1
|
||||
return True
|
||||
|
||||
header_device_id = report.header.device_id if report.header is not None else None
|
||||
with self._lock:
|
||||
self._status_reports_observed += 1
|
||||
if header_device_id is None or report.device_sn is None:
|
||||
self._identity_conflict = True
|
||||
else:
|
||||
if self._vendor_device_id is None:
|
||||
self._vendor_device_id = header_device_id
|
||||
elif self._vendor_device_id != header_device_id:
|
||||
self._identity_conflict = True
|
||||
if self._device_serial is None:
|
||||
self._device_serial = report.device_sn
|
||||
elif self._device_serial != report.device_sn:
|
||||
self._identity_conflict = True
|
||||
self._latest_report = report
|
||||
return True
|
||||
|
||||
def snapshot(self) -> ModelingControlSafetySnapshot:
|
||||
with self._lock:
|
||||
report = self._latest_report
|
||||
state = report.session_state if report is not None else None
|
||||
project_bound = bool(report and report.project_id)
|
||||
observed = self._vendor_device_id is not None and self._device_serial is not None
|
||||
safe = observed and not self._identity_conflict and self._decode_errors == 0
|
||||
return ModelingControlSafetySnapshot(
|
||||
status_reports_observed=self._status_reports_observed,
|
||||
decode_errors=self._decode_errors,
|
||||
vendor_identity_observed=self._vendor_device_id is not None,
|
||||
device_serial_observed=self._device_serial is not None,
|
||||
identity_conflict=self._identity_conflict,
|
||||
session_state=state.name.casefold() if state is not None else None,
|
||||
project_bound=project_bound,
|
||||
ready_for_start_shadow=safe and state is SessionState.READY and not project_bound,
|
||||
ready_for_stop_shadow=safe and state is SessionState.SCANNING and project_bound,
|
||||
)
|
||||
|
||||
def plan_start(
|
||||
self,
|
||||
enrollment: DeviceBoundControlEnrollment,
|
||||
*,
|
||||
project_name: str,
|
||||
) -> ShadowModelingCommand:
|
||||
self._require_bound(enrollment, required_state=SessionState.READY, project_bound=False)
|
||||
command = encode_modeling_start(
|
||||
enrollment.command_header,
|
||||
project_name=project_name,
|
||||
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||
scan_mode=ScanMode.LCC,
|
||||
mount_type=MountType.HANDHELD,
|
||||
)
|
||||
return ShadowModelingCommand.from_command(command)
|
||||
|
||||
def plan_stop(
|
||||
self,
|
||||
enrollment: DeviceBoundControlEnrollment,
|
||||
) -> ShadowModelingCommand:
|
||||
self._require_bound(enrollment, required_state=SessionState.SCANNING, project_bound=True)
|
||||
return ShadowModelingCommand.from_command(encode_modeling_stop(enrollment.command_header))
|
||||
|
||||
def _require_bound(
|
||||
self,
|
||||
enrollment: DeviceBoundControlEnrollment,
|
||||
*,
|
||||
required_state: SessionState,
|
||||
project_bound: bool,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
report = self._latest_report
|
||||
if self._identity_conflict or self._decode_errors:
|
||||
raise ModelingControlSafetyError("live K1 identity/status evidence is conflicted")
|
||||
if report is None or self._vendor_device_id is None or self._device_serial is None:
|
||||
raise ModelingControlSafetyError("live K1 identity/status evidence is missing")
|
||||
if (
|
||||
enrollment.vendor_device_id != self._vendor_device_id
|
||||
or enrollment.device_serial != self._device_serial
|
||||
):
|
||||
raise ModelingControlSafetyError(
|
||||
"device-bound control enrollment does not match the live K1"
|
||||
)
|
||||
if report.session_state is not required_state:
|
||||
raise ModelingControlSafetyError(
|
||||
f"live K1 must be {required_state.name.casefold()} for this shadow plan"
|
||||
)
|
||||
if bool(report.project_id) is not project_bound:
|
||||
raise ModelingControlSafetyError("live K1 project binding is not in the safe state")
|
||||
|
||||
|
||||
def _validate_device_serial(value: object) -> None:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ModelingEncodeError("device_serial must be a non-empty string")
|
||||
try:
|
||||
encoded = value.encode("ascii")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ModelingEncodeError("device_serial must use printable ASCII") from exc
|
||||
if len(encoded) > 256 or any(byte <= 0x20 or byte > 0x7E for byte in encoded):
|
||||
raise ModelingEncodeError("device_serial must use bounded printable ASCII without spaces")
|
||||
Reference in New Issue
Block a user