feat(k1): add device-bound shadow control gate

This commit is contained in:
DCCONSTRUCTIONS
2026-07-18 12:09:23 +03:00
parent 0ac6424c46
commit af9319a33b
7 changed files with 617 additions and 11 deletions
+18 -1
View File
@@ -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")