feat(k1): recover exact application bootstrap
This commit is contained in:
@@ -0,0 +1,502 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS
|
||||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||
ProtobufWireError,
|
||||
ProtoField,
|
||||
iter_fields,
|
||||
)
|
||||
|
||||
MAX_APPLICATION_PAYLOAD_BYTES = 64 * 1024
|
||||
MAX_HEADER_BYTES = 4 * 1024
|
||||
MAX_TEXT_BYTES = 4 * 1024
|
||||
APPLICATION_AUTHORITY_SOURCE = "owner-captured-lixelgo-application"
|
||||
COMPATIBILITY_PROFILE_ID = "xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
DEVICE_CONFIG_TIME_CONTEXT = "Publish_Proto_DeviceConfig_SetTime"
|
||||
SHADOW_BLOCKERS = ("vendor-writes-disabled", "publisher-not-installed")
|
||||
|
||||
DEVICE_INFO_REQUEST_TOPIC = "lixel/application/request/device_info"
|
||||
DEVICE_INFO_RESPONSE_TOPIC = "lixel/application/response/device_info"
|
||||
MODELING_STATUS_REQUEST_TOPIC = "lixel/application/request/modeling_status"
|
||||
MODELING_STATUS_RESPONSE_TOPIC = "lixel/application/response/modeling_status"
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC = "lixel/application/request/get_rtk_advance"
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC = "lixel/application/response/get_rtk_advance"
|
||||
DEVICE_CONFIG_REQUEST_TOPIC = "lixel/application/request/device_config"
|
||||
DEVICE_CONFIG_RESPONSE_TOPIC = "lixel/application/response/device_config"
|
||||
GET_NTRIP_PROFILE_REQUEST_TOPIC = "lixel/application/request/get_ntrip_profile"
|
||||
GET_NTRIP_PROFILE_RESPONSE_TOPIC = "lixel/application/response/get_ntrip_profile"
|
||||
GET_CLOUD_CONFIG_REQUEST_TOPIC = "lixel/application/request/get_cloud_server_config"
|
||||
GET_CLOUD_CONFIG_RESPONSE_TOPIC = "lixel/application/response/get_cloud_server_config"
|
||||
|
||||
|
||||
class ApplicationBootstrapError(ValueError):
|
||||
"""The recovered K1 application bootstrap contract was violated."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationControlAuthority:
|
||||
"""Private authority belonging to the reviewed LixelGO application profile.
|
||||
|
||||
The retained application and wire evidence show one OpenAPI value shared by
|
||||
all request types. It is not a scanner serial or a per-device enrollment.
|
||||
"""
|
||||
|
||||
openapi_key: str = field(repr=False)
|
||||
source: Literal["owner-captured-lixelgo-application"] = "owner-captured-lixelgo-application"
|
||||
compatibility_profile_id: Literal["xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"] = (
|
||||
"xgrids.lixelkity-k1.fw-3.0.2.direct-lan.v1"
|
||||
)
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if (
|
||||
self.source != APPLICATION_AUTHORITY_SOURCE
|
||||
or self.compatibility_profile_id != COMPATIBILITY_PROFILE_ID
|
||||
):
|
||||
raise ApplicationBootstrapError(
|
||||
"application authority does not match the reviewed K1 profile"
|
||||
)
|
||||
_validate_ascii_identity(self.openapi_key, "openapi_key")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LiveDeviceControlBinding:
|
||||
"""Identity and compatibility facts learned from the selected live K1."""
|
||||
|
||||
vendor_device_id: str = field(repr=False)
|
||||
device_serial: str = field(repr=False)
|
||||
software_version: str = field(repr=False)
|
||||
system_version: str = field(repr=False)
|
||||
device_model: str = field(repr=False)
|
||||
device_type: str = field(repr=False)
|
||||
is_activated: bool
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_ascii_identity(self.vendor_device_id, "vendor_device_id")
|
||||
_validate_ascii_identity(self.device_serial, "device_serial")
|
||||
for name, value in (
|
||||
("software_version", self.software_version),
|
||||
("system_version", self.system_version),
|
||||
("device_model", self.device_model),
|
||||
("device_type", self.device_type),
|
||||
):
|
||||
_validate_text(value, name)
|
||||
|
||||
@property
|
||||
def matches_reviewed_firmware(self) -> bool:
|
||||
return _contains_version(self.software_version, "3.0.2") and _contains_version(
|
||||
self.system_version, "3.0.2"
|
||||
)
|
||||
|
||||
@property
|
||||
def ready_for_reviewed_profile(self) -> bool:
|
||||
return self.is_activated and self.matches_reviewed_firmware
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ApplicationRequestHeader:
|
||||
message_type: str
|
||||
authority: ApplicationControlAuthority = field(repr=False)
|
||||
binding: LiveDeviceControlBinding | None = field(default=None, repr=False)
|
||||
context: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
_validate_ascii_identity(self.message_type, "message_type")
|
||||
if self.context is not None:
|
||||
_validate_ascii_identity(self.context, "context")
|
||||
if len(self.session_id.encode("ascii")) > MAX_TEXT_BYTES:
|
||||
raise ApplicationBootstrapError("derived session_id exceeds configured limit")
|
||||
|
||||
@property
|
||||
def session_id(self) -> str:
|
||||
device_id = self.binding.vendor_device_id if self.binding is not None else ""
|
||||
session = f"{device_id}:{self.message_type}"
|
||||
if self.context is not None:
|
||||
session = f"{session}:{self.context}"
|
||||
return session
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EncodedApplicationRequest:
|
||||
ordinal: int
|
||||
phase: Literal["identity-discovery", "bound-preparation"]
|
||||
message_type: str
|
||||
topic: str
|
||||
response_topic: str
|
||||
payload: bytes = field(repr=False)
|
||||
payload_sha256: str
|
||||
payload_bytes: int
|
||||
mutates_device: bool
|
||||
requires_live_binding: bool
|
||||
qos: int = 2
|
||||
retain: bool = False
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"ordinal": self.ordinal,
|
||||
"phase": self.phase,
|
||||
"message_type": self.message_type,
|
||||
"topic": self.topic,
|
||||
"response_topic": self.response_topic,
|
||||
"payload_sha256": self.payload_sha256,
|
||||
"payload_bytes": self.payload_bytes,
|
||||
"mutates_device": self.mutates_device,
|
||||
"requires_live_binding": self.requires_live_binding,
|
||||
"qos": self.qos,
|
||||
"retain": self.retain,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ShadowApplicationBootstrap:
|
||||
"""Exact observed pre-START request order, with no execution authority."""
|
||||
|
||||
requests: tuple[EncodedApplicationRequest, ...] = field(repr=False)
|
||||
executable: bool = False
|
||||
blockers: tuple[str, ...] = SHADOW_BLOCKERS
|
||||
retry_policy: Literal["never-automatic"] = "never-automatic"
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": "shadow-only",
|
||||
"request_count": len(self.requests),
|
||||
"mutation_count": sum(request.mutates_device for request in self.requests),
|
||||
"requests": [request.as_dict() for request in self.requests],
|
||||
"executable": self.executable,
|
||||
"blockers": list(self.blockers),
|
||||
"retry_policy": self.retry_policy,
|
||||
}
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DeviceInfoResponse:
|
||||
binding: LiveDeviceControlBinding = field(repr=False)
|
||||
session_id: str = field(repr=False)
|
||||
openapi_key: str = field(repr=False)
|
||||
result_code: int
|
||||
|
||||
|
||||
def build_shadow_bootstrap(
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
*,
|
||||
epoch_seconds: int,
|
||||
timezone_name: str,
|
||||
) -> ShadowApplicationBootstrap:
|
||||
"""Build the ten pre-START requests observed in the clean LixelGO cycle.
|
||||
|
||||
The first three requests intentionally omit the vendor device ID. The
|
||||
remaining requests are bound to identity returned by ``DeviceInfoResponse``.
|
||||
The only preparatory mutation is the observed clock/timezone sync.
|
||||
"""
|
||||
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise ApplicationBootstrapError(
|
||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||
)
|
||||
if not isinstance(epoch_seconds, int) or isinstance(epoch_seconds, bool):
|
||||
raise ApplicationBootstrapError("epoch_seconds must be an integer")
|
||||
if epoch_seconds < 0 or epoch_seconds > 0x7FFF_FFFF_FFFF_FFFF:
|
||||
raise ApplicationBootstrapError("epoch_seconds is outside recovered int64 range")
|
||||
_validate_text(timezone_name, "timezone_name")
|
||||
|
||||
specs = (
|
||||
(
|
||||
"identity-discovery",
|
||||
"DeviceInfoRequest",
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"identity-discovery",
|
||||
"ModelingStatusRequest",
|
||||
MODELING_STATUS_REQUEST_TOPIC,
|
||||
MODELING_STATUS_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"identity-discovery",
|
||||
"GetRtkAdvanceRequest",
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"DeviceConfigRequest",
|
||||
DEVICE_CONFIG_REQUEST_TOPIC,
|
||||
DEVICE_CONFIG_RESPONSE_TOPIC,
|
||||
DEVICE_CONFIG_TIME_CONTEXT,
|
||||
True,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"DeviceInfoRequest",
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"GetRtkAdvanceRequest",
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"GetNtripProfileRequest",
|
||||
GET_NTRIP_PROFILE_REQUEST_TOPIC,
|
||||
GET_NTRIP_PROFILE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"GetCloudServerConfigRequest",
|
||||
GET_CLOUD_CONFIG_REQUEST_TOPIC,
|
||||
GET_CLOUD_CONFIG_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"GetRtkAdvanceRequest",
|
||||
GET_RTK_ADVANCE_REQUEST_TOPIC,
|
||||
GET_RTK_ADVANCE_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
(
|
||||
"bound-preparation",
|
||||
"DeviceInfoRequest",
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
None,
|
||||
False,
|
||||
),
|
||||
)
|
||||
requests: list[EncodedApplicationRequest] = []
|
||||
for ordinal, (phase, message_type, topic, response_topic, context, mutates) in enumerate(
|
||||
specs, start=1
|
||||
):
|
||||
request_binding = None if ordinal <= 3 else binding
|
||||
header = ApplicationRequestHeader(
|
||||
message_type=message_type,
|
||||
authority=authority,
|
||||
binding=request_binding,
|
||||
context=context,
|
||||
)
|
||||
body = b""
|
||||
if message_type == "DeviceConfigRequest":
|
||||
time_config = _varint_field(1, epoch_seconds) + _text_field(2, timezone_name)
|
||||
body = _bytes_field(4, time_config)
|
||||
payload = _bytes_field(1, _encode_header(header)) + body
|
||||
_check_payload(payload, "application request")
|
||||
requests.append(
|
||||
EncodedApplicationRequest(
|
||||
ordinal=ordinal,
|
||||
phase=phase, # type: ignore[arg-type]
|
||||
message_type=message_type,
|
||||
topic=topic,
|
||||
response_topic=response_topic,
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
mutates_device=mutates,
|
||||
requires_live_binding=ordinal > 3,
|
||||
)
|
||||
)
|
||||
return ShadowApplicationBootstrap(tuple(requests))
|
||||
|
||||
|
||||
def decode_and_bind_device_info_response(
|
||||
payload: bytes,
|
||||
authority: ApplicationControlAuthority,
|
||||
) -> DeviceInfoResponse:
|
||||
"""Correlate the bootstrap response and derive a live per-device binding."""
|
||||
|
||||
_check_payload(payload, "DeviceInfoResponse")
|
||||
top = _unique_fields(payload, "DeviceInfoResponse", max_fields=64)
|
||||
header = _unique_fields(_required_bytes(top, 1, "response.header"), "header", max_fields=32)
|
||||
device_id = _identity_field(header, 4, "header.device_id")
|
||||
session_id = _identity_field(header, 5, "header.session_id")
|
||||
openapi_key = _identity_field(header, 6, "header.openapi_key")
|
||||
if session_id != ":DeviceInfoRequest":
|
||||
raise ApplicationBootstrapError("DeviceInfoResponse session correlation failed")
|
||||
if not hmac.compare_digest(openapi_key, authority.openapi_key):
|
||||
raise ApplicationBootstrapError("DeviceInfoResponse application authority mismatch")
|
||||
|
||||
error = _unique_fields(_required_bytes(top, 15, "response.error"), "error", max_fields=8)
|
||||
result_code = _required_uint(error, 1, "response.error.code")
|
||||
if result_code != OPENAPI_SUCCESS:
|
||||
raise ApplicationBootstrapError(
|
||||
f"DeviceInfoResponse rejected with result code {result_code}"
|
||||
)
|
||||
|
||||
device_info = _unique_fields(
|
||||
_required_bytes(top, 2, "response.device_info"), "device_info", max_fields=32
|
||||
)
|
||||
base_info = _unique_fields(
|
||||
_required_bytes(device_info, 2, "device_info.base_info"),
|
||||
"base_info",
|
||||
max_fields=32,
|
||||
allow_repeated={11},
|
||||
)
|
||||
working_status = _unique_fields(
|
||||
_required_bytes(device_info, 7, "device_info.working_status"),
|
||||
"working_status",
|
||||
max_fields=16,
|
||||
)
|
||||
binding = LiveDeviceControlBinding(
|
||||
vendor_device_id=device_id,
|
||||
device_serial=_required_text(base_info, 7, "base_info.device_sn"),
|
||||
software_version=_required_text(base_info, 2, "base_info.software_version"),
|
||||
system_version=_required_text(base_info, 3, "base_info.system_version"),
|
||||
device_model=_required_text(base_info, 6, "base_info.device_model"),
|
||||
device_type=_required_text(base_info, 8, "base_info.device_type"),
|
||||
is_activated=bool(_required_bool(working_status, 1, "working_status.is_activated")),
|
||||
)
|
||||
return DeviceInfoResponse(binding, session_id, openapi_key, result_code)
|
||||
|
||||
|
||||
def _encode_header(header: ApplicationRequestHeader) -> bytes:
|
||||
parts: list[bytes] = []
|
||||
if header.binding is not None:
|
||||
parts.append(_text_field(4, header.binding.vendor_device_id))
|
||||
parts.extend(
|
||||
(
|
||||
_text_field(5, header.session_id),
|
||||
_text_field(6, header.authority.openapi_key),
|
||||
)
|
||||
)
|
||||
payload = b"".join(parts)
|
||||
if len(payload) > MAX_HEADER_BYTES:
|
||||
raise ApplicationBootstrapError("encoded request header exceeds configured limit")
|
||||
return payload
|
||||
|
||||
|
||||
def _contains_version(value: str, expected: str) -> bool:
|
||||
return re.search(rf"(?<!\d){re.escape(expected)}(?!\d)", value) is not None
|
||||
|
||||
|
||||
def _validate_ascii_identity(value: object, name: str) -> None:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ApplicationBootstrapError(f"{name} must be a non-empty string")
|
||||
try:
|
||||
encoded = value.encode("ascii")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise ApplicationBootstrapError(f"{name} must use printable ASCII") from exc
|
||||
if len(encoded) > MAX_TEXT_BYTES:
|
||||
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
|
||||
if any(byte <= 0x20 or byte > 0x7E for byte in encoded):
|
||||
raise ApplicationBootstrapError(f"{name} must use printable ASCII without spaces")
|
||||
|
||||
|
||||
def _validate_text(value: object, name: str) -> None:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise ApplicationBootstrapError(f"{name} must be a non-empty string")
|
||||
encoded = value.encode("utf-8")
|
||||
if len(encoded) > MAX_TEXT_BYTES:
|
||||
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
|
||||
if any(ord(character) < 0x20 or ord(character) == 0x7F for character in value):
|
||||
raise ApplicationBootstrapError(f"{name} contains a control character")
|
||||
|
||||
|
||||
def _check_payload(payload: bytes, name: str) -> None:
|
||||
if len(payload) > MAX_APPLICATION_PAYLOAD_BYTES:
|
||||
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
|
||||
|
||||
|
||||
def _unique_fields(
|
||||
payload: bytes,
|
||||
name: str,
|
||||
*,
|
||||
max_fields: int,
|
||||
allow_repeated: set[int] | None = None,
|
||||
) -> dict[int, ProtoField]:
|
||||
result: dict[int, ProtoField] = {}
|
||||
repeated = allow_repeated or set()
|
||||
try:
|
||||
for item in iter_fields(payload, max_fields=max_fields):
|
||||
if item.number in result and item.number not in repeated:
|
||||
raise ApplicationBootstrapError(f"{name} field {item.number} is duplicated")
|
||||
result[item.number] = item
|
||||
except ProtobufWireError as exc:
|
||||
raise ApplicationBootstrapError(f"invalid {name}: {exc}") from exc
|
||||
return result
|
||||
|
||||
|
||||
def _required_bytes(fields: dict[int, ProtoField], number: int, name: str) -> bytes:
|
||||
item = fields.get(number)
|
||||
if item is None or item.wire_type != 2 or not isinstance(item.value, bytes):
|
||||
raise ApplicationBootstrapError(f"{name} is missing or has the wrong wire type")
|
||||
return item.value
|
||||
|
||||
|
||||
def _required_text(fields: dict[int, ProtoField], number: int, name: str) -> str:
|
||||
raw = _required_bytes(fields, number, name)
|
||||
if len(raw) > MAX_TEXT_BYTES:
|
||||
raise ApplicationBootstrapError(f"{name} exceeds configured limit")
|
||||
try:
|
||||
value = raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ApplicationBootstrapError(f"{name} is not valid UTF-8") from exc
|
||||
_validate_text(value, name)
|
||||
return value
|
||||
|
||||
|
||||
def _identity_field(fields: dict[int, ProtoField], number: int, name: str) -> str:
|
||||
value = _required_text(fields, number, name)
|
||||
_validate_ascii_identity(value, name)
|
||||
return value
|
||||
|
||||
|
||||
def _required_uint(fields: dict[int, ProtoField], number: int, name: str) -> int:
|
||||
item = fields.get(number)
|
||||
if item is None or item.wire_type != 0 or not isinstance(item.value, int):
|
||||
raise ApplicationBootstrapError(f"{name} is missing or has the wrong wire type")
|
||||
return item.value
|
||||
|
||||
|
||||
def _required_bool(fields: dict[int, ProtoField], number: int, name: str) -> int:
|
||||
value = _required_uint(fields, number, name)
|
||||
if value not in (0, 1):
|
||||
raise ApplicationBootstrapError(f"{name} is not a protobuf boolean")
|
||||
return value
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
encoded = bytearray()
|
||||
while value > 0x7F:
|
||||
encoded.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
encoded.append(value)
|
||||
return bytes(encoded)
|
||||
|
||||
|
||||
def _key(number: int, wire_type: int) -> bytes:
|
||||
return _varint((number << 3) | wire_type)
|
||||
|
||||
|
||||
def _varint_field(number: int, value: int) -> bytes:
|
||||
return _key(number, 0) + _varint(value)
|
||||
|
||||
|
||||
def _bytes_field(number: int, value: bytes) -> bytes:
|
||||
return _key(number, 2) + _varint(len(value)) + value
|
||||
|
||||
|
||||
def _text_field(number: int, value: str) -> bytes:
|
||||
return _bytes_field(number, value.encode("utf-8"))
|
||||
@@ -5,12 +5,15 @@ import threading
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Literal
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
LiveDeviceControlBinding,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import (
|
||||
CommandHeaderIdentity,
|
||||
DeviceStatusReport,
|
||||
EncodedModelingCommand,
|
||||
ModelingAction,
|
||||
ModelingEncodeError,
|
||||
MountType,
|
||||
RecordMode,
|
||||
ScanMode,
|
||||
@@ -25,59 +28,12 @@ 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
|
||||
@@ -215,13 +171,14 @@ class LiveModelingControlSafety:
|
||||
|
||||
def plan_start(
|
||||
self,
|
||||
enrollment: DeviceBoundControlEnrollment,
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
*,
|
||||
project_name: str,
|
||||
) -> ShadowModelingCommand:
|
||||
self._require_bound(enrollment, required_state=SessionState.READY, project_bound=False)
|
||||
self._require_bound(binding, required_state=SessionState.READY, project_bound=False)
|
||||
command = encode_modeling_start(
|
||||
enrollment.command_header,
|
||||
_command_header(authority, binding),
|
||||
project_name=project_name,
|
||||
record_mode=RecordMode.RECORD_AND_CALCULATE,
|
||||
scan_mode=ScanMode.LCC,
|
||||
@@ -231,14 +188,17 @@ class LiveModelingControlSafety:
|
||||
|
||||
def plan_stop(
|
||||
self,
|
||||
enrollment: DeviceBoundControlEnrollment,
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
) -> ShadowModelingCommand:
|
||||
self._require_bound(enrollment, required_state=SessionState.SCANNING, project_bound=True)
|
||||
return ShadowModelingCommand.from_command(encode_modeling_stop(enrollment.command_header))
|
||||
self._require_bound(binding, required_state=SessionState.SCANNING, project_bound=True)
|
||||
return ShadowModelingCommand.from_command(
|
||||
encode_modeling_stop(_command_header(authority, binding))
|
||||
)
|
||||
|
||||
def _require_bound(
|
||||
self,
|
||||
enrollment: DeviceBoundControlEnrollment,
|
||||
binding: LiveDeviceControlBinding,
|
||||
*,
|
||||
required_state: SessionState,
|
||||
project_bound: bool,
|
||||
@@ -250,11 +210,15 @@ class LiveModelingControlSafety:
|
||||
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
|
||||
binding.vendor_device_id != self._vendor_device_id
|
||||
or binding.device_serial != self._device_serial
|
||||
):
|
||||
raise ModelingControlSafetyError(
|
||||
"device-bound control enrollment does not match the live K1"
|
||||
"DeviceInfo binding does not match the live K1 status stream"
|
||||
)
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise ModelingControlSafetyError(
|
||||
"live DeviceInfo does not attest the reviewed activated FW 3.0.2 profile"
|
||||
)
|
||||
if report.session_state is not required_state:
|
||||
raise ModelingControlSafetyError(
|
||||
@@ -264,12 +228,11 @@ class LiveModelingControlSafety:
|
||||
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")
|
||||
def _command_header(
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
) -> CommandHeaderIdentity:
|
||||
return CommandHeaderIdentity(
|
||||
device_id=binding.vendor_device_id,
|
||||
openapi_key=authority.openapi_key,
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user