feat: prove and decode K1 realtime MQTT streams

This commit is contained in:
DCCONSTRUCTIONS
2026-07-15 19:10:41 +03:00
parent faa442eefc
commit 6b22e5a1d2
30 changed files with 4375 additions and 41 deletions
+35
View File
@@ -0,0 +1,35 @@
"""Verified protocol decoders for captured K1 application streams."""
from k1link.protocol.streams import (
DecodeLimits,
LegacyPoint,
LegacyPointCloudFrame,
LegacyPoseFrame,
LioPoint,
LioPointCloudFrame,
LioPoseFrame,
StreamDecodeError,
UnsupportedCompressionError,
decode_legacy_pointcloud,
decode_legacy_pose,
decode_lio_pcl,
decode_lio_pose,
decode_pre_path_array,
)
__all__ = [
"DecodeLimits",
"LegacyPoint",
"LegacyPointCloudFrame",
"LegacyPoseFrame",
"LioPoint",
"LioPointCloudFrame",
"LioPoseFrame",
"StreamDecodeError",
"UnsupportedCompressionError",
"decode_legacy_pointcloud",
"decode_legacy_pose",
"decode_lio_pcl",
"decode_lio_pose",
"decode_pre_path_array",
]
+89
View File
@@ -0,0 +1,89 @@
from __future__ import annotations
from collections.abc import Iterator
from dataclasses import dataclass
class ProtobufWireError(ValueError):
"""Raised when a bounded protobuf wire parse fails."""
@dataclass(frozen=True, slots=True)
class ProtoField:
number: int
wire_type: int
value: int | bytes
def read_varint(data: bytes, offset: int) -> tuple[int, int]:
"""Read one protobuf unsigned varint, bounded to 64 bits."""
value = 0
for shift in range(0, 70, 7):
if offset >= len(data):
raise ProtobufWireError("truncated varint")
octet = data[offset]
offset += 1
if shift == 63 and octet > 1:
raise ProtobufWireError("varint exceeds 64 bits")
value |= (octet & 0x7F) << shift
if not octet & 0x80:
return value, offset
raise ProtobufWireError("varint exceeds 10 bytes")
def decode_zigzag64(value: int) -> int:
"""Decode protobuf sint64 ZigZag representation."""
if value < 0 or value > 0xFFFFFFFFFFFFFFFF:
raise ProtobufWireError("ZigZag input is outside uint64")
return (value >> 1) ^ -(value & 1)
def iter_fields(data: bytes, *, max_fields: int = 1_000_000) -> Iterator[ProtoField]:
"""Iterate supported protobuf fields without recursion or unbounded allocation."""
if max_fields < 1:
raise ValueError("max_fields must be positive")
offset = 0
field_count = 0
while offset < len(data):
field_count += 1
if field_count > max_fields:
raise ProtobufWireError(f"message exceeds {max_fields} fields")
key, offset = read_varint(data, offset)
number = key >> 3
wire_type = key & 0x07
if number == 0:
raise ProtobufWireError("protobuf field number zero is invalid")
if wire_type == 0:
value, offset = read_varint(data, offset)
yield ProtoField(number, wire_type, value)
continue
if wire_type == 1:
end = offset + 8
if end > len(data):
raise ProtobufWireError("truncated fixed64 field")
yield ProtoField(number, wire_type, data[offset:end])
offset = end
continue
if wire_type == 2:
length, offset = read_varint(data, offset)
end = offset + length
if end > len(data):
raise ProtobufWireError("truncated length-delimited field")
yield ProtoField(number, wire_type, data[offset:end])
offset = end
continue
if wire_type == 5:
end = offset + 4
if end > len(data):
raise ProtobufWireError("truncated fixed32 field")
yield ProtoField(number, wire_type, data[offset:end])
offset = end
continue
raise ProtobufWireError(f"unsupported protobuf wire type {wire_type}")
+410
View File
@@ -0,0 +1,410 @@
from __future__ import annotations
import math
import struct
from dataclasses import dataclass
from typing import NamedTuple
import lz4.block
from k1link.protocol.protobuf_wire import (
ProtobufWireError,
ProtoField,
decode_zigzag64,
iter_fields,
)
class StreamDecodeError(ValueError):
"""Raised when a K1 stream payload violates its verified bounds or schema."""
class UnsupportedCompressionError(StreamDecodeError):
"""Raised for a protocol compression type that has not been verified."""
@dataclass(frozen=True, slots=True)
class DecodeLimits:
max_mqtt_payload_bytes: int = 2 * 1024 * 1024
max_compressed_bytes: int = 1024 * 1024
max_decompressed_bytes: int = 8 * 1024 * 1024
max_compression_ratio: int = 64
max_points_per_frame: int = 250_000
def __post_init__(self) -> None:
values = (
self.max_mqtt_payload_bytes,
self.max_compressed_bytes,
self.max_decompressed_bytes,
self.max_compression_ratio,
self.max_points_per_frame,
)
if any(value < 1 for value in values):
raise ValueError("all decode limits must be positive")
@dataclass(frozen=True, slots=True)
class MqttHeader:
seq: int
stamp: int
scaler: int
device_id: str
session_id: str
openapi_key: str | None
class LioPoint(NamedTuple):
x_raw: int
y_raw: int
z_raw: int
rgbi: int
@property
def intensity(self) -> int:
"""Return the only RGBA interpretation verified in the application."""
return self.rgbi & 0xFF
def scaled_xyz(self, scaler: int) -> tuple[float, float, float]:
if scaler == 0:
raise StreamDecodeError("point scaler is zero")
return self.x_raw / scaler, self.y_raw / scaler, self.z_raw / scaler
@dataclass(frozen=True, slots=True)
class LioPointCloudFrame:
header: MqttHeader
compression: int
compressed_bytes: int
decompressed_bytes: int
points: tuple[LioPoint, ...]
@dataclass(frozen=True, slots=True)
class LioPoseFrame:
header: MqttHeader
pose_stamp: int
position_xyz: tuple[float, float, float]
orientation_xyzw: tuple[float, float, float, float]
distance: float
pose_accuracy: float
class LegacyPoint(NamedTuple):
x: float
y: float
z: float
r: int
g: int
b: int
intensity: int
@dataclass(frozen=True, slots=True)
class LegacyPointCloudFrame:
envelope: bytes
stride: int
points: tuple[LegacyPoint, ...]
@dataclass(frozen=True, slots=True)
class LegacyPoseFrame:
position_xyz: tuple[float, float, float]
orientation_xyzw: tuple[float, float, float, float]
skipped_offset_12: bytes
unknown_tail: bytes
def _int_value(field: ProtoField, name: str) -> int:
if field.wire_type != 0 or not isinstance(field.value, int):
raise StreamDecodeError(f"{name} has the wrong protobuf wire type")
return field.value
def _bytes_value(field: ProtoField, name: str, wire_type: int = 2) -> bytes:
if field.wire_type != wire_type or not isinstance(field.value, bytes):
raise StreamDecodeError(f"{name} has the wrong protobuf wire type")
return field.value
def _float32(field: ProtoField, name: str) -> float:
value = float(struct.unpack("<f", _bytes_value(field, name, 5))[0])
if not math.isfinite(value):
raise StreamDecodeError(f"{name} is not finite")
return value
def _float64(field: ProtoField, name: str) -> float:
value = float(struct.unpack("<d", _bytes_value(field, name, 1))[0])
if not math.isfinite(value):
raise StreamDecodeError(f"{name} is not finite")
return value
def _text(field: ProtoField, name: str) -> str:
try:
return _bytes_value(field, name).decode("utf-8")
except UnicodeDecodeError as exc:
raise StreamDecodeError(f"{name} is not valid UTF-8") from exc
def _decode_header(payload: bytes) -> MqttHeader:
seq = 0
stamp = 0
scaler = 0
device_id = ""
session_id = ""
openapi_key: str | None = None
for field in iter_fields(payload, max_fields=64):
if field.number == 1:
seq = _int_value(field, "header.seq")
elif field.number == 2:
stamp = decode_zigzag64(_int_value(field, "header.stamp"))
elif field.number == 3:
scaler = decode_zigzag64(_int_value(field, "header.scaler"))
elif field.number == 4:
device_id = _text(field, "header.device_id")
elif field.number == 5:
session_id = _text(field, "header.session_id")
elif field.number == 6:
openapi_key = _text(field, "header.openapi_key")
return MqttHeader(seq, stamp, scaler, device_id, session_id, openapi_key)
def _decode_lio_point(payload: bytes) -> LioPoint:
x_raw = 0
y_raw = 0
z_raw = 0
rgbi = 0
for field in iter_fields(payload, max_fields=16):
if field.number == 1:
x_raw = decode_zigzag64(_int_value(field, "point.x"))
elif field.number == 2:
y_raw = decode_zigzag64(_int_value(field, "point.y"))
elif field.number == 3:
z_raw = decode_zigzag64(_int_value(field, "point.z"))
elif field.number == 4:
rgbi = _int_value(field, "point.rgbi") & 0xFFFFFFFF
return LioPoint(x_raw, y_raw, z_raw, rgbi)
def _decode_lio_pcl_report(
payload: bytes,
limits: DecodeLimits,
) -> tuple[MqttHeader, tuple[LioPoint, ...]]:
header: MqttHeader | None = None
points: list[LioPoint] = []
try:
for field in iter_fields(payload, max_fields=limits.max_points_per_frame + 64):
if field.number == 1:
header = _decode_header(_bytes_value(field, "lio_pcl.header"))
elif field.number == 2:
if len(points) >= limits.max_points_per_frame:
raise StreamDecodeError(
f"point frame exceeds {limits.max_points_per_frame} points"
)
points.append(_decode_lio_point(_bytes_value(field, "lio_pcl.point")))
except ProtobufWireError as exc:
raise StreamDecodeError(f"invalid LioPclReport: {exc}") from exc
if header is None:
raise StreamDecodeError("LioPclReport has no header")
if header.scaler == 0:
raise StreamDecodeError("LioPclReport header scaler is zero")
if not points:
raise StreamDecodeError("LioPclReport has no points")
return header, tuple(points)
def decode_lio_pcl(payload: bytes, limits: DecodeLimits | None = None) -> LioPointCloudFrame:
"""Decode the verified K1 lio_pcl envelope and raw LZ4 protobuf block."""
bounds = limits or DecodeLimits()
if len(payload) > bounds.max_mqtt_payload_bytes:
raise StreamDecodeError("lio_pcl MQTT payload exceeds configured limit")
compression = 0
decompressed_size = 0
compressed_data: bytes | None = None
try:
for field in iter_fields(payload, max_fields=64):
if field.number == 2:
compression = _int_value(field, "compression")
elif field.number == 3:
decompressed_size = _int_value(field, "compressed_size")
elif field.number == 4:
compressed_data = _bytes_value(field, "compressed_data")
except ProtobufWireError as exc:
raise StreamDecodeError(f"invalid MqttCompressMsg: {exc}") from exc
if compression != 0:
raise UnsupportedCompressionError(
f"compression enum {compression} is not the verified raw-LZ4 mode"
)
if compressed_data is None or not compressed_data:
raise StreamDecodeError("MqttCompressMsg has no compressed_data")
if len(compressed_data) > bounds.max_compressed_bytes:
raise StreamDecodeError("compressed_data exceeds configured limit")
if decompressed_size < 1 or decompressed_size > bounds.max_decompressed_bytes:
raise StreamDecodeError("compressed_size is outside configured bounds")
if decompressed_size > len(compressed_data) * bounds.max_compression_ratio:
raise StreamDecodeError("claimed LZ4 expansion ratio exceeds configured limit")
try:
decompressed = lz4.block.decompress(
compressed_data,
uncompressed_size=decompressed_size,
)
except lz4.block.LZ4BlockError as exc:
raise StreamDecodeError(f"raw LZ4 decode failed: {exc}") from exc
if len(decompressed) != decompressed_size:
raise StreamDecodeError(
f"raw LZ4 length mismatch: expected {decompressed_size}, got {len(decompressed)}"
)
header, points = _decode_lio_pcl_report(decompressed, bounds)
return LioPointCloudFrame(
header=header,
compression=compression,
compressed_bytes=len(compressed_data),
decompressed_bytes=len(decompressed),
points=points,
)
def _decode_position(payload: bytes) -> tuple[float, float, float]:
values = [0.0, 0.0, 0.0]
for field in iter_fields(payload, max_fields=16):
if 1 <= field.number <= 3:
values[field.number - 1] = _float64(field, f"position.{field.number}")
return values[0], values[1], values[2]
def _decode_orientation(payload: bytes) -> tuple[float, float, float, float]:
values = [0.0, 0.0, 0.0, 0.0]
for field in iter_fields(payload, max_fields=16):
if 1 <= field.number <= 4:
values[field.number - 1] = _float64(field, f"orientation.{field.number}")
return values[0], values[1], values[2], values[3]
def _decode_pose(
payload: bytes,
) -> tuple[tuple[float, float, float], tuple[float, float, float, float]]:
position = (0.0, 0.0, 0.0)
orientation = (0.0, 0.0, 0.0, 0.0)
for field in iter_fields(payload, max_fields=16):
if field.number == 1:
position = _decode_position(_bytes_value(field, "pose.position"))
elif field.number == 2:
orientation = _decode_orientation(_bytes_value(field, "pose.orientation"))
return position, orientation
def _decode_pose_stamped(
payload: bytes,
) -> tuple[int, tuple[float, float, float], tuple[float, float, float, float]]:
stamp = 0
position = (0.0, 0.0, 0.0)
orientation = (0.0, 0.0, 0.0, 0.0)
for field in iter_fields(payload, max_fields=16):
if field.number == 1:
stamp = decode_zigzag64(_int_value(field, "pose_stamp.stamp"))
elif field.number == 2:
position, orientation = _decode_pose(_bytes_value(field, "pose_stamp.pose"))
return stamp, position, orientation
def decode_lio_pose(payload: bytes, limits: DecodeLimits | None = None) -> LioPoseFrame:
"""Decode a direct lixel/application/report/lio_pose protobuf payload."""
bounds = limits or DecodeLimits()
if len(payload) > bounds.max_mqtt_payload_bytes:
raise StreamDecodeError("lio_pose MQTT payload exceeds configured limit")
header: MqttHeader | None = None
pose_stamp = 0
position = (0.0, 0.0, 0.0)
orientation = (0.0, 0.0, 0.0, 0.0)
distance = 0.0
pose_accuracy = 0.0
try:
for field in iter_fields(payload, max_fields=64):
if field.number == 1:
header = _decode_header(_bytes_value(field, "lio_pose.header"))
elif field.number == 2:
pose_stamp, position, orientation = _decode_pose_stamped(
_bytes_value(field, "lio_pose.pose")
)
elif field.number == 3:
distance = _float32(field, "lio_pose.distance")
elif field.number == 4:
pose_accuracy = _float32(field, "lio_pose.pose_accuracy")
except ProtobufWireError as exc:
raise StreamDecodeError(f"invalid LioPoseReport: {exc}") from exc
if header is None:
raise StreamDecodeError("LioPoseReport has no header")
return LioPoseFrame(
header=header,
pose_stamp=pose_stamp,
position_xyz=position,
orientation_xyzw=orientation,
distance=distance,
pose_accuracy=pose_accuracy,
)
def decode_legacy_pointcloud(
payload: bytes,
*,
max_points: int = 250_000,
) -> LegacyPointCloudFrame:
"""Decode the verified legacy RealtimePointcloud envelope and point records."""
if max_points < 1:
raise ValueError("max_points must be positive")
if len(payload) < 12:
raise StreamDecodeError("legacy pointcloud payload is shorter than 12-byte envelope")
stride = int.from_bytes(payload[:4], "little")
if stride < 15:
raise StreamDecodeError("legacy point stride is smaller than xyz+rgb")
body = payload[12:]
if len(body) % stride:
raise StreamDecodeError("legacy pointcloud body is not divisible by stride")
point_count = len(body) // stride
if point_count > max_points:
raise StreamDecodeError(f"legacy pointcloud exceeds {max_points} points")
points: list[LegacyPoint] = []
for offset in range(0, len(body), stride):
x, y, z = struct.unpack_from("<fff", body, offset)
if not all(math.isfinite(value) for value in (x, y, z)):
raise StreamDecodeError("legacy point position is not finite")
r, g, b = body[offset + 12 : offset + 15]
intensity = body[offset + 15] if stride >= 16 else 255
points.append(LegacyPoint(x, y, z, r, g, b, intensity))
return LegacyPointCloudFrame(payload[:12], stride, tuple(points))
def decode_legacy_pose(payload: bytes) -> LegacyPoseFrame:
"""Decode the verified legacy RealtimePath position/quaternion fields."""
if len(payload) < 32:
raise StreamDecodeError("legacy pose payload is shorter than 32 bytes")
x, y, z = struct.unpack_from("<fff", payload, 0)
wire_w, qx, qy, qz = struct.unpack_from("<ffff", payload, 16)
values = (x, y, z, qx, qy, qz, wire_w)
if not all(math.isfinite(value) for value in values):
raise StreamDecodeError("legacy pose contains a non-finite value")
return LegacyPoseFrame(
position_xyz=(x, y, z),
orientation_xyzw=(qx, qy, qz, wire_w),
skipped_offset_12=payload[12:16],
unknown_tail=payload[32:],
)
def decode_pre_path_array(payload: bytes) -> tuple[float, ...]:
"""Decode the exact 16-float64 legacy PrePathArray matrix."""
if len(payload) != 128:
raise StreamDecodeError("PrePathArray payload must be exactly 128 bytes")
values = struct.unpack("<16d", payload)
if not all(math.isfinite(value) for value in values):
raise StreamDecodeError("PrePathArray contains a non-finite value")
return values