feat(device-plugins): add profiled K1 lifecycle and canonical data plane
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
"""Decoded in-process projections consumed by Mission Core visualizers.
|
||||
|
||||
These views are not wire contracts. Portable plugin/host envelopes live in the
|
||||
versioned Plugin SDK; an extraction boundary can hydrate those envelopes into
|
||||
these allocation-conscious representations for local consumers.
|
||||
"""
|
||||
|
||||
from k1link.data_plane.views import (
|
||||
ConsumerFrameContext,
|
||||
DecodedDataPlaneView,
|
||||
DecodedDeviceStatusView,
|
||||
DecodedPointCloudView,
|
||||
DecodedPoseView,
|
||||
NormalizationError,
|
||||
StatusAttribute,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ConsumerFrameContext",
|
||||
"DecodedDataPlaneView",
|
||||
"DecodedDeviceStatusView",
|
||||
"DecodedPointCloudView",
|
||||
"DecodedPoseView",
|
||||
"NormalizationError",
|
||||
"StatusAttribute",
|
||||
]
|
||||
@@ -0,0 +1,128 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class NormalizationError(ValueError):
|
||||
"""A transport message matched a known channel but could not be normalized."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ConsumerFrameContext:
|
||||
"""Transport-neutral provenance shared by decoded consumer views.
|
||||
|
||||
Raw channel names and payloads deliberately do not cross this boundary. The
|
||||
byte count is retained for operational metrics, while opaque device/session
|
||||
aliases are optional because older streams do not carry them. They are
|
||||
deliberately named as source aliases: a vendor header must never become a
|
||||
Mission Core device identity merely by crossing the decode boundary.
|
||||
"""
|
||||
|
||||
sequence: int
|
||||
captured_at_epoch_ns: int
|
||||
received_monotonic_ns: int | None
|
||||
processing_started_monotonic_ns: int
|
||||
encoded_size_bytes: int
|
||||
live: bool
|
||||
source_device_alias: str | None = None
|
||||
source_session_alias: str | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if self.sequence < 1:
|
||||
raise ValueError("frame sequence must be positive")
|
||||
if self.captured_at_epoch_ns < 0:
|
||||
raise ValueError("capture time must be non-negative")
|
||||
if self.received_monotonic_ns is not None and self.received_monotonic_ns < 0:
|
||||
raise ValueError("receive monotonic time must be non-negative")
|
||||
if self.processing_started_monotonic_ns < 0:
|
||||
raise ValueError("processing start time must be non-negative")
|
||||
if self.encoded_size_bytes < 0:
|
||||
raise ValueError("encoded size must be non-negative")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DecodedPointCloudView:
|
||||
"""In-process point-cloud projection in a named Cartesian frame.
|
||||
|
||||
This is a consumer view, not the portable SDK ``PointCloudFrame`` contract.
|
||||
"""
|
||||
|
||||
context: ConsumerFrameContext
|
||||
frame_id: str
|
||||
positions_xyz: tuple[tuple[float, float, float], ...]
|
||||
intensities: bytes | None = None
|
||||
colors_rgb: bytes | None = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.frame_id:
|
||||
raise ValueError("point-cloud frame_id must not be empty")
|
||||
point_count = len(self.positions_xyz)
|
||||
if self.intensities is not None and len(self.intensities) != point_count:
|
||||
raise ValueError("intensity count must equal point count")
|
||||
if self.colors_rgb is not None and len(self.colors_rgb) != point_count * 3:
|
||||
raise ValueError("RGB byte count must equal point count * 3")
|
||||
if not all(
|
||||
len(position) == 3 and all(math.isfinite(value) for value in position)
|
||||
for position in self.positions_xyz
|
||||
):
|
||||
raise ValueError("point positions must contain finite xyz triples")
|
||||
|
||||
@property
|
||||
def point_count(self) -> int:
|
||||
return len(self.positions_xyz)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DecodedPoseView:
|
||||
"""In-process pose projection in a named Cartesian coordinate frame."""
|
||||
|
||||
context: ConsumerFrameContext
|
||||
frame_id: str
|
||||
child_frame_id: str
|
||||
position_xyz: tuple[float, float, float]
|
||||
orientation_xyzw: tuple[float, float, float, float]
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.frame_id or not self.child_frame_id:
|
||||
raise ValueError("pose frame identifiers must not be empty")
|
||||
values = (*self.position_xyz, *self.orientation_xyzw)
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
raise ValueError("pose values must be finite")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class StatusAttribute:
|
||||
"""One stable, normalized status attribute."""
|
||||
|
||||
name: str
|
||||
value: bool | int | float | str
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.name:
|
||||
raise ValueError("status attribute name must not be empty")
|
||||
if isinstance(self.value, float) and not math.isfinite(self.value):
|
||||
raise ValueError("status attribute float must be finite")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DecodedDeviceStatusView:
|
||||
"""An in-process device-status projection.
|
||||
|
||||
The current verified K1 profile does not yet decode its status topic. The
|
||||
view exists so future verified status codecs do not alter consumers.
|
||||
"""
|
||||
|
||||
context: ConsumerFrameContext
|
||||
state: str
|
||||
attributes: tuple[StatusAttribute, ...] = ()
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not self.state:
|
||||
raise ValueError("device status state must not be empty")
|
||||
names = [attribute.name for attribute in self.attributes]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("device status attribute names must be unique")
|
||||
|
||||
|
||||
DecodedDataPlaneView = DecodedPointCloudView | DecodedPoseView | DecodedDeviceStatusView
|
||||
Reference in New Issue
Block a user