feat(plugin-sdk): add executable v0alpha2 contracts
This commit is contained in:
@@ -1,29 +1,53 @@
|
||||
# Mission Core Plugin SDK
|
||||
|
||||
This directory owns the versioned host/plugin contract. The v1alpha frontend
|
||||
contract, validated registry, generic runtime envelope, and backend read-only
|
||||
catalog are implemented in-tree, but this is not yet a separately published
|
||||
SDK package.
|
||||
This directory owns the versioned host/plugin contract. It is an independently
|
||||
installable in-tree package; it is not yet published to a package registry.
|
||||
|
||||
The v1alpha1 contract currently validates one model per plugin, one reviewed
|
||||
`transitional-in-process` backend entrypoint, a required safe `state.read`
|
||||
action, and declared UI/action metadata. It establishes:
|
||||
`missioncore_plugin_sdk.v0alpha2` is the first executable backend-neutral
|
||||
contract layer. It uses closed, deeply immutable Pydantic models and exports
|
||||
JSON Schema for TypeScript, Rust, or other consumers. Nested mappings and
|
||||
collections are frozen after validation, and `model_copy(update=...)` is
|
||||
revalidated before a new contract is returned. Import the explicit version:
|
||||
|
||||
- plugin manifest, version compatibility, firmware profiles, and declarative
|
||||
permissions;
|
||||
- discovery candidates and opaque device references;
|
||||
- provisioning requests using secret references;
|
||||
- device-session lifecycle and health;
|
||||
- EvidenceStore handles and raw-artifact lineage;
|
||||
- canonical PointCloud, Pose, DeviceStatus, and metrics envelopes;
|
||||
- SceneSink and event interfaces;
|
||||
- capability-driven UI contribution data without plugin-owned layout.
|
||||
```python
|
||||
from missioncore_plugin_sdk import v0alpha2 as sdk
|
||||
|
||||
The XGRIDS K1 extraction is the first real-device acceptance path. Synthetic
|
||||
multi-plugin composition tests verify that backend routing has no K1 identity or
|
||||
protocol assumption; static boundary tests enforce the same rule in the frontend.
|
||||
request = sdk.OperationRequest.model_validate(document)
|
||||
schemas = sdk.contract_json_schemas()
|
||||
```
|
||||
|
||||
Current implementation references:
|
||||
For an isolated editable install:
|
||||
|
||||
```bash
|
||||
uv pip install -e packages/plugin-sdk
|
||||
python -m missioncore_plugin_sdk.v0alpha2 > plugin-sdk-v0alpha2.schema-bundle.json
|
||||
```
|
||||
|
||||
## Contract generations
|
||||
|
||||
The host accepts the original one-model v1alpha1 manifest and the additive
|
||||
v1alpha2 manifest. v1alpha2 permits one or more models, requires plugin-local
|
||||
compatibility-profile coverage for every model, and retains one reviewed
|
||||
`transitional-in-process` backend entrypoint plus a safe `state.read` action.
|
||||
The separate SDK v0alpha2 package establishes executable contracts for:
|
||||
|
||||
- stable and provisional device identities, transport aliases, and execution
|
||||
node bindings;
|
||||
- independently revisioned enrollment, connectivity, and acquisition states;
|
||||
- operation policy, request, acknowledgement, progress, completion, failure,
|
||||
timeout, cancellation, secret reference, and idempotency boundaries;
|
||||
- canonical point cloud, pose, image, encoded video, and device-status streams;
|
||||
- immutable evidence handles, raw transport records, lineage, and store
|
||||
protocol;
|
||||
- firmware/profile compatibility assessments that fail closed before active
|
||||
device control;
|
||||
- deterministic JSON Schema export for non-Python consumers.
|
||||
|
||||
The contracts deliberately contain no MQTT topic, BLE UUID, protobuf type,
|
||||
concrete viewer, or XGRIDS-specific field. Those belong to a plugin
|
||||
compatibility profile and adapter.
|
||||
|
||||
Current host implementation references:
|
||||
|
||||
- `apps/control-station/src/core/device-plugins/` — TypeScript manifest and UI
|
||||
contribution contracts;
|
||||
@@ -31,13 +55,20 @@ Current implementation references:
|
||||
- `src/k1link/web/plugin_catalog.py` — strict backend manifest validation;
|
||||
- `src/k1link/web/plugin_runtime.py` — host-owned allowlisted action dispatcher;
|
||||
- `src/k1link/web/device_plugin_composition.py` — manifest factory loader and
|
||||
startup parity check between catalog and executable adapters;
|
||||
startup parity checks;
|
||||
- `docs/adr/0003-device-plugin-ui-and-runtime-boundary.md` — accepted boundary
|
||||
and extraction sequence.
|
||||
|
||||
The v1alpha lifecycle is fail-closed: inactive provider shells must perform no
|
||||
The existing lifecycle is fail-closed: inactive provider shells perform no
|
||||
I/O, events are scoped by plugin ID, and selection cannot change until the
|
||||
active plugin confirms teardown. Process isolation and independent device
|
||||
session IDs remain the next SDK milestone. `permissions`, `mutating`, and
|
||||
`secretFields` are contract metadata only in v1alpha1; host authorization and
|
||||
secret-vault enforcement are not implemented yet.
|
||||
active plugin confirms teardown. Process isolation remains a later host
|
||||
milestone. v0alpha2 models secret references and operation policy, but host
|
||||
authorization and the actual secret vault remain separate implementation
|
||||
responsibilities.
|
||||
|
||||
## Version policy
|
||||
|
||||
v0alpha2 may receive additive fields while it remains experimental. A breaking
|
||||
wire or semantic change creates a new explicit module (for example
|
||||
`v0alpha3`); recorded documents keep their original `api_version`. Published
|
||||
field meanings and enum values are never silently redefined.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.27,<2"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "missioncore-plugin-sdk"
|
||||
version = "0.2.0a2"
|
||||
description = "Versioned host/device-plugin contracts for NODE.DC Mission Core"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.12,<3.13"
|
||||
license = { text = "Proprietary" }
|
||||
dependencies = ["pydantic>=2.11,<3"]
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["python/missioncore_plugin_sdk"]
|
||||
@@ -0,0 +1,11 @@
|
||||
"""Versioned Mission Core host/plugin contracts.
|
||||
|
||||
The package is intentionally kept independent from the Mission Core runtime and
|
||||
from every concrete device plugin. Consumers must import an explicit contract
|
||||
version so an experimental contract cannot silently change underneath recorded
|
||||
evidence or a deployed adapter.
|
||||
"""
|
||||
|
||||
from . import v0alpha2
|
||||
|
||||
__all__ = ["v0alpha2"]
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Mission Core Plugin SDK v0alpha2.
|
||||
|
||||
This version is experimental and additive to v1alpha1. It defines the runtime
|
||||
contract boundary needed to extract concrete device plugins without claiming a
|
||||
stable platform ontology.
|
||||
"""
|
||||
|
||||
from .common import API_VERSION
|
||||
from .compatibility import (
|
||||
CompatibilityAssessment,
|
||||
CompatibilityDecision,
|
||||
CompatibilityRuleResult,
|
||||
PermittedMode,
|
||||
RuleOutcome,
|
||||
)
|
||||
from .evidence import (
|
||||
EvidenceHandle,
|
||||
EvidenceRecord,
|
||||
EvidenceRetention,
|
||||
EvidenceStore,
|
||||
RawTransportRecord,
|
||||
RedactionState,
|
||||
)
|
||||
from .identity import (
|
||||
DeviceInstanceRef,
|
||||
DeviceModelRef,
|
||||
ExecutionBinding,
|
||||
ExecutionPlatform,
|
||||
IdentityBasis,
|
||||
IdentityStability,
|
||||
TransportAlias,
|
||||
)
|
||||
from .operations import (
|
||||
AcknowledgementDisposition,
|
||||
IdempotencyMode,
|
||||
OperationAcknowledgement,
|
||||
OperationCompletion,
|
||||
OperationError,
|
||||
OperationEvent,
|
||||
OperationFailure,
|
||||
OperationPolicy,
|
||||
OperationProgress,
|
||||
OperationRequest,
|
||||
OperationSafetyClass,
|
||||
OperationTerminalState,
|
||||
SecretReference,
|
||||
validate_operation_request,
|
||||
)
|
||||
from .payloads import InlinePayload, PayloadHandle, ReferencedPayload
|
||||
from .schema import contract_json_schemas
|
||||
from .session import (
|
||||
AcquisitionState,
|
||||
ConnectivityState,
|
||||
DeviceSessionContext,
|
||||
DeviceSessionRef,
|
||||
DeviceSessionSnapshot,
|
||||
EnrollmentState,
|
||||
)
|
||||
from .streams import (
|
||||
CanonicalStreamEnvelope,
|
||||
CanonicalStreamHeader,
|
||||
ClockDomain,
|
||||
DeviceStatusFrame,
|
||||
EncodedVideoPacket,
|
||||
ImageFrame,
|
||||
PointCloudFrame,
|
||||
PointField,
|
||||
PointFieldType,
|
||||
PoseFrame,
|
||||
Quaternion,
|
||||
Vector3,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"API_VERSION",
|
||||
"AcknowledgementDisposition",
|
||||
"AcquisitionState",
|
||||
"CanonicalStreamEnvelope",
|
||||
"CanonicalStreamHeader",
|
||||
"ClockDomain",
|
||||
"CompatibilityAssessment",
|
||||
"CompatibilityDecision",
|
||||
"CompatibilityRuleResult",
|
||||
"ConnectivityState",
|
||||
"DeviceInstanceRef",
|
||||
"DeviceModelRef",
|
||||
"DeviceSessionContext",
|
||||
"DeviceSessionRef",
|
||||
"DeviceSessionSnapshot",
|
||||
"DeviceStatusFrame",
|
||||
"EncodedVideoPacket",
|
||||
"EnrollmentState",
|
||||
"EvidenceHandle",
|
||||
"EvidenceRecord",
|
||||
"EvidenceRetention",
|
||||
"EvidenceStore",
|
||||
"ExecutionBinding",
|
||||
"ExecutionPlatform",
|
||||
"IdentityBasis",
|
||||
"IdentityStability",
|
||||
"IdempotencyMode",
|
||||
"ImageFrame",
|
||||
"InlinePayload",
|
||||
"OperationAcknowledgement",
|
||||
"OperationCompletion",
|
||||
"OperationError",
|
||||
"OperationEvent",
|
||||
"OperationFailure",
|
||||
"OperationPolicy",
|
||||
"OperationProgress",
|
||||
"OperationRequest",
|
||||
"OperationSafetyClass",
|
||||
"OperationTerminalState",
|
||||
"PayloadHandle",
|
||||
"PermittedMode",
|
||||
"PointCloudFrame",
|
||||
"PointField",
|
||||
"PointFieldType",
|
||||
"PoseFrame",
|
||||
"Quaternion",
|
||||
"RawTransportRecord",
|
||||
"RedactionState",
|
||||
"ReferencedPayload",
|
||||
"RuleOutcome",
|
||||
"SecretReference",
|
||||
"TransportAlias",
|
||||
"Vector3",
|
||||
"contract_json_schemas",
|
||||
"validate_operation_request",
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
"""Print the v0alpha2 JSON Schema bundle."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from .schema import contract_json_schemas
|
||||
|
||||
print(json.dumps(contract_json_schemas(), ensure_ascii=False, indent=2, sort_keys=True))
|
||||
@@ -0,0 +1,131 @@
|
||||
"""Shared primitives for the Mission Core Plugin SDK v0alpha2 contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
from typing import Annotated, Any, Literal, Never, Self
|
||||
|
||||
from pydantic import AfterValidator, BaseModel, ConfigDict, Field, JsonValue, model_validator
|
||||
|
||||
ApiVersion = Literal["missioncore.nodedc/plugin-sdk/v0alpha2"]
|
||||
API_VERSION: ApiVersion = "missioncore.nodedc/plugin-sdk/v0alpha2"
|
||||
|
||||
|
||||
def _reject_blank(value: str) -> str:
|
||||
if not value.strip():
|
||||
raise ValueError("must contain a non-whitespace character")
|
||||
return value
|
||||
|
||||
|
||||
Identifier = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=192, pattern=r"^[A-Za-z0-9][A-Za-z0-9._:/-]*$"),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
ShortText = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=256),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
LongText = Annotated[
|
||||
str,
|
||||
Field(min_length=1, max_length=4096),
|
||||
AfterValidator(_reject_blank),
|
||||
]
|
||||
Sha256Digest = Annotated[str, Field(pattern=r"^sha256:[0-9a-f]{64}$")]
|
||||
JsonObject = dict[str, JsonValue]
|
||||
|
||||
|
||||
class _FrozenDict(dict[Any, Any]):
|
||||
"""A JSON-serializable mapping that rejects mutation after construction."""
|
||||
|
||||
@staticmethod
|
||||
def _immutable(*_: object, **__: object) -> Never:
|
||||
raise TypeError("Mission Core Plugin SDK contracts are deeply immutable")
|
||||
|
||||
__setitem__ = _immutable
|
||||
__delitem__ = _immutable
|
||||
clear = _immutable
|
||||
pop = _immutable
|
||||
popitem = _immutable
|
||||
setdefault = _immutable
|
||||
update = _immutable
|
||||
__ior__ = _immutable
|
||||
|
||||
def __copy__(self) -> _FrozenDict:
|
||||
return self
|
||||
|
||||
def __deepcopy__(self, _memo: dict[int, Any]) -> _FrozenDict:
|
||||
return self
|
||||
|
||||
|
||||
class _FrozenList(list[Any]):
|
||||
"""A JSON-serializable sequence that rejects mutation after construction."""
|
||||
|
||||
@staticmethod
|
||||
def _immutable(*_: object, **__: object) -> Never:
|
||||
raise TypeError("Mission Core Plugin SDK contracts are deeply immutable")
|
||||
|
||||
__setitem__ = _immutable
|
||||
__delitem__ = _immutable
|
||||
append = _immutable
|
||||
clear = _immutable
|
||||
extend = _immutable
|
||||
insert = _immutable
|
||||
pop = _immutable
|
||||
remove = _immutable
|
||||
reverse = _immutable
|
||||
sort = _immutable
|
||||
__iadd__ = _immutable
|
||||
__imul__ = _immutable
|
||||
|
||||
def __copy__(self) -> _FrozenList:
|
||||
return self
|
||||
|
||||
def __deepcopy__(self, _memo: dict[int, Any]) -> _FrozenList:
|
||||
return self
|
||||
|
||||
|
||||
def _deep_freeze(value: Any) -> Any:
|
||||
if isinstance(value, (_FrozenDict, _FrozenList)):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return _FrozenDict({key: _deep_freeze(child) for key, child in value.items()})
|
||||
if isinstance(value, list):
|
||||
return _FrozenList(_deep_freeze(child) for child in value)
|
||||
if isinstance(value, tuple):
|
||||
return tuple(_deep_freeze(child) for child in value)
|
||||
if isinstance(value, (set, frozenset)):
|
||||
return frozenset(_deep_freeze(child) for child in value)
|
||||
return value
|
||||
|
||||
|
||||
class ContractModel(BaseModel):
|
||||
"""Closed and immutable base for every wire-safe SDK contract."""
|
||||
|
||||
model_config = ConfigDict(extra="forbid", frozen=True)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def contract_values_are_deeply_immutable(self) -> ContractModel:
|
||||
"""Freeze mutable containers after Pydantic has validated their contents."""
|
||||
|
||||
for field_name in type(self).model_fields:
|
||||
value = getattr(self, field_name)
|
||||
frozen = _deep_freeze(value)
|
||||
if frozen is not value:
|
||||
object.__setattr__(self, field_name, frozen)
|
||||
return self
|
||||
|
||||
def model_copy(
|
||||
self,
|
||||
*,
|
||||
update: Mapping[str, Any] | None = None,
|
||||
deep: bool = False,
|
||||
) -> Self:
|
||||
"""Revalidate updates so a copied contract cannot bypass deep freezing."""
|
||||
|
||||
if update:
|
||||
candidate = self.model_dump(round_trip=True, exclude_unset=True)
|
||||
candidate.update(update)
|
||||
return type(self).model_validate(candidate)
|
||||
return super().model_copy(deep=deep)
|
||||
@@ -0,0 +1,87 @@
|
||||
"""Firmware/profile compatibility decisions that fail closed for active control."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import AwareDatetime, Field, model_validator
|
||||
|
||||
from .common import API_VERSION, ApiVersion, ContractModel, Identifier, LongText, ShortText
|
||||
from .identity import DeviceInstanceRef
|
||||
|
||||
|
||||
class CompatibilityDecision(StrEnum):
|
||||
COMPATIBLE = "compatible"
|
||||
LIMITED = "limited"
|
||||
UNKNOWN = "unknown"
|
||||
INCOMPATIBLE = "incompatible"
|
||||
|
||||
|
||||
class PermittedMode(StrEnum):
|
||||
BLOCKED = "blocked"
|
||||
EVIDENCE_ONLY = "evidence-only"
|
||||
READ_ONLY = "read-only"
|
||||
ACTIVE_CONTROL = "active-control"
|
||||
|
||||
|
||||
class RuleOutcome(StrEnum):
|
||||
PASS = "pass"
|
||||
FAIL = "fail"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class CompatibilityRuleResult(ContractModel):
|
||||
rule_id: Identifier
|
||||
outcome: RuleOutcome
|
||||
blocking: bool
|
||||
detail: LongText
|
||||
evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple)
|
||||
|
||||
|
||||
class CompatibilityAssessment(ContractModel):
|
||||
api_version: ApiVersion = API_VERSION
|
||||
kind: Literal["CompatibilityAssessment"] = "CompatibilityAssessment"
|
||||
assessment_id: Identifier
|
||||
device: DeviceInstanceRef
|
||||
evaluated_at: AwareDatetime
|
||||
observed_firmware: ShortText | None = None
|
||||
profile_id: Identifier | None = None
|
||||
decision: CompatibilityDecision
|
||||
permitted_mode: PermittedMode
|
||||
rules: tuple[CompatibilityRuleResult, ...] = Field(min_length=1)
|
||||
supported_capabilities: tuple[Identifier, ...] = Field(default_factory=tuple)
|
||||
supported_actions: tuple[Identifier, ...] = Field(default_factory=tuple)
|
||||
notes: LongText | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def compatibility_decision_must_fail_closed(self) -> CompatibilityAssessment:
|
||||
blocking_failures = [
|
||||
rule for rule in self.rules if rule.blocking and rule.outcome is RuleOutcome.FAIL
|
||||
]
|
||||
unresolved_blockers = [
|
||||
rule for rule in self.rules if rule.blocking and rule.outcome is RuleOutcome.UNKNOWN
|
||||
]
|
||||
if self.decision is CompatibilityDecision.INCOMPATIBLE and (
|
||||
self.permitted_mode is not PermittedMode.BLOCKED or not blocking_failures
|
||||
):
|
||||
raise ValueError("incompatible requires blocked mode and a failed blocking rule")
|
||||
if self.permitted_mode is PermittedMode.ACTIVE_CONTROL:
|
||||
if self.decision is not CompatibilityDecision.COMPATIBLE:
|
||||
raise ValueError("active control requires a compatible decision")
|
||||
if self.profile_id is None:
|
||||
raise ValueError("active control requires a compatibility profile")
|
||||
if blocking_failures or unresolved_blockers:
|
||||
raise ValueError("active control requires every blocking rule to pass")
|
||||
if self.decision is CompatibilityDecision.UNKNOWN and self.permitted_mode not in {
|
||||
PermittedMode.BLOCKED,
|
||||
PermittedMode.EVIDENCE_ONLY,
|
||||
}:
|
||||
raise ValueError("unknown compatibility permits only blocked or evidence-only mode")
|
||||
for label, values in (
|
||||
("capabilities", self.supported_capabilities),
|
||||
("actions", self.supported_actions),
|
||||
):
|
||||
if len(values) != len(set(values)):
|
||||
raise ValueError(f"supported {label} must be unique")
|
||||
return self
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Immutable evidence handles, lineage and raw transport records."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Literal, Protocol, runtime_checkable
|
||||
|
||||
from pydantic import AwareDatetime, Field, NonNegativeInt, model_validator
|
||||
|
||||
from .common import (
|
||||
API_VERSION,
|
||||
ApiVersion,
|
||||
ContractModel,
|
||||
Identifier,
|
||||
JsonObject,
|
||||
Sha256Digest,
|
||||
ShortText,
|
||||
)
|
||||
from .session import DeviceSessionRef
|
||||
|
||||
|
||||
class EvidenceRetention(StrEnum):
|
||||
EPHEMERAL = "ephemeral"
|
||||
EXPERIMENT = "experiment"
|
||||
REGRESSION_FIXTURE = "regression-fixture"
|
||||
AUDIT = "audit"
|
||||
|
||||
|
||||
class RedactionState(StrEnum):
|
||||
UNREVIEWED = "unreviewed"
|
||||
REDACTED = "redacted"
|
||||
CLEARED = "cleared"
|
||||
RESTRICTED = "restricted"
|
||||
|
||||
|
||||
class EvidenceHandle(ContractModel):
|
||||
evidence_id: Identifier
|
||||
store_id: Identifier
|
||||
media_type: ShortText
|
||||
byte_length: NonNegativeInt
|
||||
sha256: Sha256Digest
|
||||
object_key: ShortText
|
||||
|
||||
|
||||
class EvidenceRecord(ContractModel):
|
||||
api_version: ApiVersion = API_VERSION
|
||||
kind: Literal["EvidenceRecord"] = "EvidenceRecord"
|
||||
handle: EvidenceHandle
|
||||
plugin_id: Identifier
|
||||
session: DeviceSessionRef | None = None
|
||||
acquisition_id: Identifier | None = None
|
||||
operation_id: Identifier | None = None
|
||||
created_at: AwareDatetime
|
||||
source_kind: Literal[
|
||||
"packet-capture",
|
||||
"transport-payload",
|
||||
"device-file",
|
||||
"operator-note",
|
||||
"derived",
|
||||
]
|
||||
retention: EvidenceRetention
|
||||
redaction: RedactionState
|
||||
parent_evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple)
|
||||
metadata: JsonObject = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def lineage_must_be_acyclic_at_record_boundary(self) -> EvidenceRecord:
|
||||
if self.handle.evidence_id in self.parent_evidence_ids:
|
||||
raise ValueError("an evidence record cannot name itself as a parent")
|
||||
if len(self.parent_evidence_ids) != len(set(self.parent_evidence_ids)):
|
||||
raise ValueError("parent evidence ids must be unique")
|
||||
return self
|
||||
|
||||
|
||||
class RawTransportRecord(ContractModel):
|
||||
"""A raw-first observation; decoding produces derived evidence, never replacement."""
|
||||
|
||||
api_version: ApiVersion = API_VERSION
|
||||
kind: Literal["RawTransportRecord"] = "RawTransportRecord"
|
||||
record_id: Identifier
|
||||
session: DeviceSessionRef
|
||||
profile_id: Identifier | None = None
|
||||
observed_at: AwareDatetime
|
||||
transport: Identifier
|
||||
direction: Literal["device-to-host", "host-to-device", "peer-to-peer", "unknown"]
|
||||
channel: ShortText
|
||||
sequence: NonNegativeInt | None = None
|
||||
payload: EvidenceHandle
|
||||
metadata: JsonObject = Field(default_factory=dict)
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class EvidenceStore(Protocol):
|
||||
"""Minimal store boundary; implementations own persistence and access policy."""
|
||||
|
||||
def put_bytes(
|
||||
self,
|
||||
*,
|
||||
evidence_id: str,
|
||||
media_type: str,
|
||||
payload: bytes,
|
||||
) -> EvidenceHandle: ...
|
||||
|
||||
def register(self, record: EvidenceRecord) -> None: ...
|
||||
|
||||
def get_record(self, evidence_id: str) -> EvidenceRecord: ...
|
||||
|
||||
def open_bytes(self, handle: EvidenceHandle) -> bytes: ...
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Stable device identity contracts.
|
||||
|
||||
Transport-local names (for example a CoreBluetooth UUID assigned by one Mac)
|
||||
may be recorded as aliases, but cannot be asserted as a stable device identity.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import AwareDatetime, Field, model_validator
|
||||
|
||||
from .common import ContractModel, Identifier, ShortText
|
||||
|
||||
|
||||
class IdentityStability(StrEnum):
|
||||
STABLE = "stable"
|
||||
PROVISIONAL = "provisional"
|
||||
|
||||
|
||||
class IdentityBasis(StrEnum):
|
||||
HARDWARE_IDENTIFIER = "hardware-identifier"
|
||||
PLUGIN_DERIVED = "plugin-derived"
|
||||
OPERATOR_ASSIGNED = "operator-assigned"
|
||||
TRANSPORT_LOCAL = "transport-local"
|
||||
|
||||
|
||||
class ExecutionPlatform(StrEnum):
|
||||
MACOS = "macos"
|
||||
LINUX = "linux"
|
||||
WINDOWS = "windows"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class DeviceModelRef(ContractModel):
|
||||
plugin_id: Identifier
|
||||
plugin_version: ShortText
|
||||
model_id: Identifier
|
||||
|
||||
|
||||
class TransportAlias(ContractModel):
|
||||
"""A non-authoritative address observed in one transport scope."""
|
||||
|
||||
transport: Identifier
|
||||
scope_id: Identifier
|
||||
value: ShortText
|
||||
observed_at: AwareDatetime
|
||||
|
||||
|
||||
class DeviceInstanceRef(ContractModel):
|
||||
"""Plugin-scoped device identity, independent from a connection session."""
|
||||
|
||||
device_id: Identifier
|
||||
model: DeviceModelRef
|
||||
stability: IdentityStability
|
||||
basis: IdentityBasis
|
||||
aliases: tuple[TransportAlias, ...] = Field(default_factory=tuple)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def stable_identity_must_not_be_transport_local(self) -> DeviceInstanceRef:
|
||||
if (
|
||||
self.stability is IdentityStability.STABLE
|
||||
and self.basis is IdentityBasis.TRANSPORT_LOCAL
|
||||
):
|
||||
raise ValueError("a transport-local identifier cannot be a stable device identity")
|
||||
return self
|
||||
|
||||
|
||||
class ExecutionBinding(ContractModel):
|
||||
"""The logical node and concrete agent process hosting a device session."""
|
||||
|
||||
node_id: Identifier
|
||||
agent_instance_id: Identifier
|
||||
platform: ExecutionPlatform
|
||||
@@ -0,0 +1,187 @@
|
||||
"""Auditable device-operation requests and lifecycle events."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import (
|
||||
AwareDatetime,
|
||||
Field,
|
||||
JsonValue,
|
||||
NonNegativeInt,
|
||||
PositiveFloat,
|
||||
model_validator,
|
||||
)
|
||||
|
||||
from .common import (
|
||||
API_VERSION,
|
||||
ApiVersion,
|
||||
ContractModel,
|
||||
Identifier,
|
||||
JsonObject,
|
||||
LongText,
|
||||
ShortText,
|
||||
)
|
||||
from .session import DeviceSessionRef
|
||||
|
||||
|
||||
class OperationSafetyClass(StrEnum):
|
||||
READ_ONLY = "read-only"
|
||||
REVERSIBLE = "reversible"
|
||||
STATE_CHANGING = "state-changing"
|
||||
SAFETY_CRITICAL = "safety-critical"
|
||||
|
||||
|
||||
class IdempotencyMode(StrEnum):
|
||||
IDEMPOTENT = "idempotent"
|
||||
IDEMPOTENT_WITH_KEY = "idempotent-with-key"
|
||||
NON_IDEMPOTENT = "non-idempotent"
|
||||
|
||||
|
||||
class SecretReference(ContractModel):
|
||||
"""Opaque secret-vault reference; secret material is never an SDK value."""
|
||||
|
||||
provider: Identifier
|
||||
reference: ShortText
|
||||
version: ShortText | None = None
|
||||
|
||||
|
||||
class OperationPolicy(ContractModel):
|
||||
action_id: Identifier
|
||||
safety_class: OperationSafetyClass
|
||||
idempotency: IdempotencyMode
|
||||
timeout_seconds: PositiveFloat
|
||||
acknowledgement_required: bool = True
|
||||
retry_limit: NonNegativeInt = 0
|
||||
preconditions: tuple[Identifier, ...] = Field(default_factory=tuple)
|
||||
secret_fields: tuple[Identifier, ...] = Field(default_factory=tuple)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def policy_collections_must_be_unique(self) -> OperationPolicy:
|
||||
for label, values in (
|
||||
("preconditions", self.preconditions),
|
||||
("secret fields", self.secret_fields),
|
||||
):
|
||||
if len(values) != len(set(values)):
|
||||
raise ValueError(f"{label} must be unique")
|
||||
if self.idempotency is IdempotencyMode.NON_IDEMPOTENT and self.retry_limit:
|
||||
raise ValueError("non-idempotent operations cannot declare automatic retries")
|
||||
return self
|
||||
|
||||
|
||||
class OperationRequest(ContractModel):
|
||||
api_version: ApiVersion = API_VERSION
|
||||
kind: Literal["OperationRequest"] = "OperationRequest"
|
||||
operation_id: Identifier
|
||||
session: DeviceSessionRef
|
||||
action_id: Identifier
|
||||
requested_at: AwareDatetime
|
||||
deadline_at: AwareDatetime
|
||||
idempotency_key: Identifier | None = None
|
||||
parameters: JsonObject = Field(default_factory=dict)
|
||||
secret_refs: dict[Identifier, SecretReference] = Field(default_factory=dict)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_request_boundaries(self) -> OperationRequest:
|
||||
if self.deadline_at <= self.requested_at:
|
||||
raise ValueError("deadline_at must be later than requested_at")
|
||||
overlap = set(self.parameters).intersection(self.secret_refs)
|
||||
if overlap:
|
||||
raise ValueError(f"secret fields cannot also appear in parameters: {sorted(overlap)}")
|
||||
return self
|
||||
|
||||
|
||||
class AcknowledgementDisposition(StrEnum):
|
||||
ACCEPTED = "accepted"
|
||||
REJECTED = "rejected"
|
||||
DUPLICATE = "duplicate"
|
||||
|
||||
|
||||
class OperationAcknowledgement(ContractModel):
|
||||
"""Receipt/admission acknowledgement; never proof of operation completion."""
|
||||
|
||||
api_version: ApiVersion = API_VERSION
|
||||
event_type: Literal["acknowledgement"] = "acknowledgement"
|
||||
operation_id: Identifier
|
||||
session: DeviceSessionRef
|
||||
sequence: NonNegativeInt
|
||||
recorded_at: AwareDatetime
|
||||
disposition: AcknowledgementDisposition
|
||||
device_receipt_id: Identifier | None = None
|
||||
message: LongText | None = None
|
||||
|
||||
|
||||
class OperationProgress(ContractModel):
|
||||
api_version: ApiVersion = API_VERSION
|
||||
event_type: Literal["progress"] = "progress"
|
||||
operation_id: Identifier
|
||||
session: DeviceSessionRef
|
||||
sequence: NonNegativeInt
|
||||
recorded_at: AwareDatetime
|
||||
stage: Identifier
|
||||
progress: Annotated[float, Field(ge=0.0, le=1.0)] | None = None
|
||||
message: LongText | None = None
|
||||
measurements: dict[str, JsonValue] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OperationCompletion(ContractModel):
|
||||
api_version: ApiVersion = API_VERSION
|
||||
event_type: Literal["completion"] = "completion"
|
||||
operation_id: Identifier
|
||||
session: DeviceSessionRef
|
||||
sequence: NonNegativeInt
|
||||
recorded_at: AwareDatetime
|
||||
result: JsonObject = Field(default_factory=dict)
|
||||
evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple)
|
||||
|
||||
|
||||
class OperationTerminalState(StrEnum):
|
||||
FAILED = "failed"
|
||||
CANCELLED = "cancelled"
|
||||
TIMED_OUT = "timed-out"
|
||||
|
||||
|
||||
class OperationError(ContractModel):
|
||||
code: Identifier
|
||||
message: LongText
|
||||
retryable: bool
|
||||
details: JsonObject = Field(default_factory=dict)
|
||||
|
||||
|
||||
class OperationFailure(ContractModel):
|
||||
api_version: ApiVersion = API_VERSION
|
||||
event_type: Literal["failure"] = "failure"
|
||||
operation_id: Identifier
|
||||
session: DeviceSessionRef
|
||||
sequence: NonNegativeInt
|
||||
recorded_at: AwareDatetime
|
||||
terminal_state: OperationTerminalState
|
||||
error: OperationError
|
||||
evidence_ids: tuple[Identifier, ...] = Field(default_factory=tuple)
|
||||
|
||||
|
||||
OperationEvent = Annotated[
|
||||
OperationAcknowledgement | OperationProgress | OperationCompletion | OperationFailure,
|
||||
Field(discriminator="event_type"),
|
||||
]
|
||||
|
||||
|
||||
def validate_operation_request(policy: OperationPolicy, request: OperationRequest) -> None:
|
||||
"""Validate request data that depends on its declared action policy."""
|
||||
|
||||
if request.action_id != policy.action_id:
|
||||
raise ValueError("operation request action_id does not match its policy")
|
||||
declared_secrets = set(policy.secret_fields)
|
||||
supplied_secrets = set(request.secret_refs)
|
||||
missing = declared_secrets - supplied_secrets
|
||||
undeclared = supplied_secrets - declared_secrets
|
||||
if missing:
|
||||
raise ValueError(f"missing secret references: {sorted(missing)}")
|
||||
if undeclared:
|
||||
raise ValueError(f"undeclared secret references: {sorted(undeclared)}")
|
||||
if policy.idempotency is IdempotencyMode.IDEMPOTENT_WITH_KEY:
|
||||
if request.idempotency_key is None:
|
||||
raise ValueError("idempotent-with-key operation requires idempotency_key")
|
||||
elif request.idempotency_key is not None:
|
||||
raise ValueError("idempotency_key is allowed only for idempotent-with-key operations")
|
||||
@@ -0,0 +1,62 @@
|
||||
"""Language-neutral binary payload handles used by canonical stream contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import binascii
|
||||
import hashlib
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import Field, NonNegativeInt, PositiveInt, model_validator
|
||||
|
||||
from .common import ContractModel, Identifier, Sha256Digest, ShortText
|
||||
|
||||
|
||||
class InlinePayload(ContractModel):
|
||||
kind: Literal["inline-base64"] = "inline-base64"
|
||||
media_type: ShortText
|
||||
byte_length: NonNegativeInt
|
||||
sha256: Sha256Digest
|
||||
data: str
|
||||
|
||||
@model_validator(mode="after")
|
||||
def inline_data_must_match_descriptor(self) -> InlinePayload:
|
||||
try:
|
||||
decoded = base64.b64decode(self.data, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise ValueError("data must be canonical base64") from exc
|
||||
if len(decoded) != self.byte_length:
|
||||
raise ValueError("byte_length does not match decoded inline payload")
|
||||
digest = f"sha256:{hashlib.sha256(decoded).hexdigest()}"
|
||||
if digest != self.sha256:
|
||||
raise ValueError("sha256 does not match decoded inline payload")
|
||||
if base64.b64encode(decoded).decode("ascii") != self.data:
|
||||
raise ValueError("data must use canonical padded base64 encoding")
|
||||
return self
|
||||
|
||||
|
||||
class ReferencedPayload(ContractModel):
|
||||
"""Opaque payload location owned by a declared transport or evidence store."""
|
||||
|
||||
kind: Literal["reference"] = "reference"
|
||||
media_type: ShortText
|
||||
byte_length: NonNegativeInt
|
||||
sha256: Sha256Digest | None = None
|
||||
reference_scheme: Literal["evidence", "shared-memory", "transport", "uri"]
|
||||
locator: ShortText
|
||||
offset: NonNegativeInt = 0
|
||||
segment_length: PositiveInt | None = None
|
||||
owner_id: Identifier | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def segment_must_fit_known_payload(self) -> ReferencedPayload:
|
||||
if self.offset > self.byte_length:
|
||||
raise ValueError("offset cannot exceed byte_length")
|
||||
if self.segment_length is not None and self.offset + self.segment_length > self.byte_length:
|
||||
raise ValueError("referenced segment exceeds byte_length")
|
||||
if self.reference_scheme in {"evidence", "shared-memory"} and self.owner_id is None:
|
||||
raise ValueError("evidence and shared-memory references require owner_id")
|
||||
return self
|
||||
|
||||
|
||||
PayloadHandle = Annotated[InlinePayload | ReferencedPayload, Field(discriminator="kind")]
|
||||
@@ -0,0 +1,30 @@
|
||||
"""Deterministic JSON Schema export for non-Python SDK consumers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pydantic import TypeAdapter
|
||||
|
||||
from .compatibility import CompatibilityAssessment
|
||||
from .evidence import EvidenceRecord, RawTransportRecord
|
||||
from .identity import DeviceInstanceRef
|
||||
from .operations import OperationEvent, OperationPolicy, OperationRequest
|
||||
from .session import DeviceSessionContext, DeviceSessionSnapshot
|
||||
from .streams import CanonicalStreamEnvelope
|
||||
|
||||
|
||||
def contract_json_schemas() -> dict[str, dict[str, object]]:
|
||||
"""Return named schemas; callers may persist or serve the resulting bundle."""
|
||||
|
||||
contracts: dict[str, object] = {
|
||||
"DeviceInstanceRef": DeviceInstanceRef,
|
||||
"DeviceSessionContext": DeviceSessionContext,
|
||||
"DeviceSessionSnapshot": DeviceSessionSnapshot,
|
||||
"OperationPolicy": OperationPolicy,
|
||||
"OperationRequest": OperationRequest,
|
||||
"OperationEvent": OperationEvent,
|
||||
"CanonicalStreamEnvelope": CanonicalStreamEnvelope,
|
||||
"EvidenceRecord": EvidenceRecord,
|
||||
"RawTransportRecord": RawTransportRecord,
|
||||
"CompatibilityAssessment": CompatibilityAssessment,
|
||||
}
|
||||
return {name: TypeAdapter(contract).json_schema() for name, contract in contracts.items()}
|
||||
@@ -0,0 +1,70 @@
|
||||
"""Independent device-session lifecycle contracts."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import AwareDatetime, Field, NonNegativeInt, model_validator
|
||||
|
||||
from .common import ContractModel, Identifier, LongText
|
||||
from .identity import DeviceInstanceRef, ExecutionBinding
|
||||
|
||||
|
||||
class EnrollmentState(StrEnum):
|
||||
EMPTY = "empty"
|
||||
SELECTING_MODEL = "selecting-model"
|
||||
SETUP_IN_PROGRESS = "setup-in-progress"
|
||||
ENROLLED = "enrolled"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class ConnectivityState(StrEnum):
|
||||
UNKNOWN = "unknown"
|
||||
OFFLINE = "offline"
|
||||
CONNECTING = "connecting"
|
||||
CONNECTED = "connected"
|
||||
DEGRADED = "degraded"
|
||||
|
||||
|
||||
class AcquisitionState(StrEnum):
|
||||
IDLE = "idle"
|
||||
PREPARING = "preparing"
|
||||
STARTING = "starting"
|
||||
STREAMING = "streaming"
|
||||
STOPPING = "stopping"
|
||||
FAILED = "failed"
|
||||
|
||||
|
||||
class DeviceSessionContext(ContractModel):
|
||||
"""Immutable identity and binding established when a plugin opens a session."""
|
||||
|
||||
session_id: Identifier
|
||||
device: DeviceInstanceRef
|
||||
execution: ExecutionBinding
|
||||
compatibility_profile_id: Identifier | None = None
|
||||
compatibility_assessment_id: Identifier | None = None
|
||||
opened_at: AwareDatetime
|
||||
|
||||
|
||||
class DeviceSessionRef(ContractModel):
|
||||
session_id: Identifier
|
||||
device_id: Identifier
|
||||
|
||||
|
||||
class DeviceSessionSnapshot(ContractModel):
|
||||
"""Revisioned state; enrollment, connectivity and acquisition never alias."""
|
||||
|
||||
context: DeviceSessionContext
|
||||
revision: NonNegativeInt
|
||||
enrollment: EnrollmentState
|
||||
connectivity: ConnectivityState
|
||||
acquisition: AcquisitionState
|
||||
observed_at: AwareDatetime
|
||||
message: LongText | None = None
|
||||
active_operation_ids: tuple[Identifier, ...] = Field(default_factory=tuple)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def operation_ids_must_be_unique(self) -> DeviceSessionSnapshot:
|
||||
if len(self.active_operation_ids) != len(set(self.active_operation_ids)):
|
||||
raise ValueError("active operation ids must be unique")
|
||||
return self
|
||||
@@ -0,0 +1,174 @@
|
||||
"""Vendor-neutral canonical stream envelopes."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from enum import StrEnum
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from pydantic import AwareDatetime, Field, NonNegativeInt, PositiveInt, model_validator
|
||||
|
||||
from .common import API_VERSION, ApiVersion, ContractModel, Identifier, JsonObject, ShortText
|
||||
from .payloads import PayloadHandle
|
||||
from .session import DeviceSessionRef
|
||||
|
||||
|
||||
class ClockDomain(StrEnum):
|
||||
UTC = "utc"
|
||||
DEVICE_MONOTONIC = "device-monotonic"
|
||||
HOST_MONOTONIC = "host-monotonic"
|
||||
UNKNOWN = "unknown"
|
||||
|
||||
|
||||
class CanonicalStreamHeader(ContractModel):
|
||||
api_version: ApiVersion = API_VERSION
|
||||
message_id: Identifier
|
||||
session: DeviceSessionRef
|
||||
acquisition_id: Identifier | None = None
|
||||
source_id: Identifier
|
||||
channel_id: Identifier
|
||||
sequence: NonNegativeInt
|
||||
captured_at: AwareDatetime
|
||||
observed_at: AwareDatetime
|
||||
clock_domain: ClockDomain
|
||||
compatibility_profile_id: Identifier | None = None
|
||||
|
||||
|
||||
class PointFieldType(StrEnum):
|
||||
INT8 = "int8"
|
||||
UINT8 = "uint8"
|
||||
INT16 = "int16"
|
||||
UINT16 = "uint16"
|
||||
INT32 = "int32"
|
||||
UINT32 = "uint32"
|
||||
FLOAT32 = "float32"
|
||||
FLOAT64 = "float64"
|
||||
|
||||
|
||||
_POINT_FIELD_BYTES = {
|
||||
PointFieldType.INT8: 1,
|
||||
PointFieldType.UINT8: 1,
|
||||
PointFieldType.INT16: 2,
|
||||
PointFieldType.UINT16: 2,
|
||||
PointFieldType.INT32: 4,
|
||||
PointFieldType.UINT32: 4,
|
||||
PointFieldType.FLOAT32: 4,
|
||||
PointFieldType.FLOAT64: 8,
|
||||
}
|
||||
|
||||
|
||||
class PointField(ContractModel):
|
||||
name: Identifier
|
||||
offset: NonNegativeInt
|
||||
data_type: PointFieldType
|
||||
count: PositiveInt = 1
|
||||
|
||||
|
||||
class PointCloudFrame(ContractModel):
|
||||
kind: Literal["point-cloud"] = "point-cloud"
|
||||
header: CanonicalStreamHeader
|
||||
coordinate_frame: Identifier
|
||||
width: PositiveInt
|
||||
height: PositiveInt = 1
|
||||
point_count: PositiveInt
|
||||
point_step: PositiveInt
|
||||
row_step: PositiveInt
|
||||
little_endian: bool = True
|
||||
dense: bool
|
||||
fields: tuple[PointField, ...] = Field(min_length=3)
|
||||
payload: PayloadHandle
|
||||
|
||||
@model_validator(mode="after")
|
||||
def point_layout_must_be_self_consistent(self) -> PointCloudFrame:
|
||||
if self.point_count != self.width * self.height:
|
||||
raise ValueError("point_count must equal width * height")
|
||||
if self.row_step != self.point_step * self.width:
|
||||
raise ValueError("row_step must equal point_step * width")
|
||||
if self.payload.byte_length != self.row_step * self.height:
|
||||
raise ValueError("payload byte_length must match the declared point layout")
|
||||
names = [field.name for field in self.fields]
|
||||
if len(names) != len(set(names)):
|
||||
raise ValueError("point field names must be unique")
|
||||
if not {"x", "y", "z"}.issubset(names):
|
||||
raise ValueError("canonical point clouds require x, y, and z fields")
|
||||
for field in self.fields:
|
||||
field_end = field.offset + _POINT_FIELD_BYTES[field.data_type] * field.count
|
||||
if field_end > self.point_step:
|
||||
raise ValueError(f"point field {field.name} exceeds point_step")
|
||||
return self
|
||||
|
||||
|
||||
class Vector3(ContractModel):
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
|
||||
|
||||
class Quaternion(ContractModel):
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
w: float
|
||||
|
||||
@model_validator(mode="after")
|
||||
def quaternion_must_be_normalized(self) -> Quaternion:
|
||||
norm = math.sqrt(self.x**2 + self.y**2 + self.z**2 + self.w**2)
|
||||
if not math.isclose(norm, 1.0, rel_tol=1e-3, abs_tol=1e-3):
|
||||
raise ValueError("quaternion must be normalized")
|
||||
return self
|
||||
|
||||
|
||||
class PoseFrame(ContractModel):
|
||||
kind: Literal["pose"] = "pose"
|
||||
header: CanonicalStreamHeader
|
||||
parent_frame: Identifier
|
||||
child_frame: Identifier
|
||||
translation_m: Vector3
|
||||
rotation: Quaternion
|
||||
covariance: tuple[float, ...] | None = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def covariance_must_be_six_by_six(self) -> PoseFrame:
|
||||
if self.covariance is not None and len(self.covariance) != 36:
|
||||
raise ValueError("pose covariance must contain 36 values")
|
||||
return self
|
||||
|
||||
|
||||
class ImageFrame(ContractModel):
|
||||
kind: Literal["image"] = "image"
|
||||
header: CanonicalStreamHeader
|
||||
camera_id: Identifier
|
||||
optical_frame: Identifier
|
||||
width: PositiveInt
|
||||
height: PositiveInt
|
||||
pixel_format: Literal["rgb8", "bgr8", "rgba8", "mono8", "mono16", "depth16", "jpeg", "png"]
|
||||
payload: PayloadHandle
|
||||
calibration_id: Identifier | None = None
|
||||
|
||||
|
||||
class EncodedVideoPacket(ContractModel):
|
||||
kind: Literal["encoded-video"] = "encoded-video"
|
||||
header: CanonicalStreamHeader
|
||||
stream_id: Identifier
|
||||
camera_id: Identifier
|
||||
codec: Literal["h264", "h265", "mjpeg", "av1", "unknown"]
|
||||
key_frame: bool
|
||||
presentation_time_ns: NonNegativeInt
|
||||
decode_time_ns: NonNegativeInt | None = None
|
||||
duration_ns: NonNegativeInt | None = None
|
||||
payload: PayloadHandle
|
||||
|
||||
|
||||
class DeviceStatusFrame(ContractModel):
|
||||
kind: Literal["device-status"] = "device-status"
|
||||
header: CanonicalStreamHeader
|
||||
health: Literal["unknown", "healthy", "degraded", "fault"]
|
||||
state: Identifier
|
||||
measurements: JsonObject = Field(default_factory=dict)
|
||||
message: ShortText | None = None
|
||||
|
||||
|
||||
CanonicalStreamEnvelope = Annotated[
|
||||
PointCloudFrame | PoseFrame | ImageFrame | EncodedVideoPacket | DeviceStatusFrame,
|
||||
Field(discriminator="kind"),
|
||||
]
|
||||
Reference in New Issue
Block a user