feat(plugins): isolate device integrations

This commit is contained in:
DCCONSTRUCTIONS
2026-07-17 19:29:32 +03:00
parent f9ffb7bd1c
commit 24a47318f2
122 changed files with 3304 additions and 1892 deletions
+1
View File
@@ -0,0 +1 @@
"""Concrete device integrations loaded only through reviewed plugin manifests."""
@@ -0,0 +1,5 @@
"""XGRIDS/LixelKity K1 compatibility plugin implementation."""
from .observation import build_xgrids_k1_observation, xgrids_k1_archive_source
__all__ = ["build_xgrids_k1_observation", "xgrids_k1_archive_source"]
@@ -0,0 +1,15 @@
"""Bounded, offline analysis of sensitive K1 evidence artifacts."""
from k1link.device_plugins.xgrids_k1.analyze.stream_summary import (
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
StreamSummary,
summarize_mqtt_streams,
)
__all__ = [
"DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES",
"MAX_STREAM_SUMMARY_PAYLOAD_BYTES",
"StreamSummary",
"summarize_mqtt_streams",
]
@@ -0,0 +1,353 @@
from __future__ import annotations
import hashlib
import math
import os
import stat
from dataclasses import dataclass, field
from pathlib import Path
from typing import TypedDict
from k1link.artifacts import utc_now_iso
from k1link.device_plugins.xgrids_k1.mqtt import DEFAULT_MAX_MESSAGE_BYTES, iter_capture_frames
from k1link.device_plugins.xgrids_k1.protocol import (
DecodeLimits,
StreamDecodeError,
decode_lio_pcl,
decode_lio_pose,
)
LIO_PCL_TOPIC = "lixel/application/report/lio_pcl"
LIO_POSE_TOPIC = "lixel/application/report/lio_pose"
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES = DecodeLimits().max_mqtt_payload_bytes
MAX_STREAM_SUMMARY_PAYLOAD_BYTES = DEFAULT_MAX_MESSAGE_BYTES
_HASH_CHUNK_BYTES = 1024 * 1024
class IntRangeSummary(TypedDict):
min: int | None
max: int | None
class ScalerSummary(TypedDict):
min: int | None
max: int | None
constant: bool | None
class ByteSummary(TypedDict):
total: int
per_frame: IntRangeSummary
class PointSummary(TypedDict):
total: int
per_frame: IntRangeSummary
class SourceSummary(TypedDict):
bytes: int
sha256: str
class LimitSummary(TypedDict):
max_payload_bytes: int
max_compressed_bytes: int
max_decompressed_bytes: int
max_compression_ratio: int
max_points_per_frame: int
class FrameSummary(TypedDict):
count: int
payload_bytes: int
encoded_frame_bytes: int
other_count: int
other_payload_bytes: int
class DecodeSummary(TypedDict):
attempted: int
successes: int
errors: int
class PointCloudSummary(TypedDict):
frame_count: int
payload_bytes: int
decode_successes: int
decode_errors: int
points: PointSummary
scalers: ScalerSummary
compressed_bytes: ByteSummary
decompressed_bytes: ByteSummary
class PoseSummary(TypedDict):
frame_count: int
payload_bytes: int
decode_successes: int
decode_errors: int
first_to_last_displacement_meters: float | None
class StreamSummary(TypedDict):
schema_version: int
created_at_utc: str
sensitivity: str
source: SourceSummary
limits: LimitSummary
frames: FrameSummary
decoding: DecodeSummary
point_cloud: PointCloudSummary
pose: PoseSummary
@dataclass(slots=True)
class _IntRange:
minimum: int | None = None
maximum: int | None = None
def add(self, value: int) -> None:
if self.minimum is None or value < self.minimum:
self.minimum = value
if self.maximum is None or value > self.maximum:
self.maximum = value
def summary(self) -> IntRangeSummary:
return {"min": self.minimum, "max": self.maximum}
@dataclass(slots=True)
class _PointCloudAccumulator:
frame_count: int = 0
payload_bytes: int = 0
decode_successes: int = 0
decode_errors: int = 0
point_total: int = 0
compressed_total: int = 0
decompressed_total: int = 0
points_per_frame: _IntRange = field(default_factory=_IntRange)
scalers: _IntRange = field(default_factory=_IntRange)
compressed_per_frame: _IntRange = field(default_factory=_IntRange)
decompressed_per_frame: _IntRange = field(default_factory=_IntRange)
first_scaler: int | None = None
scaler_constant: bool = True
def record_payload(self, payload_bytes: int) -> None:
self.frame_count += 1
self.payload_bytes += payload_bytes
def record_error(self) -> None:
self.decode_errors += 1
def record_decoded(
self,
*,
point_count: int,
scaler: int,
compressed_bytes: int,
decompressed_bytes: int,
) -> None:
self.decode_successes += 1
self.point_total += point_count
self.compressed_total += compressed_bytes
self.decompressed_total += decompressed_bytes
self.points_per_frame.add(point_count)
self.scalers.add(scaler)
self.compressed_per_frame.add(compressed_bytes)
self.decompressed_per_frame.add(decompressed_bytes)
if self.first_scaler is None:
self.first_scaler = scaler
elif scaler != self.first_scaler:
self.scaler_constant = False
def summary(self) -> PointCloudSummary:
scaler_summary: ScalerSummary = {
"min": self.scalers.minimum,
"max": self.scalers.maximum,
"constant": self.scaler_constant if self.decode_successes else None,
}
return {
"frame_count": self.frame_count,
"payload_bytes": self.payload_bytes,
"decode_successes": self.decode_successes,
"decode_errors": self.decode_errors,
"points": {
"total": self.point_total,
"per_frame": self.points_per_frame.summary(),
},
"scalers": scaler_summary,
"compressed_bytes": {
"total": self.compressed_total,
"per_frame": self.compressed_per_frame.summary(),
},
"decompressed_bytes": {
"total": self.decompressed_total,
"per_frame": self.decompressed_per_frame.summary(),
},
}
@dataclass(slots=True)
class _PoseAccumulator:
frame_count: int = 0
payload_bytes: int = 0
decode_successes: int = 0
decode_errors: int = 0
first_position: tuple[float, float, float] | None = None
last_position: tuple[float, float, float] | None = None
def record_payload(self, payload_bytes: int) -> None:
self.frame_count += 1
self.payload_bytes += payload_bytes
def record_error(self) -> None:
self.decode_errors += 1
def record_decoded(self, position: tuple[float, float, float]) -> None:
self.decode_successes += 1
if self.first_position is None:
self.first_position = position
self.last_position = position
def summary(self) -> PoseSummary:
displacement: float | None = None
if self.first_position is not None and self.last_position is not None:
candidate = math.dist(self.first_position, self.last_position)
if math.isfinite(candidate):
displacement = candidate
return {
"frame_count": self.frame_count,
"payload_bytes": self.payload_bytes,
"decode_successes": self.decode_successes,
"decode_errors": self.decode_errors,
"first_to_last_displacement_meters": displacement,
}
def summarize_mqtt_streams(
capture: Path,
*,
max_payload_bytes: int = DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
) -> StreamSummary:
"""Stream a raw MQTT capture into an aggregate-only, coordinate-free summary."""
if not 1 <= max_payload_bytes <= MAX_STREAM_SUMMARY_PAYLOAD_BYTES:
raise ValueError(
f"max_payload_bytes must be between 1 and {MAX_STREAM_SUMMARY_PAYLOAD_BYTES}"
)
capture_path = capture.expanduser()
before = capture_path.stat()
if not stat.S_ISREG(before.st_mode):
raise ValueError("capture must be a regular file")
capture_sha256 = _sha256_file(capture_path)
hashed = capture_path.stat()
if _file_identity(before) != _file_identity(hashed):
raise RuntimeError("capture changed while it was being hashed")
limits = DecodeLimits(max_mqtt_payload_bytes=max_payload_bytes)
point_cloud = _PointCloudAccumulator()
pose = _PoseAccumulator()
frame_count = 0
payload_bytes = 0
encoded_frame_bytes = 0
other_count = 0
other_payload_bytes = 0
for capture_frame in iter_capture_frames(
capture_path,
max_payload_bytes=max_payload_bytes,
):
frame_count += 1
frame_payload_bytes = len(capture_frame.payload)
payload_bytes += frame_payload_bytes
encoded_frame_bytes += capture_frame.raw_frame_bytes
if capture_frame.topic == LIO_PCL_TOPIC:
point_cloud.record_payload(frame_payload_bytes)
try:
decoded = decode_lio_pcl(capture_frame.payload, limits)
except StreamDecodeError:
point_cloud.record_error()
continue
point_cloud.record_decoded(
point_count=len(decoded.points),
scaler=decoded.header.scaler,
compressed_bytes=decoded.compressed_bytes,
decompressed_bytes=decoded.decompressed_bytes,
)
continue
if capture_frame.topic == LIO_POSE_TOPIC:
pose.record_payload(frame_payload_bytes)
try:
decoded_pose = decode_lio_pose(capture_frame.payload, limits)
except StreamDecodeError:
pose.record_error()
continue
pose.record_decoded(decoded_pose.position_xyz)
continue
# Unknown topic text is intentionally neither retained nor emitted: a malformed
# capture could place an identifier in that field.
other_count += 1
other_payload_bytes += frame_payload_bytes
after = capture_path.stat()
if _file_identity(hashed) != _file_identity(after):
raise RuntimeError("capture changed while it was being analyzed")
decode_successes = point_cloud.decode_successes + pose.decode_successes
decode_errors = point_cloud.decode_errors + pose.decode_errors
return {
"schema_version": 1,
"created_at_utc": utc_now_iso(),
"sensitivity": (
"sensitive derived K1 stream statistics; keep in ignored storage; "
"identifiers, keys, error text and coordinates are omitted"
),
"source": {
"bytes": hashed.st_size,
"sha256": capture_sha256,
},
"limits": {
"max_payload_bytes": limits.max_mqtt_payload_bytes,
"max_compressed_bytes": limits.max_compressed_bytes,
"max_decompressed_bytes": limits.max_decompressed_bytes,
"max_compression_ratio": limits.max_compression_ratio,
"max_points_per_frame": limits.max_points_per_frame,
},
"frames": {
"count": frame_count,
"payload_bytes": payload_bytes,
"encoded_frame_bytes": encoded_frame_bytes,
"other_count": other_count,
"other_payload_bytes": other_payload_bytes,
},
"decoding": {
"attempted": point_cloud.frame_count + pose.frame_count,
"successes": decode_successes,
"errors": decode_errors,
},
"point_cloud": point_cloud.summary(),
"pose": pose.summary(),
}
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(_HASH_CHUNK_BYTES), b""):
digest.update(chunk)
return digest.hexdigest()
def _file_identity(file_stat: os.stat_result) -> tuple[int, int, int, int]:
return (
file_stat.st_dev,
file_stat.st_ino,
file_stat.st_size,
file_stat.st_mtime_ns,
)
@@ -0,0 +1,840 @@
from __future__ import annotations
import hashlib
import json
import math
import os
import re
import stat
from collections.abc import Mapping
from dataclasses import dataclass
from datetime import UTC, datetime
from functools import lru_cache
from pathlib import Path
from typing import IO, Any
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
FRAME_HEADER,
GROUP_COMMIT_MAX_BYTES,
GROUP_COMMIT_MAX_MESSAGES,
MAX_CONFIGURABLE_MESSAGE_BYTES,
MAX_TOPIC_BYTES,
RAW_MAGIC,
)
from k1link.sessions.models import (
LegacyMediaSourceCandidate,
SessionModality,
SessionStatus,
)
MAX_LEGACY_JSON_BYTES = 2 * 1024 * 1024
LEGACY_SESSION_PATTERN = re.compile(r"^[A-Za-z0-9._-]+_viewer_live(?:_[0-9]+)?$")
SHA256_PATTERN = re.compile(r"^[a-f0-9]{64}$")
MEDIA_SOURCE_PATTERN = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$")
MEDIA_EPOCH_PATTERN = re.compile(r"^epoch-(?!0+$)[0-9]+$")
MEDIA_SEGMENT_PATTERN = re.compile(r"^[0-9]+\.m4s$")
MAX_MEDIA_INDEX_BYTES = 32 * 1024 * 1024
MAX_RECOVERY_METADATA_BYTES = 64 * 1024 * 1024
MAX_RECOVERY_METADATA_LINE_BYTES = 64 * 1024
MAX_RECOVERY_MESSAGES = 500_000
@dataclass(frozen=True, slots=True)
class LegacySessionCandidate:
"""K1 archive discovery result retained inside the compatibility adapter."""
session_id: str
display_name: str
status: SessionStatus
started_at_utc: str | None
completed_at_utc: str | None
duration_seconds: float | None
modalities: tuple[SessionModality, ...]
replayable: bool
total_bytes: int
allowed_root: Path
session_root: Path
raw_path: Path
raw_byte_length: int
replay_raw_byte_length: int
replay_metadata_byte_length: int
raw_sha256: str | None
raw_integrity_status: str
media_sources: tuple[LegacyMediaSourceCandidate, ...]
@dataclass(frozen=True, slots=True)
class _RecoveredCapture:
message_count: int
topic_counts: dict[str, int]
started_at_utc: str
completed_at_utc: str
duration_seconds: float
raw_committed_bytes: int
metadata_committed_bytes: int
def discover_legacy_viewer_sessions(root: Path) -> tuple[LegacySessionCandidate, ...]:
"""Describe legacy live sessions without copying or decoding their payloads."""
allowed_root = root.expanduser().resolve()
if not allowed_root.is_dir():
return ()
active_session_root = _active_session_root(allowed_root)
candidates: list[LegacySessionCandidate] = []
for entry in sorted(allowed_root.iterdir()):
if not entry.is_dir() or not LEGACY_SESSION_PATTERN.fullmatch(entry.name):
continue
resolved = entry.resolve()
if not resolved.is_relative_to(allowed_root):
continue
# The saved-session catalog deliberately omits the writer-owned run.
# Besides avoiding a misleading interrupted/error row, this prevents
# the two-second reconciler from repeatedly scanning and hashing a
# capture that is still growing. Marker release makes it discoverable
# on the next reconciliation pass.
if active_session_root == resolved:
continue
candidates.append(
_describe_session(
allowed_root,
resolved,
active=False,
)
)
return tuple(candidates)
def _describe_session(
allowed_root: Path,
session_root: Path,
*,
active: bool,
) -> LegacySessionCandidate:
capture_root = session_root / "captures" / "mqtt_live"
raw_path = capture_root / "mqtt.raw.k1mqtt"
summary = _read_json_object(capture_root / "mqtt.summary.json")
manifest = _read_json_object(session_root / "manifest.redacted.json")
raw_bytes = raw_path.stat().st_size if _confined_file(raw_path, session_root) else 0
raw_magic_ok = False
if raw_bytes >= len(RAW_MAGIC):
with raw_path.open("rb") as stream:
raw_magic_ok = stream.read(len(RAW_MAGIC)) == RAW_MAGIC
has_completed_summary = _is_completed_summary(summary)
completed = (
_validate_completed_capture(capture_root, session_root, raw_path, summary)
if has_completed_summary and raw_magic_ok
else None
)
recovered = (
_recover_interrupted_capture(capture_root, session_root, raw_path)
if raw_magic_ok
and completed is None
and (not has_completed_summary or summary.get("error") is not None)
else None
)
message_count = (
_non_negative_int(summary.get("message_count"))
if completed is not None
else recovered.message_count if recovered is not None else 0
)
summary_topic_counts = summary.get("topic_counts")
topic_counts = (
_normalized_topic_counts(summary_topic_counts)
if completed is not None
else recovered.topic_counts if recovered is not None else {}
)
media_sources = _discover_media_sources(session_root)
modalities = list(_modalities(topic_counts))
if media_sources:
modalities.append("video")
replayable = bool(
not active
and
raw_magic_ok
and raw_bytes > len(RAW_MAGIC)
and message_count > 0
and any(modality in {"point-cloud", "trajectory"} for modality in modalities)
)
if completed is not None:
status = (
"interrupted"
if active
else _status(replayable, summary.get("error"), summary.get("stop_reason"))
)
started_at = (
_safe_timestamp(summary.get("created_at_utc"))
or _safe_timestamp(manifest.get("started_at_utc"))
or _timestamp_from_session_name(session_root.name)
)
completed_at = _safe_timestamp(summary.get("completed_at_utc")) or _safe_timestamp(
manifest.get("completed_at_utc")
)
duration = _duration(summary.get("capture_elapsed_seconds"))
declared_hash = _declared_raw_hash(summary)
integrity_status = "verified" if declared_hash is not None else "validated-structure"
else:
status = "interrupted" if recovered is not None or active else "failed"
started_at = (
recovered.started_at_utc
if recovered is not None
else _safe_timestamp(manifest.get("started_at_utc"))
or _timestamp_from_session_name(session_root.name)
)
completed_at = recovered.completed_at_utc if recovered is not None else None
duration = recovered.duration_seconds if recovered is not None else None
declared_hash = None
integrity_status = "validated-prefix" if recovered is not None else "unverified"
replay_raw_bytes = (
completed.raw_committed_bytes
if completed is not None
else recovered.raw_committed_bytes if recovered is not None else 0
)
replay_metadata_bytes = (
completed.metadata_committed_bytes
if completed is not None
else recovered.metadata_committed_bytes if recovered is not None else 0
)
return LegacySessionCandidate(
session_id=session_root.name,
display_name=session_root.name,
status=status,
started_at_utc=started_at,
completed_at_utc=completed_at,
duration_seconds=duration,
modalities=tuple(modalities),
replayable=replayable,
total_bytes=raw_bytes + sum(source.byte_length for source in media_sources),
allowed_root=allowed_root,
session_root=session_root,
raw_path=raw_path.resolve(strict=False),
raw_byte_length=raw_bytes,
replay_raw_byte_length=replay_raw_bytes,
replay_metadata_byte_length=replay_metadata_bytes,
raw_sha256=declared_hash,
raw_integrity_status=integrity_status,
media_sources=media_sources,
)
def _is_completed_summary(summary: dict[str, Any]) -> bool:
return (
"message_count" in summary
and isinstance(summary.get("message_count"), int)
and not isinstance(summary.get("message_count"), bool)
and isinstance(summary.get("topic_counts"), dict)
and isinstance(summary.get("stop_reason"), str)
)
def _validate_completed_capture(
capture_root: Path,
session_root: Path,
raw_path: Path,
summary: dict[str, Any],
) -> _RecoveredCapture | None:
metadata_path = capture_root / "mqtt.metadata.jsonl"
try:
raw_stat = raw_path.lstat()
metadata_stat = metadata_path.lstat()
except OSError:
return None
if not stat.S_ISREG(raw_stat.st_mode) or not stat.S_ISREG(metadata_stat.st_mode):
return None
fingerprint = json.dumps(
{
"message_count": summary.get("message_count"),
"topic_counts": summary.get("topic_counts"),
"raw_bytes": summary.get("raw_bytes"),
"artifact_hashes": summary.get("artifact_hashes"),
},
sort_keys=True,
separators=(",", ":"),
)
return _validate_completed_capture_cached(
str(capture_root),
str(session_root),
str(raw_path),
_stat_identity(raw_stat),
_stat_identity(metadata_stat),
fingerprint,
)
@lru_cache(maxsize=128)
def _validate_completed_capture_cached(
capture_root_text: str,
session_root_text: str,
raw_path_text: str,
_raw_identity: tuple[int, int, int, int, int],
_metadata_identity: tuple[int, int, int, int, int],
summary_fingerprint: str,
) -> _RecoveredCapture | None:
capture_root = Path(capture_root_text)
session_root = Path(session_root_text)
raw_path = Path(raw_path_text)
summary = json.loads(summary_fingerprint)
validated = _scan_capture_prefix(
capture_root,
session_root,
raw_path,
tolerate_incomplete_metadata_tail=False,
tolerate_raw_crash_tail=False,
)
if validated is None:
return None
if validated.message_count != _non_negative_int(summary.get("message_count")):
return None
if validated.topic_counts != _normalized_topic_counts(summary.get("topic_counts")):
return None
declared_raw_bytes = summary.get("raw_bytes")
if declared_raw_bytes is not None and (
not isinstance(declared_raw_bytes, int)
or isinstance(declared_raw_bytes, bool)
or declared_raw_bytes != validated.raw_committed_bytes
):
return None
raw_hash = _declared_raw_hash(summary)
if raw_hash is not None and _sha256_stable(raw_path) != raw_hash:
return None
metadata_hash = _declared_metadata_hash(summary)
metadata_path = capture_root / "mqtt.metadata.jsonl"
if metadata_hash is not None and _sha256_stable(metadata_path) != metadata_hash:
return None
return validated
def _recover_interrupted_capture(
capture_root: Path,
session_root: Path,
raw_path: Path,
) -> _RecoveredCapture | None:
return _scan_capture_prefix(
capture_root,
session_root,
raw_path,
tolerate_incomplete_metadata_tail=True,
tolerate_raw_crash_tail=True,
)
def _scan_capture_prefix(
capture_root: Path,
session_root: Path,
raw_path: Path,
*,
tolerate_incomplete_metadata_tail: bool,
tolerate_raw_crash_tail: bool,
) -> _RecoveredCapture | None:
metadata_path = capture_root / "mqtt.metadata.jsonl"
if not _confined_file(raw_path, session_root) or not _confined_file(
metadata_path, session_root
):
return None
try:
raw_size = raw_path.stat().st_size
with raw_path.open("rb") as raw_stream, metadata_path.open("rb") as metadata_stream:
if raw_stream.read(len(RAW_MAGIC)) != RAW_MAGIC:
return None
consumed_metadata_bytes = 0
message_count = 0
topic_counts: dict[str, int] = {}
first_epoch_ns: int | None = None
last_epoch_ns: int | None = None
first_monotonic_ns: int | None = None
last_monotonic_ns: int | None = None
first_timestamp: str | None = None
last_timestamp: str | None = None
metadata_committed_bytes = 0
while (
consumed_metadata_bytes < MAX_RECOVERY_METADATA_BYTES
and message_count < MAX_RECOVERY_MESSAGES
):
remaining = MAX_RECOVERY_METADATA_BYTES - consumed_metadata_bytes
read_limit = min(MAX_RECOVERY_METADATA_LINE_BYTES + 1, remaining + 1)
line = metadata_stream.readline(read_limit)
if not line:
break
consumed_metadata_bytes += len(line)
if (
len(line) > MAX_RECOVERY_METADATA_LINE_BYTES
or consumed_metadata_bytes > MAX_RECOVERY_METADATA_BYTES
):
return None
if not line.endswith(b"\n"):
if tolerate_incomplete_metadata_tail:
break
return None
record = _metadata_record(line)
if record is None:
return None
metadata_committed_bytes = consumed_metadata_bytes
if record.get("record_type") != "message":
continue
expected_sequence = message_count + 1
validated = _validate_recovery_frame(
raw_stream,
raw_size=raw_size,
record=record,
expected_sequence=expected_sequence,
)
if validated is None:
return None
topic, epoch_ns, monotonic_ns, timestamp = validated
if last_monotonic_ns is not None and monotonic_ns < last_monotonic_ns:
return None
message_count = expected_sequence
topic_counts[topic] = topic_counts.get(topic, 0) + 1
if first_epoch_ns is None:
first_epoch_ns = epoch_ns
first_monotonic_ns = monotonic_ns
first_timestamp = timestamp
last_epoch_ns = epoch_ns
last_monotonic_ns = monotonic_ns
last_timestamp = timestamp
if metadata_stream.read(1):
return None
raw_committed_bytes = raw_stream.tell()
if raw_committed_bytes != raw_size and not (
tolerate_raw_crash_tail
and _tolerable_raw_crash_tail(raw_stream, raw_size=raw_size)
):
return None
except OSError:
return None
if (
message_count == 0
or first_epoch_ns is None
or last_epoch_ns is None
or first_timestamp is None
or last_timestamp is None
or first_monotonic_ns is None
or last_monotonic_ns is None
):
return None
return _RecoveredCapture(
message_count=message_count,
topic_counts=topic_counts,
started_at_utc=first_timestamp,
completed_at_utc=last_timestamp,
duration_seconds=(last_monotonic_ns - first_monotonic_ns) / 1_000_000_000,
raw_committed_bytes=raw_committed_bytes,
metadata_committed_bytes=metadata_committed_bytes,
)
def _metadata_record(line: bytes) -> dict[str, Any] | None:
try:
value = json.loads(line.decode("utf-8"))
except (UnicodeDecodeError, json.JSONDecodeError):
return None
return value if isinstance(value, dict) else None
def _tolerable_raw_crash_tail(raw_stream: IO[bytes], *, raw_size: int) -> bool:
"""Accept at most one raw frame which was not committed by metadata.
The capture writer flushes raw before metadata and does not begin the next
message until the current metadata record is written. Consequently a
process crash can leave only a prefix (or all) of one additional frame.
Anything beyond that boundary is corruption, not a recoverable tail.
"""
tail_offset = raw_stream.tell()
tail_bytes = raw_size - tail_offset
if tail_bytes <= 0:
return True
if tail_bytes > GROUP_COMMIT_MAX_BYTES + MAX_CONFIGURABLE_MESSAGE_BYTES:
return False
frames = 0
while raw_stream.tell() < raw_size:
remaining = raw_size - raw_stream.tell()
header = raw_stream.read(min(remaining, FRAME_HEADER.size))
if len(header) < FRAME_HEADER.size:
return frames < GROUP_COMMIT_MAX_MESSAGES
topic_length, payload_length = FRAME_HEADER.unpack(header)
if not 1 <= topic_length <= MAX_TOPIC_BYTES:
return False
if payload_length > MAX_CONFIGURABLE_MESSAGE_BYTES:
return False
frame_bytes = FRAME_HEADER.size + topic_length + payload_length
frame_remaining = raw_size - (raw_stream.tell() - FRAME_HEADER.size)
available_topic_bytes = min(
topic_length,
max(0, frame_remaining - FRAME_HEADER.size),
)
topic_prefix = raw_stream.read(available_topic_bytes)
if available_topic_bytes < topic_length:
return frames < GROUP_COMMIT_MAX_MESSAGES
try:
topic = topic_prefix.decode("utf-8")
except UnicodeDecodeError:
return False
if not topic:
return False
payload_available = frame_remaining - FRAME_HEADER.size - topic_length
if payload_available < payload_length:
return frames < GROUP_COMMIT_MAX_MESSAGES
raw_stream.seek(payload_length, 1)
frames += 1
if frames > GROUP_COMMIT_MAX_MESSAGES or frame_remaining < frame_bytes:
return False
return frames <= GROUP_COMMIT_MAX_MESSAGES
def _validate_recovery_frame(
raw_stream: IO[bytes],
*,
raw_size: int,
record: dict[str, Any],
expected_sequence: int,
) -> tuple[str, int, int, str] | None:
sequence = record.get("sequence")
topic = record.get("topic")
payload_bytes = record.get("payload_bytes")
frame_offset = record.get("raw_frame_offset")
payload_offset = record.get("raw_payload_offset")
frame_bytes = record.get("raw_frame_bytes")
epoch_ns = record.get("received_at_epoch_ns")
monotonic_ns = record.get("received_monotonic_ns")
if (
sequence != expected_sequence
or not isinstance(topic, str)
or not topic
or not isinstance(payload_bytes, int)
or isinstance(payload_bytes, bool)
or not 0 <= payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES
or not isinstance(frame_offset, int)
or isinstance(frame_offset, bool)
or frame_offset != raw_stream.tell()
or not isinstance(payload_offset, int)
or isinstance(payload_offset, bool)
or not isinstance(frame_bytes, int)
or isinstance(frame_bytes, bool)
or not isinstance(epoch_ns, int)
or isinstance(epoch_ns, bool)
or epoch_ns < 0
or not isinstance(monotonic_ns, int)
or isinstance(monotonic_ns, bool)
or monotonic_ns < 0
):
return None
header = raw_stream.read(FRAME_HEADER.size)
if len(header) != FRAME_HEADER.size:
return None
topic_length, raw_payload_bytes = FRAME_HEADER.unpack(header)
if (
not 1 <= topic_length <= MAX_TOPIC_BYTES
or raw_payload_bytes != payload_bytes
or payload_offset != frame_offset + FRAME_HEADER.size + topic_length
or frame_bytes != FRAME_HEADER.size + topic_length + payload_bytes
or frame_offset + frame_bytes > raw_size
):
return None
raw_topic = raw_stream.read(topic_length)
try:
decoded_topic = raw_topic.decode("utf-8")
except UnicodeDecodeError:
return None
if decoded_topic != topic:
return None
raw_stream.seek(payload_bytes, 1)
timestamp = _safe_timestamp(record.get("received_at_utc"))
if timestamp is None:
try:
timestamp = datetime.fromtimestamp(epoch_ns / 1_000_000_000, tz=UTC).isoformat()
except (OSError, OverflowError, ValueError):
return None
return topic, epoch_ns, monotonic_ns, timestamp
def _read_json_object(path: Path) -> dict[str, Any]:
try:
if not path.is_file() or path.stat().st_size > MAX_LEGACY_JSON_BYTES:
return {}
value = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return {}
return value if isinstance(value, dict) else {}
def _confined_file(path: Path, session_root: Path) -> bool:
try:
resolved = path.resolve(strict=True)
except OSError:
return False
return resolved.is_file() and resolved.is_relative_to(session_root)
def _non_negative_int(value: object) -> int:
return value if isinstance(value, int) and not isinstance(value, bool) and value >= 0 else 0
def _normalized_topic_counts(value: object) -> dict[str, int]:
if not isinstance(value, dict):
return {}
result: dict[str, int] = {}
for topic, raw_count in value.items():
count = _non_negative_int(raw_count)
if isinstance(topic, str) and count > 0:
result[topic] = count
return result
def _duration(value: object) -> float | None:
if not isinstance(value, (int, float)) or isinstance(value, bool):
return None
duration = float(value)
return duration if math.isfinite(duration) and duration >= 0 else None
def _safe_timestamp(value: object) -> str | None:
if not isinstance(value, str) or len(value) > 64:
return None
candidate = value.strip()
try:
parsed = datetime.fromisoformat(candidate.replace("Z", "+00:00"))
except ValueError:
return None
if parsed.tzinfo is None:
return None
return candidate
def _timestamp_from_session_name(session_id: str) -> str | None:
prefix = session_id.split("_", 1)[0]
try:
parsed = datetime.strptime(prefix, "%Y%m%dT%H%M%SZ").replace(tzinfo=UTC)
except ValueError:
return None
return parsed.isoformat().replace("+00:00", "Z")
def _declared_raw_hash(summary: dict[str, Any]) -> str | None:
hashes = summary.get("artifact_hashes")
if not isinstance(hashes, dict):
return None
value = hashes.get("raw_sha256")
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _declared_metadata_hash(summary: dict[str, Any]) -> str | None:
hashes = summary.get("artifact_hashes")
if not isinstance(hashes, dict):
return None
value = hashes.get("metadata_jsonl_sha256")
return value if isinstance(value, str) and SHA256_PATTERN.fullmatch(value) else None
def _sha256_stable(path: Path) -> str:
try:
current = path.lstat()
except OSError:
return ""
if not stat.S_ISREG(current.st_mode):
return ""
return _sha256_cached(str(path), _stat_identity(current))
@lru_cache(maxsize=128)
def _sha256_cached(
path_text: str,
expected_identity: tuple[int, int, int, int, int],
) -> str:
path = Path(path_text)
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
except OSError:
return ""
try:
before = os.fstat(descriptor)
if not stat.S_ISREG(before.st_mode) or _stat_identity(before) != expected_identity:
return ""
digest = hashlib.sha256()
while chunk := os.read(descriptor, 1024 * 1024):
digest.update(chunk)
after = os.fstat(descriptor)
try:
current = os.lstat(path)
except OSError:
return ""
if _stat_identity(before) != _stat_identity(after) or (
current.st_dev,
current.st_ino,
) != (before.st_dev, before.st_ino):
return ""
return digest.hexdigest()
finally:
os.close(descriptor)
def _stat_identity(value: os.stat_result) -> tuple[int, int, int, int, int]:
return (value.st_dev, value.st_ino, value.st_size, value.st_mtime_ns, value.st_ctime_ns)
def _active_session_root(root: Path) -> Path | None:
marker = root / ".current_session"
try:
marker_stat = marker.lstat()
if not stat.S_ISREG(marker_stat.st_mode) or marker_stat.st_size > 4096:
return None
value = marker.read_text(encoding="utf-8").strip()
except (OSError, UnicodeDecodeError):
return None
if not value:
return None
raw = Path(value).expanduser()
if raw.is_absolute():
candidate = raw
elif raw.parts and raw.parts[0] == root.name:
candidate = root.parent / raw
else:
candidate = root / raw
try:
resolved = candidate.resolve(strict=True)
except OSError:
return None
return resolved if resolved.is_dir() and resolved.is_relative_to(root) else None
def _modalities(topic_counts: Mapping[str, int]) -> tuple[SessionModality, ...]:
topics = {
topic
for topic, count in topic_counts.items()
if isinstance(topic, str) and _non_negative_int(count) > 0
}
result: list[SessionModality] = []
if any(topic == "RealtimePointcloud" or topic.endswith("/lio_pcl") for topic in topics):
result.append("point-cloud")
if any(topic == "RealtimePath" or topic.endswith("/lio_pose") for topic in topics):
result.append("trajectory")
return tuple(result)
def _status(replayable: bool, error: object, stop_reason: object) -> SessionStatus:
if error is None and stop_reason in {
"duration_elapsed",
"external_stop",
"keyboard_interrupt",
}:
return "ready" if replayable else "failed"
return "interrupted" if replayable else "failed"
def _discover_media_sources(session_root: Path) -> tuple[LegacyMediaSourceCandidate, ...]:
media_root = session_root / "media"
try:
resolved_media_root = media_root.resolve(strict=True)
except OSError:
return ()
if not resolved_media_root.is_dir() or not resolved_media_root.is_relative_to(session_root):
return ()
candidates: list[LegacyMediaSourceCandidate] = []
for source_root in sorted(resolved_media_root.iterdir()):
if not source_root.is_dir() or not MEDIA_SOURCE_PATTERN.fullmatch(source_root.name):
continue
try:
resolved_source_root = source_root.resolve(strict=True)
except OSError:
continue
if not resolved_source_root.is_relative_to(resolved_media_root):
continue
epochs = [
epoch
for epoch in sorted(resolved_source_root.iterdir())
if epoch.is_dir()
and MEDIA_EPOCH_PATTERN.fullmatch(epoch.name)
and _validated_media_epoch(epoch, resolved_source_root.name)
]
if not epochs:
continue
byte_length = sum(_media_epoch_bytes(epoch) for epoch in epochs)
artifact_suffix = hashlib.sha256(resolved_source_root.name.encode()).hexdigest()[:16]
candidates.append(
LegacyMediaSourceCandidate(
source_id=resolved_source_root.name,
artifact_id=f"recorded-video-{artifact_suffix}",
locator=resolved_source_root,
byte_length=byte_length,
epoch_count=len(epochs),
)
)
return tuple(candidates)
def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
try:
resolved_epoch = epoch.resolve(strict=True)
init_path = (epoch / "init.mp4").resolve(strict=True)
segments_root = (epoch / "segments").resolve(strict=True)
index_path = (epoch / "index.jsonl").resolve(strict=True)
summary_path = (epoch / "summary.json").resolve(strict=True)
except OSError:
return False
if not all(
path.is_relative_to(resolved_epoch)
for path in (init_path, segments_root, index_path, summary_path)
):
return False
if (
not init_path.is_file()
or init_path.stat().st_size <= 0
or not segments_root.is_dir()
or not index_path.is_file()
or not 0 < index_path.stat().st_size <= MAX_MEDIA_INDEX_BYTES
or not summary_path.is_file()
):
return False
summary = _read_json_object(summary_path)
if summary.get("schema_version") not in {1, "missioncore.camera-recording/v1"}:
return False
if summary.get("source_id") != expected_source_id:
return False
segment_count = _non_negative_int(summary.get("segment_count"))
if segment_count < 1:
return False
segments = [
path.resolve()
for path in sorted(segments_root.iterdir())
if path.is_file() and MEDIA_SEGMENT_PATTERN.fullmatch(path.name)
]
if len(segments) != segment_count or any(
not path.is_relative_to(segments_root) or path.stat().st_size <= 0 for path in segments
):
return False
try:
index_records = [
json.loads(line)
for line in index_path.read_text(encoding="utf-8").splitlines()
if line.strip()
]
except (OSError, UnicodeDecodeError, json.JSONDecodeError):
return False
if len(index_records) != segment_count or not all(
isinstance(record, dict)
and _non_negative_int(record.get("sequence")) > 0
for record in index_records
):
return False
sequences = [int(record["sequence"]) for record in index_records]
return len(sequences) == len(set(sequences))
def _media_epoch_bytes(epoch: Path) -> int:
try:
resolved_epoch = epoch.resolve(strict=True)
except OSError:
return 0
total = 0
for path in resolved_epoch.rglob("*"):
try:
resolved = path.resolve(strict=True)
except OSError:
continue
if resolved.is_file() and resolved.is_relative_to(resolved_epoch):
total += resolved.stat().st_size
return total
@@ -0,0 +1 @@
"""Bluetooth Low Energy discovery tools."""
@@ -0,0 +1,104 @@
from __future__ import annotations
import asyncio
from importlib.metadata import version
from typing import TypedDict
from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError
from k1link.artifacts import utc_now_iso
class DescriptorRecord(TypedDict):
uuid: str
handle: int
description: str
class CharacteristicRecord(TypedDict):
uuid: str
handle: int
description: str
properties: list[str]
descriptors: list[DescriptorRecord]
class ServiceRecord(TypedDict):
uuid: str
handle: int
description: str
characteristics: list[CharacteristicRecord]
class GattDumpResult(TypedDict):
schema_version: int
started_at_utc: str
completed_at_utc: str
adapter: str
bleak_version: str
device_macos_uuid: str
device_name: str
metadata_only: bool
services: list[ServiceRecord]
async def dump_metadata(device_macos_uuid: str, timeout_seconds: float) -> GattDumpResult:
"""Connect and enumerate GATT metadata without characteristic reads or writes."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
started_at = utc_now_iso()
async with asyncio.timeout(timeout_seconds):
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
services: list[ServiceRecord] = []
for service in client.services:
characteristics: list[CharacteristicRecord] = []
for characteristic in service.characteristics:
descriptors: list[DescriptorRecord] = []
for descriptor in characteristic.descriptors:
descriptors.append(
{
"uuid": descriptor.uuid,
"handle": descriptor.handle,
"description": descriptor.description,
}
)
characteristics.append(
{
"uuid": characteristic.uuid,
"handle": characteristic.handle,
"description": characteristic.description,
"properties": sorted(characteristic.properties),
"descriptors": descriptors,
}
)
services.append(
{
"uuid": service.uuid,
"handle": service.handle,
"description": service.description,
"characteristics": characteristics,
}
)
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": client.name,
"metadata_only": True,
"services": services,
}
@@ -0,0 +1,68 @@
from __future__ import annotations
import asyncio
from importlib.metadata import version
from typing import TypedDict
from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError
from k1link.artifacts import utc_now_iso
class CharacteristicReadResult(TypedDict):
schema_version: int
started_at_utc: str
completed_at_utc: str
adapter: str
bleak_version: str
device_macos_uuid: str
device_name: str
characteristic_uuid: str
operation: str
value_length: int
value_hex: str
async def read_characteristic_once(
device_macos_uuid: str,
characteristic_uuid: str,
timeout_seconds: float,
) -> CharacteristicReadResult:
"""Read one explicitly selected characteristic once without pairing or writes."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
started_at = utc_now_iso()
async with asyncio.timeout(timeout_seconds):
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
characteristic = client.services.get_characteristic(characteristic_uuid)
if characteristic is None:
raise ValueError(f"Characteristic not found: {characteristic_uuid}")
if "read" not in characteristic.properties:
raise ValueError(f"Characteristic is not readable: {characteristic_uuid}")
value = bytes(await client.read_gatt_char(characteristic))
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": client.name,
"characteristic_uuid": characteristic.uuid,
"operation": "single_gatt_read_no_pair_no_write",
"value_length": len(value),
"value_hex": value.hex(),
}
@@ -0,0 +1,82 @@
from __future__ import annotations
from importlib.metadata import version
from typing import TypedDict
from bleak import BleakScanner
from bleak.backends.device import BLEDevice
from bleak.backends.scanner import AdvertisementData
from k1link.artifacts import utc_now_iso
class BleDeviceRecord(TypedDict):
macos_uuid: str
id_kind: str
name: str | None
local_name: str | None
rssi: int
tx_power: int | None
service_uuids: list[str]
manufacturer_data_hex: dict[str, str]
service_data_hex: dict[str, str]
k1_name_candidate: bool
class BleScanResult(TypedDict):
schema_version: int
started_at_utc: str
completed_at_utc: str
duration_seconds: float
adapter: str
bleak_version: str
device_count: int
devices: list[BleDeviceRecord]
def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) -> BleDeviceRecord:
name = advertisement.local_name or device.name
normalized_name = (name or "").casefold()
return {
"macos_uuid": device.address,
"id_kind": "corebluetooth_uuid",
"name": device.name,
"local_name": advertisement.local_name,
"rssi": advertisement.rssi,
"tx_power": advertisement.tx_power,
"service_uuids": sorted(advertisement.service_uuids),
"manufacturer_data_hex": {
str(company_id): data.hex()
for company_id, data in sorted(advertisement.manufacturer_data.items())
},
"service_data_hex": {
service_uuid: data.hex()
for service_uuid, data in sorted(advertisement.service_data.items())
},
"k1_name_candidate": normalized_name.startswith("xgr-")
or any(marker in normalized_name for marker in ("lixel", "xgrids", "k1")),
}
async def scan(duration_seconds: float) -> BleScanResult:
if duration_seconds <= 0:
raise ValueError("duration_seconds must be positive")
started_at = utc_now_iso()
discovered = await BleakScanner.discover(timeout=duration_seconds, return_adv=True)
devices = [
advertisement_record(device, advertisement) for device, advertisement in discovered.values()
]
devices.sort(
key=lambda item: (not item["k1_name_candidate"], -item["rssi"], item["macos_uuid"])
)
return {
"schema_version": 1,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"duration_seconds": duration_seconds,
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_count": len(devices),
"devices": devices,
}
@@ -0,0 +1,281 @@
from __future__ import annotations
import asyncio
import ipaddress
from importlib.metadata import version
from time import monotonic
from typing import Literal, TypedDict
from bleak import BleakClient, BleakScanner
from bleak.exc import BleakDeviceNotFoundError, BleakError
from k1link.artifacts import utc_now_iso
PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
SERVICE_UUID = "00007f00-0000-1000-8000-00805f9b34fb"
WRITE_CHARACTERISTIC_UUID = "00007f01-0000-1000-8000-00805f9b34fb"
STATUS_CHARACTERISTIC_UUID = "00007f02-0000-1000-8000-00805f9b34fb"
FRAME_LENGTH = 99
SSID_SLOT_LENGTH = 32
PASSWORD_SLOT_LENGTH = 64
AP_FALLBACK_IPV4 = "192.168.56.1"
ProvisioningOutcome = Literal[
"lan_address_observed",
"status_changed",
"no_status_change_before_timeout",
"ble_disconnected_after_write",
]
WriteMode = Literal["auto", "with_response", "without_response"]
ResolvedWriteMode = Literal["with_response", "without_response"]
class WifiStatus(TypedDict):
value_length: int
mode: str | None
ipv4: str | None
status_code: int
reserved: int | None
trailer_hex: str
class StatusObservation(TypedDict):
observed_at_utc: str
seconds_after_write: float
status: WifiStatus
class WifiProvisioningResult(TypedDict):
schema_version: int
profile_id: str
started_at_utc: str
completed_at_utc: str
adapter: str
bleak_version: str
device_macos_uuid: str
device_name: str
service_uuid: str
write_characteristic_uuid: str
status_characteristic_uuid: str
operation: str
write_mode: ResolvedWriteMode
write_without_response_advertised: bool
max_write_without_response_size: int
frame_length: int
baseline_status: WifiStatus
observations: list[StatusObservation]
outcome: ProvisioningOutcome
def build_wifi_provisioning_frame(ssid: str, password: str) -> bytearray:
"""Build the deterministic 99-byte frame used by LixelGO for K1 Wi-Fi setup."""
ssid_bytes = ssid.encode("utf-8")
password_bytes = password.encode("utf-8")
if not ssid_bytes:
raise ValueError("SSID must not be empty")
if not password_bytes:
raise ValueError("Wi-Fi password must not be empty")
if len(ssid_bytes) > SSID_SLOT_LENGTH:
raise ValueError("SSID must be at most 32 UTF-8 bytes")
if len(password_bytes) > PASSWORD_SLOT_LENGTH:
raise ValueError("Wi-Fi password must be at most 64 UTF-8 bytes")
frame = bytearray(FRAME_LENGTH)
frame[0] = len(ssid_bytes)
frame[1 : 1 + len(ssid_bytes)] = ssid_bytes
frame[33] = len(password_bytes)
frame[34 : 34 + len(password_bytes)] = password_bytes
frame[98] = 0
return frame
def parse_wifi_status(value: bytes) -> WifiStatus:
"""Parse the non-secret status frame returned by the K1 read characteristic."""
if len(value) < 51:
raise ValueError("K1 Wi-Fi status must contain at least 51 bytes")
mode_length = value[0]
if mode_length > SSID_SLOT_LENGTH:
raise ValueError("K1 Wi-Fi status mode length is invalid")
try:
mode = value[1 : 1 + mode_length].decode("utf-8") if mode_length else None
except UnicodeDecodeError as exc:
raise ValueError("K1 Wi-Fi status mode is not valid UTF-8") from exc
address_length = value[33]
address_start = 34
address_end = address_start + address_length
if address_end > len(value):
raise ValueError("K1 Wi-Fi status address length exceeds the frame")
ipv4: str | None = None
if address_length:
try:
address = ipaddress.ip_address(value[address_start:address_end])
except ValueError:
address = None
if isinstance(address, ipaddress.IPv4Address):
ipv4 = str(address)
return {
"value_length": len(value),
"mode": mode,
"ipv4": ipv4,
"status_code": value[50],
"reserved": value[51] if len(value) > 51 else None,
"trailer_hex": value[52:].hex() if len(value) > 52 else "",
}
def _outcome(
baseline: WifiStatus,
observations: list[StatusObservation],
disconnected: bool,
) -> ProvisioningOutcome:
if observations:
final = observations[-1]["status"]
if final["ipv4"] not in (None, AP_FALLBACK_IPV4):
return "lan_address_observed"
if final != baseline:
return "status_changed"
if disconnected:
return "ble_disconnected_after_write"
return "no_status_change_before_timeout"
async def provision_wifi_once(
device_macos_uuid: str,
ssid: str,
password: str,
timeout_seconds: float = 45.0,
poll_interval_seconds: float = 1.0,
write_mode: WriteMode = "auto",
) -> WifiProvisioningResult:
"""Perform one reviewed provisioning write and poll the K1 status characteristic."""
if timeout_seconds <= 0:
raise ValueError("timeout_seconds must be positive")
if poll_interval_seconds <= 0:
raise ValueError("poll_interval_seconds must be positive")
if write_mode not in ("auto", "with_response", "without_response"):
raise ValueError(f"Unsupported write mode: {write_mode}")
frame = build_wifi_provisioning_frame(ssid, password)
started_at = utc_now_iso()
observations: list[StatusObservation] = []
disconnected = False
try:
async with asyncio.timeout(timeout_seconds + 25.0):
device = await BleakScanner.find_device_by_address(
device_macos_uuid,
timeout=min(20.0, timeout_seconds),
)
if device is None:
raise BleakDeviceNotFoundError(
device_macos_uuid,
"Device was not rediscovered; keep the K1 powered and nearby.",
)
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
device_name = client.name
service = client.services.get_service(SERVICE_UUID)
write_characteristic = client.services.get_characteristic(WRITE_CHARACTERISTIC_UUID)
status_characteristic = client.services.get_characteristic(
STATUS_CHARACTERISTIC_UUID
)
if service is None:
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
if write_characteristic is None:
raise ValueError(
f"Reviewed K1 write characteristic not found: {WRITE_CHARACTERISTIC_UUID}"
)
if status_characteristic is None:
raise ValueError(
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
)
if write_characteristic.service_uuid != service.uuid:
raise ValueError("K1 write characteristic is attached to an unexpected service")
if status_characteristic.service_uuid != service.uuid:
raise ValueError(
"K1 status characteristic is attached to an unexpected service"
)
if "read" not in status_characteristic.properties:
raise ValueError("Reviewed K1 status characteristic is not readable")
properties = set(write_characteristic.properties)
max_without_response = write_characteristic.max_write_without_response_size
resolved_write_mode: ResolvedWriteMode
if write_mode == "auto":
if "write-without-response" in properties:
resolved_write_mode = "without_response"
elif "write" in properties:
resolved_write_mode = "with_response"
else:
raise ValueError("Reviewed K1 characteristic is not writable")
elif write_mode == "with_response":
if "write" not in properties:
raise ValueError(
"Reviewed K1 characteristic does not advertise writes with response"
)
resolved_write_mode = "with_response"
else:
if len(frame) > max_without_response:
raise ValueError(
"Provisioning frame exceeds the negotiated write-without-response size"
)
resolved_write_mode = "without_response"
baseline_value = bytes(await client.read_gatt_char(status_characteristic))
baseline = parse_wifi_status(baseline_value)
await client.write_gatt_char(
write_characteristic,
frame,
response=resolved_write_mode == "with_response",
)
write_completed = monotonic()
deadline = write_completed + timeout_seconds
while monotonic() < deadline:
try:
value = bytes(await client.read_gatt_char(status_characteristic))
except BleakError:
if not client.is_connected:
disconnected = True
break
raise
status = parse_wifi_status(value)
observation: StatusObservation = {
"observed_at_utc": utc_now_iso(),
"seconds_after_write": round(monotonic() - write_completed, 3),
"status": status,
}
if not observations or status != observations[-1]["status"]:
observations.append(observation)
if status["ipv4"] not in (None, AP_FALLBACK_IPV4):
break
await asyncio.sleep(poll_interval_seconds)
return {
"schema_version": 1,
"profile_id": PROFILE_ID,
"started_at_utc": started_at,
"completed_at_utc": utc_now_iso(),
"adapter": "CoreBluetooth",
"bleak_version": version("bleak"),
"device_macos_uuid": device_macos_uuid,
"device_name": device_name,
"service_uuid": service.uuid,
"write_characteristic_uuid": write_characteristic.uuid,
"status_characteristic_uuid": status_characteristic.uuid,
"operation": "single_reviewed_wifi_provisioning_write",
"write_mode": resolved_write_mode,
"write_without_response_advertised": ("write-without-response" in properties),
"max_write_without_response_size": max_without_response,
"frame_length": len(frame),
"baseline_status": baseline,
"observations": observations,
"outcome": _outcome(baseline, observations, disconnected),
}
finally:
frame[:] = b"\x00" * len(frame)
@@ -0,0 +1,974 @@
from __future__ import annotations
import asyncio
import os
import queue
import signal
import subprocess
import threading
from collections import deque
from contextlib import suppress
from dataclasses import dataclass, field
from pathlib import Path
from typing import IO, Any, Final, Literal
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
from k1link.web.camera_archive import (
CameraArchiveError,
CameraArchiveKind,
CameraArchiveStatus,
CameraArchiveWriter,
)
CameraSourceId = Literal["sensor.camera.left", "sensor.camera.right"]
CAMERA_SOURCE_PATHS: Final[dict[CameraSourceId, str]] = {
"sensor.camera.left": "/live/chn_left_main",
"sensor.camera.right": "/live/chn_right_main",
}
CAMERA_SOURCE_LABELS: Final[dict[CameraSourceId, str]] = {
"sensor.camera.left": "K1 · камера слева",
"sensor.camera.right": "K1 · камера справа",
}
CAMERA_MEDIA_TYPE: Final = 'video/mp4; codecs="avc1.641028"'
CAMERA_EXCLUSIVE_GROUP: Final = "camera.preview.decoder"
MAX_FMP4_BOX_BYTES: Final = 8 * 1024 * 1024
MAX_FMP4_SEGMENT_BYTES: Final = 1024 * 1024
MAX_QUEUED_SEGMENTS: Final = 4
CAMERA_DRAIN_TIMEOUT_SECONDS: Final = 5.0
@dataclass
class CameraProcessLease:
generation: int
source_id: CameraSourceId
process: subprocess.Popen[bytes]
stderr_tail: deque[str]
segments: queue.Queue[tuple[str, bytes] | None] = field(
default_factory=lambda: queue.Queue(maxsize=MAX_QUEUED_SEGMENTS)
)
failure_code: str | None = None
@dataclass
class _CameraProducer:
generation: int
source_id: CameraSourceId
process: subprocess.Popen[bytes]
archive: CameraArchiveWriter | None
stderr_tail: deque[str] = field(default_factory=lambda: deque(maxlen=12))
delivery: CameraProcessLease | None = None
init_segment: bytes | None = None
failure_code: str | None = None
stop_requested: bool = False
drain_requested: bool = False
reader_started: bool = False
reader_done: threading.Event = field(default_factory=threading.Event)
class XgridsK1CameraGateway:
"""One fail-closed K1 producer with independent archive and preview planes.
During an acquisition the gateway, rather than a browser WebSocket, owns
FFmpeg. Every complete fMP4 segment is durably appended before it can enter
the bounded preview queue. Attaching, dropping, or disconnecting a browser
therefore cannot stop or back-pressure the source-of-record camera stream.
Outside an acquisition the legacy lazy-preview lifecycle remains available.
"""
def __init__(self, repository_root: Path, plugin_id: str) -> None:
self._repository_root = repository_root.resolve()
self._plugin_id = plugin_id
self._lock = threading.RLock()
self._lifecycle_lock = threading.Lock()
self._revision = 0
self._generation = 0
self._phase = "idle"
self._source_id: CameraSourceId | None = None
self._target_host: str | None = None
self._producer: _CameraProducer | None = None
self._recording_root: Path | None = None
self._archive_summaries: list[dict[str, Any]] = []
self._error: dict[str, str] | None = None
self._closed = False
self._ffmpeg_path, self._ffmpeg_source = _resolve_ffmpeg(self._repository_root)
def snapshot(self) -> dict[str, Any]:
with self._lock:
delivery = None
if (
self._source_id is not None
and self._ffmpeg_path is not None
and self._phase != "error"
):
delivery = {
"id": f"camera-preview-{self._generation}",
"kind": "mse-fmp4-websocket",
"url": (
f"/api/v1/device-plugins/{self._plugin_id}"
f"/camera-preview/{self._generation}"
),
"media_type": CAMERA_MEDIA_TYPE,
}
return {
"schema_version": "missioncore.camera-preview/v1alpha1",
"phase": self._phase,
"revision": self._revision,
"generation": self._generation if self._source_id is not None else None,
"active_source_id": self._source_id,
"activation": {
"exclusive_group": CAMERA_EXCLUSIVE_GROUP,
"max_active": 1,
},
"recording": {
"active": self._recording_root is not None,
"session": (
self._recording_root.name if self._recording_root is not None else None
),
"active_epoch": (
self._producer.generation
if self._producer is not None and self._producer.archive is not None
else None
),
"completed_epochs": len(self._archive_summaries),
"last_summary": (
dict(self._archive_summaries[-1]) if self._archive_summaries else None
),
},
"delivery": delivery,
"runtime_dependency": {
"kind": "ffmpeg",
"status": "available" if self._ffmpeg_path is not None else "missing",
"source": self._ffmpeg_source,
},
"error": dict(self._error) if self._error is not None else None,
}
def select(self, source_id: CameraSourceId, target_host: str) -> dict[str, Any]:
if source_id not in CAMERA_SOURCE_PATHS:
raise ValueError("неизвестный camera source")
target = validate_private_ipv4(target_host)
with self._lifecycle_lock:
with self._lock:
self._require_open_locked()
if self._ffmpeg_path is None:
self._generation += 1
self._revision += 1
self._source_id = source_id
self._target_host = target
self._set_error_locked(
"ffmpeg-unavailable",
"Локальный camera adapter FFmpeg не найден.",
)
raise RuntimeError("локальный camera adapter FFmpeg не найден")
if (
self._source_id == source_id
and self._target_host == target
and self._phase in {"selected", "connecting", "streaming"}
):
return self.snapshot()
old_producer, old_delivery = self._detach_producer_locked()
self._generation += 1
self._revision += 1
self._source_id = source_id
self._target_host = target
self._phase = "selected"
self._error = None
recording_active = self._recording_root is not None
self._shutdown_producer(
old_producer,
old_delivery,
status="complete",
failure_code="source-switch",
)
if recording_active:
self._spawn_selected_producer()
return self.snapshot()
def stop(self, generation: int) -> dict[str, Any]:
with self._lifecycle_lock:
with self._lock:
if self._source_id is None:
return self.snapshot()
if generation != self._generation:
raise ValueError("camera preview generation устарело")
producer, delivery = self._detach_producer_locked()
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._error = None
self._shutdown_producer(
producer,
delivery,
status="complete",
failure_code="source-stopped",
)
return self.snapshot()
def stop_current(self) -> dict[str, Any]:
with self._lifecycle_lock:
with self._lock:
producer, delivery = self._detach_producer_locked()
changed = self._source_id is not None or self._phase != "idle"
if changed:
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._recording_root = None
self._error = None
self._shutdown_producer(
producer,
delivery,
status="interrupted",
failure_code="gateway-stop",
)
return self.snapshot()
def start_recording(self, session_dir: Path) -> dict[str, Any]:
"""Make an existing observation session the camera recording root."""
root = session_dir.expanduser().resolve()
if not root.is_dir():
raise ValueError("observation session directory does not exist")
if not root.is_relative_to(self._repository_root):
raise ValueError("camera recording root must stay inside the repository")
with self._lifecycle_lock:
with self._lock:
self._require_open_locked()
if self._recording_root is not None:
if self._recording_root == root:
return self.snapshot()
raise RuntimeError("для camera gateway уже активна другая acquisition-сессия")
producer, delivery = self._detach_producer_locked()
self._recording_root = root
self._archive_summaries = []
selected = self._source_id is not None
if selected:
self._phase = "selected"
self._revision += 1
# A pre-acquisition browser-owned process cannot become evidence
# retrospectively. Restart it at a clean codec epoch instead.
self._shutdown_producer(
producer,
delivery,
status="interrupted",
failure_code="recording-start-restart",
)
if selected:
self._spawn_selected_producer()
return self.snapshot()
def stop_recording(
self,
*,
status: CameraArchiveStatus = "complete",
failure_code: str | None = None,
) -> dict[str, Any]:
"""Stop the acquisition-owned producer and seal its active epoch."""
with self._lifecycle_lock:
with self._lock:
producer, delivery = self._detach_producer_locked()
recording_was_active = self._recording_root is not None
self._recording_root = None
if self._source_id is not None and self._phase != "error":
self._phase = "selected"
if recording_was_active or producer is not None:
self._revision += 1
self._shutdown_producer(
producer,
delivery,
status=status,
failure_code=failure_code,
)
return self.snapshot()
def open_delivery(self, generation: int) -> CameraProcessLease:
with self._lifecycle_lock:
with self._lock:
self._require_open_locked()
if generation != self._generation or self._source_id is None:
raise ValueError("camera preview generation не активно")
producer = self._producer
if producer is None:
producer = self._spawn_selected_producer()
with self._lock:
if self._producer is not producer or producer.generation != generation:
raise ValueError("camera preview generation не активно")
if producer.delivery is not None:
raise RuntimeError("для camera preview уже открыт browser consumer")
lease = CameraProcessLease(
generation=producer.generation,
source_id=producer.source_id,
process=producer.process,
stderr_tail=producer.stderr_tail,
)
producer.delivery = lease
if producer.init_segment is not None:
lease.segments.put_nowait(("init", producer.init_segment))
self._revision += 1
return lease
def mark_streaming(self, lease: CameraProcessLease) -> None:
with self._lock:
producer = self._producer
if producer is None or producer.delivery is not lease:
return
self._mark_streaming_locked(producer)
def release_delivery(self, lease: CameraProcessLease, *, client_closed: bool) -> None:
producer_to_stop: _CameraProducer | None = None
with self._lifecycle_lock:
with self._lock:
producer = self._producer
if producer is None or producer.delivery is not lease:
return
producer.delivery = None
self._revision += 1
# During acquisition the browser is a disposable observer. In
# legacy preview-only mode retain the old lazy-owner behavior.
if self._recording_root is None:
producer.stop_requested = True
self._producer = None
producer_to_stop = producer
self._phase = "selected"
self._error = None
if producer_to_stop is not None:
self._shutdown_producer(
producer_to_stop,
None,
status="interrupted",
failure_code=(lease.failure_code or "browser-disconnected"),
)
def close(self) -> None:
with self._lifecycle_lock:
with self._lock:
if self._closed:
return
self._closed = True
producer, delivery = self._detach_producer_locked()
self._revision += 1
self._phase = "idle"
self._source_id = None
self._target_host = None
self._recording_root = None
self._error = None
self._shutdown_producer(
producer,
delivery,
status="interrupted",
failure_code="gateway-closed",
)
def _spawn_selected_producer(self) -> _CameraProducer:
with self._lock:
self._require_open_locked()
source_id = self._source_id
target_host = self._target_host
ffmpeg_path = self._ffmpeg_path
generation = self._generation
recording_root = self._recording_root
if source_id is None or target_host is None or ffmpeg_path is None:
raise RuntimeError("camera preview runtime не готов")
if self._producer is not None:
return self._producer
archive: CameraArchiveWriter | None = None
if recording_root is not None:
try:
archive = CameraArchiveWriter(recording_root, source_id, generation)
except (OSError, ValueError, CameraArchiveError) as exc:
with self._lock:
self._set_error_locked(
"camera-storage-failed",
"Не удалось открыть долговременное хранилище camera stream.",
)
raise RuntimeError(
"Не удалось открыть долговременное хранилище camera stream."
) from exc
try:
process = subprocess.Popen(
_build_ffmpeg_argv(ffmpeg_path, target_host, source_id),
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
shell=False,
start_new_session=(os.name == "posix"),
)
except (OSError, ValueError) as exc:
if archive is not None:
with suppress(CameraArchiveError):
self._record_archive_summary(
archive.close(status="failed", failure_code="ffmpeg-start-failed")
)
with self._lock:
self._set_error_locked(
"ffmpeg-start-failed",
"Не удалось запустить локальный camera adapter.",
)
raise RuntimeError("Не удалось запустить локальный camera adapter.") from exc
if process.stdout is None or process.stderr is None:
_terminate_process(process)
if archive is not None:
self._record_archive_summary(
archive.close(status="failed", failure_code="ffmpeg-pipes-unavailable")
)
with self._lock:
self._set_error_locked(
"ffmpeg-pipes-unavailable",
"Camera adapter не открыл media pipes.",
)
raise RuntimeError("camera adapter не открыл media pipes")
producer = _CameraProducer(
generation=generation,
source_id=source_id,
process=process,
archive=archive,
)
with self._lock:
if (
self._closed
or generation != self._generation
or source_id != self._source_id
or target_host != self._target_host
):
producer.stop_requested = True
stale = True
else:
self._producer = producer
self._revision += 1
self._phase = "connecting"
self._error = None
stale = False
if stale:
self._shutdown_producer(
producer,
None,
status="interrupted",
failure_code="stale-generation",
)
raise RuntimeError("camera generation изменилась во время запуска adapter")
threading.Thread(
target=_drain_stderr,
args=(producer,),
name=f"k1-camera-stderr-{generation}",
daemon=True,
).start()
producer.reader_started = True
threading.Thread(
target=_read_fmp4_stdout,
args=(self, producer),
name=f"k1-camera-fmp4-{generation}",
daemon=True,
).start()
return producer
def _publish_segment(
self,
producer: _CameraProducer,
kind: CameraArchiveKind,
payload: bytes,
) -> bool:
if len(payload) > MAX_FMP4_SEGMENT_BYTES:
self._mark_producer_failure(
producer,
"segment-too-large",
"Camera adapter отклонил слишком большой video segment.",
)
return False
with self._lock:
producer_owned = self._producer is producer or (
producer.drain_requested and producer.archive is not None
)
if not producer_owned or producer.stop_requested:
return False
archive = producer.archive
if archive is not None:
try:
# Source of record first; preview is always expendable.
archive.append(kind, payload)
except (CameraArchiveError, OSError, ValueError):
self._mark_producer_failure(
producer,
"camera-storage-failed",
"Долговременная запись camera stream завершилась ошибкой.",
)
return False
with self._lock:
producer_owned = self._producer is producer or (
producer.drain_requested and producer.archive is not None
)
if not producer_owned or producer.stop_requested:
return False
if kind == "init":
producer.init_segment = payload
else:
self._mark_streaming_locked(producer)
delivery = producer.delivery
if delivery is None:
return True
try:
delivery.segments.put_nowait((kind, payload))
except queue.Full:
self._drop_slow_delivery(producer, delivery)
return True
def _drop_slow_delivery(
self,
producer: _CameraProducer,
delivery: CameraProcessLease,
) -> None:
with self._lock:
if self._producer is producer and producer.delivery is delivery:
producer.delivery = None
delivery.failure_code = "consumer-too-slow"
self._revision += 1
_close_segment_queue(delivery)
def _mark_producer_failure(
self,
producer: _CameraProducer,
code: str,
message: str,
) -> None:
with self._lock:
producer_owned = self._producer is producer or producer.drain_requested
if not producer_owned or producer.stop_requested:
return
producer.failure_code = code
self._set_error_locked(code, message)
with suppress(OSError):
producer.process.terminate()
def _producer_ended(self, producer: _CameraProducer) -> None:
with self._lock:
delivery = producer.delivery
producer.delivery = None
owns_shutdown = self._producer is producer and not producer.stop_requested
if owns_shutdown:
self._producer = None
self._revision += 1
if producer.failure_code is None:
producer.failure_code = "camera-source-ended"
self._set_error_locked(
"camera-source-ended",
_safe_ffmpeg_message(producer.stderr_tail),
)
if delivery is not None:
delivery.failure_code = producer.failure_code or "camera-source-ended"
_close_segment_queue(delivery)
if not owns_shutdown:
return
_terminate_process(producer.process)
status: CameraArchiveStatus = (
"interrupted"
if producer.failure_code
in {"camera-source-ended", "incomplete-fmp4-fragment"}
else "failed"
)
try:
self._finalize_archive(producer, status, producer.failure_code)
except CameraArchiveError:
with self._lock:
self._set_error_locked(
"camera-storage-finalize-failed",
"Не удалось завершить долговременную запись camera stream.",
)
def _shutdown_producer(
self,
producer: _CameraProducer | None,
delivery: CameraProcessLease | None,
*,
status: CameraArchiveStatus,
failure_code: str | None,
) -> None:
if delivery is not None:
delivery.failure_code = producer.failure_code if producer is not None else failure_code
_close_segment_queue(delivery)
if producer is None:
return
_terminate_process(producer.process, close_streams=False)
drained = (
not producer.reader_started
or producer.reader_done.wait(timeout=CAMERA_DRAIN_TIMEOUT_SECONDS)
)
_close_process_streams(producer.process)
if not drained:
producer.failure_code = "camera-drain-timeout"
with self._lock:
self._set_error_locked(
"camera-drain-timeout",
"Camera adapter не завершил долговременную запись вовремя.",
)
effective_failure_code = producer.failure_code or failure_code
effective_status: CameraArchiveStatus = (
"interrupted"
if producer.failure_code == "incomplete-fmp4-fragment"
else "failed"
if producer.failure_code is not None
else status
)
if delivery is not None:
delivery.failure_code = effective_failure_code
try:
self._finalize_archive(
producer,
effective_status,
effective_failure_code,
)
except CameraArchiveError:
with self._lock:
self._set_error_locked(
"camera-storage-finalize-failed",
"Не удалось завершить долговременную запись camera stream.",
)
raise
def _finalize_archive(
self,
producer: _CameraProducer,
status: CameraArchiveStatus,
failure_code: str | None,
) -> None:
archive = producer.archive
producer.archive = None
if archive is None:
return
self._record_archive_summary(archive.close(status=status, failure_code=failure_code))
def _record_archive_summary(self, summary: dict[str, Any]) -> None:
with self._lock:
self._archive_summaries.append(dict(summary))
def _detach_producer_locked(
self,
) -> tuple[_CameraProducer | None, CameraProcessLease | None]:
producer = self._producer
self._producer = None
if producer is None:
return None, None
producer.drain_requested = producer.archive is not None
producer.stop_requested = not producer.drain_requested
delivery = producer.delivery
producer.delivery = None
return producer, delivery
def _mark_streaming_locked(self, producer: _CameraProducer) -> None:
if self._producer is producer and self._phase != "streaming":
self._revision += 1
self._phase = "streaming"
def _set_error_locked(self, code: str, message: str) -> None:
self._revision += 1
self._phase = "error"
self._error = {"code": code, "message": message}
def _require_open_locked(self) -> None:
if self._closed:
raise RuntimeError("camera gateway уже закрыт")
def _resolve_ffmpeg(repository_root: Path) -> tuple[Path | None, str]:
configured = os.environ.get("MISSIONCORE_FFMPEG_BINARY")
candidates: list[tuple[Path, str]] = []
if configured:
candidates.append((Path(configured).expanduser(), "configured"))
candidates.append((repository_root / ".runtime" / "ffmpeg" / "ffmpeg", "bundled-local"))
# Explicit development-only fallbacks. Product packaging must provide the
# repository-local binary or MISSIONCORE_FFMPEG_BINARY instead.
candidates.extend(
(
(Path("/opt/homebrew/bin/ffmpeg"), "development-system"),
(Path("/usr/local/bin/ffmpeg"), "development-system"),
)
)
for candidate, source in candidates:
try:
resolved = candidate.resolve(strict=True)
except OSError:
continue
if resolved.is_file() and os.access(resolved, os.X_OK):
return resolved, source
return None, "missing"
def _build_ffmpeg_argv(
ffmpeg_path: Path,
target_host: str,
source_id: CameraSourceId,
) -> list[str]:
target = validate_private_ipv4(target_host)
path = CAMERA_SOURCE_PATHS.get(source_id)
if path is None:
raise ValueError("неизвестный camera source")
upstream = f"rtsp://{target}:8554{path}"
return [
str(ffmpeg_path),
"-hide_banner",
"-loglevel",
"warning",
"-nostdin",
"-rtsp_transport",
"tcp",
"-allowed_media_types",
"video",
"-timeout",
"5000000",
"-probesize",
"4000000",
"-analyzeduration",
"2000000",
"-fflags",
"nobuffer",
"-i",
upstream,
"-map",
"0:v:0",
"-an",
"-sn",
"-dn",
"-c:v",
"copy",
"-f",
"mp4",
"-movflags",
"+empty_moov+default_base_moof+omit_tfhd_offset+frag_every_frame+skip_trailer",
"-flush_packets",
"1",
"pipe:1",
]
def _read_exact(stream: IO[bytes], size: int) -> bytes:
chunks: list[bytes] = []
remaining = size
while remaining:
chunk = stream.read(remaining)
if not chunk:
raise EOFError
chunks.append(chunk)
remaining -= len(chunk)
return b"".join(chunks)
def _read_mp4_box(stream: IO[bytes]) -> tuple[bytes, bytes]:
header = _read_exact(stream, 8)
size = int.from_bytes(header[:4], "big")
box_type = header[4:8]
if size == 1:
extended = _read_exact(stream, 8)
size = int.from_bytes(extended, "big")
header += extended
if size == 0 or size < len(header) or size > MAX_FMP4_BOX_BYTES:
raise ValueError("invalid or unbounded ISO-BMFF box")
return box_type, header + _read_exact(stream, size - len(header))
def _close_segment_queue(lease: CameraProcessLease) -> None:
try:
lease.segments.put_nowait(None)
except queue.Full:
while True:
try:
lease.segments.get_nowait()
except queue.Empty:
break
lease.segments.put_nowait(None)
def _read_fmp4_stdout(
gateway: XgridsK1CameraGateway,
producer: _CameraProducer,
) -> None:
stream = producer.process.stdout
if stream is None:
try:
gateway._mark_producer_failure(
producer,
"invalid-fmp4",
"Camera adapter вернул некорректный fMP4 stream.",
)
gateway._producer_ended(producer)
finally:
producer.reader_done.set()
return
init_parts: list[bytes] = []
fragment_parts: list[bytes] = []
init_sent = False
try:
while True:
box_type, box = _read_mp4_box(stream)
if not init_sent:
init_parts.append(box)
if box_type == b"moov":
if not gateway._publish_segment(
producer,
"init",
b"".join(init_parts),
):
return
init_sent = True
continue
if box_type == b"moof":
fragment_parts = [box]
continue
if fragment_parts:
fragment_parts.append(box)
if box_type == b"mdat":
if not gateway._publish_segment(
producer,
"media",
b"".join(fragment_parts),
):
return
fragment_parts = []
continue
# `styp`/`sidx` are valid media-segment prefixes. Preserve them and
# wait for the following moof+mdat instead of forwarding raw boxes.
if box_type in {b"styp", b"sidx"}:
fragment_parts.append(box)
except EOFError:
if not init_sent:
gateway._mark_producer_failure(
producer,
"invalid-fmp4",
"Camera adapter вернул некорректный fMP4 stream.",
)
elif fragment_parts:
gateway._mark_producer_failure(
producer,
"incomplete-fmp4-fragment",
"Camera stream завершился на неполном video fragment.",
)
except (OSError, ValueError):
gateway._mark_producer_failure(
producer,
"invalid-fmp4",
"Camera adapter вернул некорректный fMP4 stream.",
)
finally:
try:
gateway._producer_ended(producer)
finally:
producer.reader_done.set()
def _drain_stderr(producer: _CameraProducer) -> None:
stream = producer.process.stderr
if stream is None:
return
try:
while True:
line = stream.readline()
if not line:
return
text = line.decode("utf-8", errors="replace").strip()
if text:
producer.stderr_tail.append(text[-240:])
except (OSError, ValueError):
return
def _safe_ffmpeg_message(stderr_tail: deque[str]) -> str:
# Never reflect the RTSP URL or device address into browser state.
joined = " ".join(stderr_tail).lower()
if "connection refused" in joined:
return "Camera endpoint отклонил локальное соединение."
if "timed out" in joined or "timeout" in joined:
return "Camera endpoint не ответил за отведённое время."
if "invalid data" in joined or "could not find codec" in joined:
return "Camera endpoint вернул неподдерживаемый media stream."
return "Camera stream завершился до первого пригодного видеофрагмента."
def _terminate_process(
process: subprocess.Popen[bytes] | None,
*,
close_streams: bool = True,
) -> None:
if process is None:
return
try:
if process.poll() is None:
if os.name == "posix":
# The process can have exited or lost its dedicated process
# group between poll() and killpg(). Either outcome means
# there is no group left for us to signal; still reap the
# child and finish the archive instead of aborting teardown.
with suppress(OSError):
os.killpg(process.pid, signal.SIGINT)
else:
process.terminate()
try:
process.wait(timeout=2.0)
except subprocess.TimeoutExpired:
process.terminate()
try:
process.wait(timeout=1.0)
except subprocess.TimeoutExpired:
process.kill()
process.wait(timeout=1.0)
finally:
if close_streams:
_close_process_streams(process)
def _close_process_streams(process: subprocess.Popen[bytes]) -> None:
for stream in (process.stdout, process.stderr):
if stream is not None:
with suppress(OSError):
stream.close()
def build_xgrids_k1_camera_router(
gateway: XgridsK1CameraGateway,
plugin_id: str,
) -> APIRouter:
router = APIRouter(include_in_schema=False)
@router.websocket(f"/api/v1/device-plugins/{plugin_id}/camera-preview/{{generation}}")
async def camera_preview(websocket: WebSocket, generation: int) -> None:
await websocket.accept()
try:
lease = gateway.open_delivery(generation)
except (ValueError, RuntimeError):
await websocket.close(code=1008, reason="Camera preview lease is not active")
return
client_closed = False
try:
while True:
segment = await asyncio.to_thread(lease.segments.get)
if segment is None:
break
kind, payload = segment
if kind == "media":
gateway.mark_streaming(lease)
await websocket.send_bytes(payload)
except WebSocketDisconnect:
client_closed = True
except RuntimeError:
client_closed = True
finally:
gateway.release_delivery(lease, client_closed=client_closed)
with suppress(RuntimeError):
await websocket.close()
return router
+541
View File
@@ -0,0 +1,541 @@
from __future__ import annotations
import asyncio
import json
import platform
import shutil
import subprocess
import sys
from pathlib import Path
from typing import Annotated, TypedDict
import typer
import uvicorn
from bleak.exc import BleakError
from rich.console import Console
from rich.table import Table
from k1link import __version__
from k1link.artifacts import write_json_atomic
from k1link.device_plugins.xgrids_k1.analyze import (
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
summarize_mqtt_streams,
)
from k1link.device_plugins.xgrids_k1.ble.gatt import dump_metadata
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
PROFILE_ID,
WriteMode,
provision_wifi_once,
)
from k1link.device_plugins.xgrids_k1.mqtt import (
DEFAULT_MAX_MESSAGE_BYTES,
MAX_CONFIGURABLE_MESSAGE_BYTES,
CaptureError,
capture_mqtt,
)
from k1link.device_plugins.xgrids_k1.net.snapshot import snapshot
from k1link.device_plugins.xgrids_k1.usb.snapshot import snapshot as usb_snapshot
from k1link.macos_credentials import CredentialDialogError, prompt_wifi_credentials
app = typer.Typer(
name="k1link",
help="Safe, evidence-led research tooling for an owner-controlled XGRIDS K1.",
no_args_is_help=True,
)
console = Console()
ble_app = typer.Typer(help="Bluetooth LE discovery and metadata commands.", no_args_is_help=True)
net_app = typer.Typer(help="Read-only local network observation commands.", no_args_is_help=True)
usb_app = typer.Typer(help="Read-only macOS USB metadata commands.", no_args_is_help=True)
analyze_app = typer.Typer(help="Bounded offline evidence analysis commands.", no_args_is_help=True)
app.add_typer(ble_app, name="ble")
app.add_typer(net_app, name="net")
app.add_typer(usb_app, name="usb")
app.add_typer(analyze_app, name="analyze")
class ToolStatus(TypedDict):
name: str
available: bool
path: str | None
class NetworkStatus(TypedDict):
wifi_interface: str | None
default_route_interface: str | None
vpn_default_route: bool
class DoctorPayload(TypedDict):
k1link_version: str
python_version: str
python_executable: str
python_is_3_12: bool
local_venv: bool
platform: str
machine: str
network: NetworkStatus
tools: list[ToolStatus]
notes: list[str]
def _tool_status(name: str) -> ToolStatus:
path = shutil.which(name)
return {"name": name, "available": path is not None, "path": path}
def _command_output(args: list[str]) -> str | None:
try:
result = subprocess.run(
args,
check=False,
capture_output=True,
text=True,
timeout=5,
)
except (OSError, subprocess.SubprocessError):
return None
if result.returncode != 0:
return None
return result.stdout.strip()
def _default_route_interface() -> str | None:
output = _command_output(["route", "-n", "get", "default"])
if output is None:
return None
for line in output.splitlines():
key, separator, value = line.strip().partition(":")
if separator and key == "interface":
return value.strip()
return None
def _wifi_interface() -> str | None:
output = _command_output(["networksetup", "-listallhardwareports"])
if output is None:
return None
blocks = output.split("\n\n")
for block in blocks:
if "Hardware Port: Wi-Fi" not in block:
continue
for line in block.splitlines():
key, separator, value = line.partition(":")
if separator and key.strip() == "Device":
return value.strip()
return None
def _doctor_payload() -> DoctorPayload:
executable = Path(sys.executable)
default_route = _default_route_interface()
wifi_interface = _wifi_interface()
notes = [
"Bluetooth permission is intentionally not requested by doctor.",
"Missing tshark/nmap is acceptable before the network-analysis gate.",
"No Homebrew or system changes are performed by this command.",
]
if default_route is not None and default_route.startswith("utun"):
notes.append(
"Default route uses a VPN/tunnel interface; future K1 commands must resolve "
"the route for the confirmed K1 IP instead of assuming the default route."
)
return {
"k1link_version": __version__,
"python_version": platform.python_version(),
"python_executable": str(executable),
"python_is_3_12": sys.version_info[:2] == (3, 12),
"local_venv": Path(sys.prefix).name == ".venv",
"platform": platform.platform(),
"machine": platform.machine(),
"network": {
"wifi_interface": wifi_interface,
"default_route_interface": default_route,
"vpn_default_route": bool(default_route and default_route.startswith("utun")),
},
"tools": [_tool_status(name) for name in ("uv", "tcpdump", "tshark", "nmap", "ffmpeg")],
"notes": notes,
}
@app.callback()
def main() -> None:
"""Run safe research commands for an owner-controlled XGRIDS K1."""
@app.command()
def doctor(
json_output: bool = typer.Option(False, "--json", help="Emit machine-readable JSON."),
) -> None:
"""Inspect the local toolchain without touching the K1 or system configuration."""
payload = _doctor_payload()
if json_output:
typer.echo(json.dumps(payload, ensure_ascii=False, indent=2))
return
console.print(f"k1link {payload['k1link_version']}")
console.print(f"Python {payload['python_version']} ({payload['python_executable']})")
console.print(
"Local .venv: " + ("[green]yes[/green]" if payload["local_venv"] else "[red]no[/red]")
)
network = payload["network"]
console.print(
"Wi-Fi interface: "
f"{network['wifi_interface'] or '-'}; default route: "
f"{network['default_route_interface'] or '-'}"
)
table = Table(title="External tools")
table.add_column("Tool")
table.add_column("Available")
table.add_column("Path")
for item in payload["tools"]:
table.add_row(
str(item["name"]),
"yes" if item["available"] else "no",
str(item["path"] or "-"),
)
console.print(table)
for note in payload["notes"]:
console.print(f"- {note}")
@app.command("serve")
def serve_console(
port: Annotated[
int,
typer.Option(min=1024, max=65535, help="Loopback HTTP port for the local console."),
] = 8000,
) -> None:
"""Serve the built Mission Core Control Station and loopback control API."""
frontend = Path(__file__).resolve().parents[2] / "apps" / "control-station" / "dist"
if not frontend.is_dir():
console.print(
"[red]Frontend build is missing.[/red] Run npm install && npm run build "
"inside apps/control-station."
)
raise typer.Exit(code=2)
console.print(f"NODEDC MISSION CORE: http://127.0.0.1:{port}")
console.print("The credential endpoint is bound to this Mac only.")
uvicorn.run(
"k1link.web.app:app",
host="127.0.0.1",
port=port,
log_level="info",
access_log=True,
)
@ble_app.command("scan")
def ble_scan(
out: Annotated[
Path,
typer.Option(help="Ignored session JSON path; parent directories are created."),
],
duration: Annotated[
float,
typer.Option(min=1.0, max=300.0, help="Scan duration in seconds."),
] = 30.0,
) -> None:
"""Discover BLE advertisements without connecting or changing device configuration."""
try:
result = asyncio.run(scan(duration))
except (BleakError, OSError, ValueError) as exc:
console.print(f"[red]BLE scan failed:[/red] {type(exc).__name__}: {exc}")
console.print("Check System Settings → Privacy & Security → Bluetooth.")
raise typer.Exit(code=2) from exc
write_json_atomic(out, result)
table = Table(title=f"BLE devices ({result['device_count']})")
table.add_column("Candidate")
table.add_column("Name")
table.add_column("RSSI")
table.add_column("macOS UUID")
for device in result["devices"]:
table.add_row(
"K1?" if device["k1_name_candidate"] else "",
device["local_name"] or device["name"] or "-",
str(device["rssi"]),
device["macos_uuid"],
)
console.print(table)
console.print(f"Saved: {out}")
@ble_app.command("gatt-dump")
def ble_gatt_dump(
device: Annotated[str, typer.Option(help="CoreBluetooth/macOS UUID from ble scan.")],
out: Annotated[
Path,
typer.Option(help="Ignored session JSON path; parent directories are created."),
],
timeout: Annotated[
float,
typer.Option(min=5.0, max=120.0, help="Connection timeout in seconds."),
] = 45.0,
) -> None:
"""Enumerate GATT metadata only: no characteristic reads, subscriptions or writes."""
console.print(
"Connecting for service discovery only; no characteristic values will be read or written."
)
try:
result = asyncio.run(dump_metadata(device, timeout))
except (BleakError, OSError, ValueError) as exc:
console.print(f"[red]GATT metadata failed:[/red] {type(exc).__name__}: {exc}")
raise typer.Exit(code=2) from exc
write_json_atomic(out, result)
characteristic_count = sum(len(service["characteristics"]) for service in result["services"])
console.print(
f"Device: {result['device_name']}; services: {len(result['services'])}; "
f"characteristics: {characteristic_count}"
)
console.print(f"Saved: {out}")
@ble_app.command("wifi-configure")
def ble_wifi_configure(
device: Annotated[str, typer.Option(help="CoreBluetooth/macOS UUID from ble scan.")],
out: Annotated[
Path,
typer.Option(help="Ignored sensitive session JSON path; parent directories are created."),
],
profile: Annotated[
str,
typer.Option(help="Exact reviewed provisioning profile ID."),
],
confirm_write: Annotated[
bool,
typer.Option(
"--confirm-write",
help="Confirm one state-changing BLE Wi-Fi provisioning write.",
),
] = False,
write_mode: Annotated[
WriteMode,
typer.Option(help="ATT write mode; auto follows the live characteristic properties."),
] = "auto",
timeout: Annotated[
float,
typer.Option(min=10.0, max=120.0, help="Status polling timeout after the write."),
] = 45.0,
) -> None:
"""Send router credentials once using the reviewed K1 firmware-3 profile."""
if profile != PROFILE_ID:
console.print(f"[red]Unknown or unreviewed profile:[/red] {profile}")
raise typer.Exit(code=2)
if not confirm_write:
console.print(
"[red]Write not confirmed.[/red] Add --confirm-write after reviewing the profile."
)
raise typer.Exit(code=2)
console.print(
"Two local macOS dialogs will request the Wi-Fi name and hidden password. "
"The password is never printed, logged, or written to the result file; "
"the K1 may echo the SSID in the ignored sensitive status result."
)
try:
ssid, password = prompt_wifi_credentials()
except CredentialDialogError as exc:
console.print(f"[red]Credential entry failed:[/red] {exc}")
raise typer.Exit(code=2) from exc
console.print("Credentials accepted locally. Starting the single reviewed BLE write.")
try:
result = asyncio.run(
provision_wifi_once(
device,
ssid,
password,
timeout_seconds=timeout,
write_mode=write_mode,
)
)
except (BleakError, OSError, TimeoutError, ValueError) as exc:
console.print(f"[red]Wi-Fi provisioning failed:[/red] {type(exc).__name__}: {exc}")
console.print("No automatic retry was attempted.")
raise typer.Exit(code=2) from exc
finally:
password = ""
ssid = ""
write_json_atomic(out, result)
observations = result["observations"]
final_status = observations[-1]["status"] if observations else result["baseline_status"]
console.print(f"Outcome: {result['outcome']}; ATT mode: {result['write_mode']}")
console.print(
f"Final status code: {final_status['status_code']}; "
f"reported IPv4: {final_status['ipv4'] or '-'}"
)
console.print("Saved sensitive device/network metadata; do not commit the output.")
console.print(f"Saved: {out}")
@net_app.command("snapshot")
def net_snapshot(
out: Annotated[
Path,
typer.Option(help="Ignored session JSON path; parent directories are created."),
],
) -> None:
"""Save routes, interfaces and the existing neighbor table without scanning the LAN."""
result = snapshot()
write_json_atomic(out, result)
console.print("Saved a sensitive local network snapshot; do not commit the output.")
console.print(f"Saved: {out}")
@net_app.command("mqtt-capture")
def net_mqtt_capture(
host: Annotated[
str,
typer.Option("--host", help="Confirmed K1 RFC1918 IPv4 address; hostnames are rejected."),
],
out: Annotated[
Path,
typer.Option(
"--out",
help="Sensitive output directory; use captures/... so Git ignores it.",
),
],
confirm_owned_device: Annotated[
bool,
typer.Option(
"--confirm-owned-device",
help="Confirm the target is an owner-controlled K1 before connecting.",
),
] = False,
port: Annotated[
int,
typer.Option(min=1, max=65535, help="MQTT broker TCP port."),
] = 1883,
duration: Annotated[
float,
typer.Option(min=1.0, max=3600.0, help="Capture duration after SUBACK, in seconds."),
] = 60.0,
max_message_bytes: Annotated[
int,
typer.Option(
min=1,
max=MAX_CONFIGURABLE_MESSAGE_BYTES,
help="Abort before storing a payload larger than this byte limit.",
),
] = DEFAULT_MAX_MESSAGE_BYTES,
) -> None:
"""Capture fixed K1 MQTT report topics once; never publish or reconnect."""
if not confirm_owned_device:
console.print(
"[red]Target ownership not confirmed.[/red] "
"Add --confirm-owned-device for the confirmed K1 IPv4 address."
)
raise typer.Exit(code=2)
console.print(
"Starting one read-only MQTT subscription session. "
"No application messages will be published and no reconnect will be attempted."
)
try:
result = capture_mqtt(
host,
out,
port=port,
duration_seconds=duration,
max_message_bytes=max_message_bytes,
on_ready=lambda: console.print(
"[green]MQTT subscriptions active; capture timer started.[/green]"
),
)
except CaptureError as exc:
console.print(f"[red]MQTT capture failed:[/red] {exc}")
if exc.summary is not None:
console.print(f"Partial artifacts preserved in: {out}")
raise typer.Exit(code=2) from exc
except (OSError, RuntimeError, ValueError) as exc:
console.print(f"[red]MQTT capture failed:[/red] {type(exc).__name__}: {exc}")
raise typer.Exit(code=2) from exc
console.print(
f"Capture stopped: {result['stop_reason']}; messages: {result['message_count']}; "
f"payload bytes: {result['payload_bytes']}"
)
console.print("Saved sensitive raw MQTT evidence; do not commit the output.")
console.print(f"Saved: {out}")
@usb_app.command("snapshot")
def usb_snapshot_command(
out: Annotated[
Path,
typer.Option(help="Ignored session JSON path; parent directories are created."),
],
) -> None:
"""Save XGRIDS USB/interface/storage metadata without opening device files."""
result = usb_snapshot()
write_json_atomic(out, result)
console.print(
"Saved sensitive USB metadata only; no sudo, device-file reads or device writes used."
)
console.print(f"XGRIDS candidates: {result['xgrids_device_count']}")
console.print(f"Saved: {out}")
@analyze_app.command("mqtt-streams")
def analyze_mqtt_streams(
capture: Annotated[
Path,
typer.Option(
"--capture",
exists=True,
file_okay=True,
dir_okay=False,
readable=True,
help="Repository-native mqtt.raw.k1mqtt capture to analyze offline.",
),
],
out: Annotated[
Path,
typer.Option(
"--out",
help=(
"Sensitive atomic JSON output; keep under captures/, sessions/, or "
"artifacts/decoded/."
),
),
],
max_payload_bytes: Annotated[
int,
typer.Option(
"--max-payload-bytes",
min=1,
max=MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
help="Reject a capture frame larger than this bounded payload limit.",
),
] = DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
) -> None:
"""Summarize captured firmware-3 point-cloud and pose streams without coordinates."""
if capture.expanduser().resolve() == out.expanduser().resolve():
console.print("[red]Analysis failed:[/red] capture and output must be different files")
raise typer.Exit(code=2)
try:
result = summarize_mqtt_streams(
capture,
max_payload_bytes=max_payload_bytes,
)
write_json_atomic(out, result)
except (OSError, RuntimeError, ValueError) as exc:
console.print(f"[red]Analysis failed:[/red] {type(exc).__name__}: {exc}")
raise typer.Exit(code=2) from exc
console.print(
f"Frames: {result['frames']['count']}; decode successes: "
f"{result['decoding']['successes']}; errors: {result['decoding']['errors']}"
)
console.print("Saved sensitive aggregate-only output; keep it in ignored storage.")
console.print(f"Saved: {out}")
if __name__ == "__main__":
app()
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,112 @@
from __future__ import annotations
import asyncio
from typing import Any
from fastapi import APIRouter, HTTPException, WebSocket, WebSocketDisconnect
from k1link.device_plugins.xgrids_k1.facade import (
ACTION_DISCOVERY_SCAN,
ACTION_NETWORK_PROVISION,
ACTION_STATE_READ,
ACTION_STREAM_START_LIVE,
ACTION_STREAM_START_REPLAY,
ACTION_STREAM_STOP,
ACTION_VIEWER_SETTINGS_UPDATE,
BleScanRequest,
ConnectRequest,
LiveRequest,
ReplayRequest,
ViewerSettingsRequest,
XgridsK1PluginFacade,
)
from k1link.web.plugin_runtime import PluginExecutionError, invoke_device_plugin_adapter
def build_xgrids_k1_legacy_router(adapter: XgridsK1PluginFacade) -> APIRouter:
"""Temporary flat API kept for scripts created before the plugin boundary."""
router = APIRouter(include_in_schema=True)
@router.get("/api/state", deprecated=True)
async def get_state() -> dict[str, Any]:
return await invoke_device_plugin_adapter(adapter, ACTION_STATE_READ, {})
@router.post("/api/ble/scan", deprecated=True)
async def scan_ble(request: BleScanRequest) -> dict[str, Any]:
try:
return await invoke_device_plugin_adapter(
adapter,
ACTION_DISCOVERY_SCAN,
request.model_dump(),
)
except (PluginExecutionError, ValueError) as exc:
raise HTTPException(status_code=502, detail=f"Ошибка поиска BLE: {exc}") from exc
@router.post("/api/connect", deprecated=True)
async def connect(request: ConnectRequest) -> dict[str, Any]:
try:
return await invoke_device_plugin_adapter(
adapter,
ACTION_NETWORK_PROVISION,
request.model_dump(),
)
except (PluginExecutionError, ValueError) as exc:
raise HTTPException(
status_code=502,
detail=f"Ошибка подключения устройства к Wi-Fi: {exc}",
) from exc
@router.post("/api/session/live", deprecated=True)
async def start_live(request: LiveRequest) -> dict[str, Any]:
try:
return await invoke_device_plugin_adapter(
adapter,
ACTION_STREAM_START_LIVE,
request.model_dump(),
)
except (PluginExecutionError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/api/session/replay", deprecated=True)
async def start_replay(request: ReplayRequest) -> dict[str, Any]:
try:
return await invoke_device_plugin_adapter(
adapter,
ACTION_STREAM_START_REPLAY,
request.model_dump(),
)
except (PluginExecutionError, ValueError) as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.post("/api/session/stop", deprecated=True)
async def stop_session() -> dict[str, Any]:
return await invoke_device_plugin_adapter(adapter, ACTION_STREAM_STOP, {})
@router.post("/api/viewer/settings", deprecated=True)
async def update_viewer_settings(request: ViewerSettingsRequest) -> dict[str, Any]:
return await invoke_device_plugin_adapter(
adapter,
ACTION_VIEWER_SETTINGS_UPDATE,
request.model_dump(),
)
@router.websocket("/api/events")
async def events(websocket: WebSocket) -> None:
await websocket.accept()
try:
while True:
await websocket.send_json(
{
"state": await invoke_device_plugin_adapter(
adapter,
ACTION_STATE_READ,
{},
)
}
)
await asyncio.sleep(0.5)
except WebSocketDisconnect:
return
return router
@@ -0,0 +1,29 @@
"""Read-only MQTT evidence capture for an owner-controlled K1."""
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
DEFAULT_MAX_MESSAGE_BYTES,
MAX_CONFIGURABLE_MESSAGE_BYTES,
REPORT_TOPICS,
CapturedMqttMessage,
CaptureError,
CaptureFormatError,
CaptureFrame,
CaptureSummary,
capture_mqtt,
iter_capture_frames,
validate_private_ipv4,
)
__all__ = [
"DEFAULT_MAX_MESSAGE_BYTES",
"MAX_CONFIGURABLE_MESSAGE_BYTES",
"REPORT_TOPICS",
"CaptureError",
"CaptureFormatError",
"CaptureFrame",
"CaptureSummary",
"CapturedMqttMessage",
"capture_mqtt",
"iter_capture_frames",
"validate_private_ipv4",
]
@@ -0,0 +1,748 @@
from __future__ import annotations
import hashlib
import ipaddress
import json
import math
import os
import struct
import time
from collections.abc import Callable, Iterator
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import IO, Literal, TypedDict
import paho.mqtt.client as mqtt
from paho.mqtt.enums import CallbackAPIVersion
from paho.mqtt.properties import Properties
from paho.mqtt.reasoncodes import ReasonCode
from k1link.artifacts import utc_now_iso
REPORT_TOPICS: tuple[str, ...] = (
"lixel/application/report/#",
"RealtimePointcloud",
"RealtimePath",
"DeviceStatus",
)
DEFAULT_MAX_MESSAGE_BYTES = 64 * 1024 * 1024
MAX_CONFIGURABLE_MESSAGE_BYTES = 256 * 1024 * 1024
MAX_TOPIC_BYTES = 65_535
CONNECT_TIMEOUT_SECONDS = 10.0
KEEPALIVE_SECONDS = 30
LOOP_INTERVAL_SECONDS = 0.25
GROUP_COMMIT_INTERVAL_SECONDS = 0.5
GROUP_COMMIT_MAX_BYTES = 4 * 1024 * 1024
GROUP_COMMIT_MAX_MESSAGES = 32
# Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes.
RAW_MAGIC = b"K1MQTT\x00\x01"
FRAME_HEADER = struct.Struct(">IQ")
_PRIVATE_NETWORKS = tuple(
ipaddress.ip_network(cidr) for cidr in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
)
StopReason = Literal[
"duration_elapsed",
"external_stop",
"keyboard_interrupt",
"message_too_large",
"connection_failed",
"connection_lost",
"subscription_failed",
"capture_error",
]
class ArtifactPaths(TypedDict):
raw: str
metadata_jsonl: str
summary: str
class ArtifactHashes(TypedDict):
raw_sha256: str
metadata_jsonl_sha256: str
class RawFormat(TypedDict):
magic_hex: str
frame_header_struct: str
frame_layout: str
class CaptureSummary(TypedDict):
schema_version: int
created_at_utc: str
completed_at_utc: str
sensitivity: str
target_ipv4: str
target_port: int
mqtt_protocol: str
subscription_qos: int
clean_session: bool
reconnect_enabled: bool
publishing_enabled: bool
subscriptions: list[str]
requested_duration_seconds: float
capture_elapsed_seconds: float
operation_elapsed_seconds: float
max_message_bytes: int
connected: bool
subscribed: bool
stop_reason: StopReason
error: str | None
message_count: int
rejected_message_count: int
payload_bytes: int
raw_bytes: int
topic_counts: dict[str, int]
raw_format: RawFormat
artifacts: ArtifactPaths
artifact_hashes: ArtifactHashes
class CaptureError(RuntimeError):
"""A one-shot capture failed after preserving all artifacts written so far."""
def __init__(self, message: str, summary: CaptureSummary | None = None) -> None:
super().__init__(message)
self.summary = summary
class MessageTooLargeError(CaptureError):
"""An MQTT message exceeded the configured evidence boundary."""
class CaptureFormatError(ValueError):
"""A raw capture is corrupt, truncated or outside configured reader bounds."""
@dataclass(frozen=True, slots=True)
class CaptureFrame:
sequence: int
topic: str
payload: bytes
raw_frame_offset: int
raw_payload_offset: int
raw_frame_bytes: int
@dataclass(frozen=True, slots=True)
class CapturedMqttMessage:
"""A message flushed to the raw writer before it is exposed to live preview."""
sequence: int
topic: str
payload: bytes
qos: int
retain: bool
dup: bool
received_at_utc: str
received_at_epoch_ns: int
received_monotonic_ns: int
@dataclass
class _CaptureState:
connected: bool = False
subscribed: bool = False
stopping: bool = False
subscription_mid: int | None = None
stop_reason: StopReason = "capture_error"
error: str | None = None
class _CaptureWriter:
def __init__(self, out_dir: Path, max_message_bytes: int) -> None:
self.out_dir = out_dir.expanduser().resolve()
self.raw_path = self.out_dir / "mqtt.raw.k1mqtt"
self.metadata_path = self.out_dir / "mqtt.metadata.jsonl"
self.summary_path = self.out_dir / "mqtt.summary.json"
self.max_message_bytes = max_message_bytes
self.message_count = 0
self.rejected_message_count = 0
self.payload_bytes = 0
self.topic_counts: dict[str, int] = {}
self._raw: IO[bytes] | None = None
self._metadata: IO[str] | None = None
self._pending_metadata: list[str] = []
self._pending_raw_bytes = 0
self._last_commit_monotonic = time.monotonic()
def open(self) -> None:
self.out_dir.mkdir(parents=True, exist_ok=True)
artifact_paths = (self.raw_path, self.metadata_path, self.summary_path)
existing = [path.name for path in artifact_paths if path.exists()]
if existing:
names = ", ".join(existing)
raise FileExistsError(f"refusing to overwrite existing capture artifact(s): {names}")
try:
self._raw = _open_binary_exclusive(self.raw_path)
self._raw.write(RAW_MAGIC)
self._metadata = _open_text_exclusive(self.metadata_path)
_fsync_directory(self.out_dir)
except BaseException:
with suppress(OSError):
self.close()
raise
def record(self, message: mqtt.MQTTMessage) -> CapturedMqttMessage:
raw = self._require_raw()
metadata = self._require_metadata()
topic = message.topic
topic_bytes = topic.encode("utf-8")
payload = message.payload
received_at_utc = utc_now_iso()
received_at_epoch_ns = time.time_ns()
received_monotonic_ns = time.monotonic_ns()
if not 1 <= len(topic_bytes) <= MAX_TOPIC_BYTES:
raise ValueError(
f"incoming MQTT topic is {len(topic_bytes)} bytes; expected 1..{MAX_TOPIC_BYTES}"
)
if len(payload) > self.max_message_bytes:
self.rejected_message_count += 1
record = {
"schema_version": 1,
"record_type": "rejected_message",
"sequence": self.message_count + self.rejected_message_count,
"received_at_utc": received_at_utc,
"received_monotonic_ns": received_monotonic_ns,
"topic": topic,
"payload_bytes": len(payload),
"max_message_bytes": self.max_message_bytes,
"reason": "message_too_large",
}
self._commit_pending()
self._write_metadata(metadata, record)
os.fsync(metadata.fileno())
raise MessageTooLargeError(
f"message on {topic!r} is {len(payload)} bytes; "
f"limit is {self.max_message_bytes} bytes"
)
offset = raw.tell()
header = FRAME_HEADER.pack(len(topic_bytes), len(payload))
raw.write(header)
raw.write(topic_bytes)
raw.write(payload)
raw.flush()
self.message_count += 1
self.payload_bytes += len(payload)
self.topic_counts[topic] = self.topic_counts.get(topic, 0) + 1
frame_bytes = len(header) + len(topic_bytes) + len(payload)
record = {
"schema_version": 1,
"record_type": "message",
"sequence": self.message_count,
"received_at_utc": received_at_utc,
"received_at_epoch_ns": received_at_epoch_ns,
"received_monotonic_ns": received_monotonic_ns,
"topic": topic,
"qos": message.qos,
"retain": message.retain,
"dup": message.dup,
"payload_bytes": len(payload),
"payload_sha256": hashlib.sha256(payload).hexdigest(),
"raw_frame_offset": offset,
"raw_payload_offset": offset + len(header) + len(topic_bytes),
"raw_frame_bytes": frame_bytes,
}
self._pending_metadata.append(
json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n"
)
self._pending_raw_bytes += frame_bytes
if (
len(self._pending_metadata) >= GROUP_COMMIT_MAX_MESSAGES
or self._pending_raw_bytes >= GROUP_COMMIT_MAX_BYTES
or time.monotonic() - self._last_commit_monotonic
>= GROUP_COMMIT_INTERVAL_SECONDS
):
self._commit_pending()
return CapturedMqttMessage(
sequence=self.message_count,
topic=topic,
payload=payload,
qos=message.qos,
retain=message.retain,
dup=message.dup,
received_at_utc=received_at_utc,
received_at_epoch_ns=received_at_epoch_ns,
received_monotonic_ns=received_monotonic_ns,
)
def close(self) -> None:
first_error: OSError | None = None
try:
self._commit_pending()
except OSError as exc:
first_error = exc
# Make the magic (including the zero-message case) and any last stream
# buffers durable before the handles are released.
for stream in (self._raw, self._metadata):
if stream is None or stream.closed:
continue
try:
stream.flush()
os.fsync(stream.fileno())
except OSError as exc:
if first_error is None:
first_error = exc
finally:
try:
stream.close()
except OSError as exc:
if first_error is None:
first_error = exc
if first_error is not None:
raise first_error
def maybe_commit(self, now_monotonic: float | None = None) -> None:
if not self._pending_metadata:
return
now = time.monotonic() if now_monotonic is None else now_monotonic
if now - self._last_commit_monotonic >= GROUP_COMMIT_INTERVAL_SECONDS:
self._commit_pending(now_monotonic=now)
@property
def raw_bytes(self) -> int:
if self.raw_path.exists():
return self.raw_path.stat().st_size
return 0
def _require_raw(self) -> IO[bytes]:
if self._raw is None or self._raw.closed:
raise RuntimeError("capture writer is not open")
return self._raw
def _require_metadata(self) -> IO[str]:
if self._metadata is None or self._metadata.closed:
raise RuntimeError("capture writer is not open")
return self._metadata
@staticmethod
def _write_metadata(stream: IO[str], record: dict[str, object]) -> None:
stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
stream.flush()
def _commit_pending(self, *, now_monotonic: float | None = None) -> None:
if not self._pending_metadata:
return
raw = self._require_raw()
metadata = self._require_metadata()
# Group commit invariant: durable raw bytes always precede durable
# metadata references. A crash can therefore lose only the bounded
# in-memory group, never expose metadata pointing past durable raw.
raw.flush()
os.fsync(raw.fileno())
payload = "".join(self._pending_metadata)
self._pending_metadata.clear()
self._pending_raw_bytes = 0
metadata.write(payload)
metadata.flush()
os.fsync(metadata.fileno())
self._last_commit_monotonic = (
time.monotonic() if now_monotonic is None else now_monotonic
)
def validate_private_ipv4(value: str) -> str:
"""Require a literal RFC1918 address so capture cannot target arbitrary hosts."""
try:
address = ipaddress.ip_address(value)
except ValueError as exc:
raise ValueError("host must be a literal private IPv4 address") from exc
if not isinstance(address, ipaddress.IPv4Address) or not any(
address in network for network in _PRIVATE_NETWORKS
):
raise ValueError("host must be an RFC1918 private IPv4 address")
return str(address)
def iter_capture_frames(
path: Path,
*,
max_payload_bytes: int = MAX_CONFIGURABLE_MESSAGE_BYTES,
max_topic_bytes: int = MAX_TOPIC_BYTES,
) -> Iterator[CaptureFrame]:
"""Yield validated frames from a K1 MQTT raw capture without decoding payloads."""
if not 1 <= max_payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
raise ValueError(
f"max_payload_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}"
)
if not 1 <= max_topic_bytes <= MAX_TOPIC_BYTES:
raise ValueError(f"max_topic_bytes must be between 1 and {MAX_TOPIC_BYTES}")
with path.expanduser().open("rb") as stream:
magic = stream.read(len(RAW_MAGIC))
if magic != RAW_MAGIC:
if len(magic) < len(RAW_MAGIC):
raise CaptureFormatError("raw capture is truncated before the complete magic")
raise CaptureFormatError("raw capture magic/version is not supported")
sequence = 0
while True:
frame_offset = stream.tell()
header = stream.read(FRAME_HEADER.size)
if not header:
return
if len(header) != FRAME_HEADER.size:
raise CaptureFormatError(
f"frame at offset {frame_offset} has a truncated length header"
)
topic_length, payload_length = FRAME_HEADER.unpack(header)
if not 1 <= topic_length <= max_topic_bytes:
raise CaptureFormatError(
f"frame at offset {frame_offset} topic length {topic_length} "
f"is outside 1..{max_topic_bytes}"
)
if payload_length > max_payload_bytes:
raise CaptureFormatError(
f"frame at offset {frame_offset} payload length {payload_length} "
f"exceeds {max_payload_bytes}"
)
topic_raw = _read_exact(
stream,
topic_length,
description=f"topic at frame offset {frame_offset}",
)
try:
topic = topic_raw.decode("utf-8")
except UnicodeDecodeError as exc:
raise CaptureFormatError(
f"frame at offset {frame_offset} topic is not valid UTF-8"
) from exc
payload_offset = stream.tell()
payload = _read_exact(
stream,
payload_length,
description=f"payload at frame offset {frame_offset}",
)
sequence += 1
yield CaptureFrame(
sequence=sequence,
topic=topic,
payload=payload,
raw_frame_offset=frame_offset,
raw_payload_offset=payload_offset,
raw_frame_bytes=FRAME_HEADER.size + topic_length + payload_length,
)
def capture_mqtt(
host: str,
out_dir: Path,
*,
port: int = 1883,
duration_seconds: float = 60.0,
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
on_ready: Callable[[], None] | None = None,
on_message_recorded: Callable[[CapturedMqttMessage], None] | None = None,
should_stop: Callable[[], bool] | None = None,
_client_factory: Callable[[], mqtt.Client] | None = None,
) -> CaptureSummary:
"""Capture the fixed K1 report subscriptions once, without publishing or reconnecting."""
target_ipv4 = validate_private_ipv4(host)
if not 1 <= port <= 65535:
raise ValueError("port must be between 1 and 65535")
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
raise ValueError("duration_seconds must be finite and greater than zero")
if not 1 <= max_message_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
raise ValueError(
f"max_message_bytes must be between 1 and {MAX_CONFIGURABLE_MESSAGE_BYTES}"
)
client = (
_client_factory()
if _client_factory is not None
else mqtt.Client(
callback_api_version=CallbackAPIVersion.VERSION2,
clean_session=True,
protocol=mqtt.MQTTv311,
reconnect_on_failure=False,
)
)
writer = _CaptureWriter(out_dir, max_message_bytes)
writer.open()
state = _CaptureState()
created_at_utc = utc_now_iso()
operation_started = time.monotonic()
capture_started: float | None = None
failure: CaptureError | None = None
def fail(reason: StopReason, message: str) -> None:
if state.error is None:
state.stop_reason = reason
state.error = message
def on_connect(
callback_client: mqtt.Client,
_userdata: object,
_flags: mqtt.ConnectFlags,
reason_code: ReasonCode,
_properties: Properties | None,
) -> None:
if reason_code.is_failure:
fail("connection_failed", f"broker rejected connection: {reason_code}")
return
state.connected = True
try:
result, mid = callback_client.subscribe([(topic, 0) for topic in REPORT_TOPICS])
except (OSError, RuntimeError, ValueError) as exc:
fail(
"subscription_failed",
f"subscribe failed: {type(exc).__name__}: {exc}",
)
return
if result != mqtt.MQTT_ERR_SUCCESS or mid is None:
fail("subscription_failed", f"subscribe failed: {mqtt.error_string(result)}")
return
state.subscription_mid = mid
def on_subscribe(
_callback_client: mqtt.Client,
_userdata: object,
mid: int,
reason_codes: list[ReasonCode],
_properties: Properties | None,
) -> None:
if mid != state.subscription_mid:
fail("subscription_failed", f"unexpected SUBACK message id: {mid}")
return
if len(reason_codes) != len(REPORT_TOPICS) or any(
reason_code.is_failure for reason_code in reason_codes
):
fail("subscription_failed", "broker rejected one or more fixed subscriptions")
return
state.subscribed = True
def on_message(
_callback_client: mqtt.Client,
_userdata: object,
message: mqtt.MQTTMessage,
) -> None:
if state.error is not None:
return
try:
recorded = writer.record(message)
except MessageTooLargeError as exc:
fail("message_too_large", str(exc))
except (OSError, RuntimeError, ValueError) as exc:
fail("capture_error", f"artifact write failed: {type(exc).__name__}: {exc}")
return
if on_message_recorded is not None:
try:
on_message_recorded(recorded)
except (OSError, RuntimeError, ValueError) as exc:
fail("capture_error", f"preview callback failed: {type(exc).__name__}: {exc}")
def on_disconnect(
_callback_client: mqtt.Client,
_userdata: object,
_flags: mqtt.DisconnectFlags,
reason_code: ReasonCode,
_properties: Properties | None,
) -> None:
if not state.stopping:
fail("connection_lost", f"broker connection ended: {reason_code}")
client.on_connect = on_connect
client.on_subscribe = on_subscribe
client.on_message = on_message
client.on_disconnect = on_disconnect
connect_attempted = False
try:
connect_attempted = True
connect_result = client.connect(
target_ipv4,
port=port,
keepalive=KEEPALIVE_SECONDS,
)
if connect_result != mqtt.MQTT_ERR_SUCCESS:
fail("connection_failed", f"connect failed: {mqtt.error_string(connect_result)}")
while state.error is None:
now = time.monotonic()
if should_stop is not None and should_stop():
state.stop_reason = "external_stop"
break
if state.subscribed and capture_started is None:
capture_started = now
if on_ready is not None:
on_ready()
if capture_started is not None and now - capture_started >= duration_seconds:
state.stop_reason = "duration_elapsed"
break
if capture_started is None and now - operation_started >= CONNECT_TIMEOUT_SECONDS:
fail("connection_failed", "timed out waiting for CONNACK/SUBACK")
break
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
try:
writer.maybe_commit()
except OSError as exc:
fail("capture_error", f"group commit failed: {type(exc).__name__}: {exc}")
break
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
fail(
"connection_lost",
f"MQTT network loop failed: {mqtt.error_string(loop_result)}",
)
except KeyboardInterrupt:
state.stop_reason = "keyboard_interrupt"
except (OSError, RuntimeError, ValueError) as exc:
fail("connection_failed", f"MQTT capture failed: {type(exc).__name__}: {exc}")
finally:
state.stopping = True
if connect_attempted:
try:
client.disconnect()
except (OSError, RuntimeError, ValueError) as exc:
if state.error is None:
fail("capture_error", f"disconnect failed: {type(exc).__name__}: {exc}")
try:
writer.close()
except OSError as exc:
if state.error is None:
fail("capture_error", f"artifact close failed: {type(exc).__name__}: {exc}")
operation_completed = time.monotonic()
capture_elapsed = 0.0 if capture_started is None else operation_completed - capture_started
summary = _build_summary(
writer=writer,
target_ipv4=target_ipv4,
port=port,
duration_seconds=duration_seconds,
capture_elapsed=capture_elapsed,
operation_elapsed=operation_completed - operation_started,
max_message_bytes=max_message_bytes,
created_at_utc=created_at_utc,
state=state,
)
try:
_write_summary_exclusive(writer.summary_path, summary)
except OSError as exc:
raise CaptureError(f"could not write capture summary: {type(exc).__name__}: {exc}") from exc
if state.error is not None:
failure = CaptureError(state.error, summary)
if failure is not None:
raise failure
return summary
def _build_summary(
*,
writer: _CaptureWriter,
target_ipv4: str,
port: int,
duration_seconds: float,
capture_elapsed: float,
operation_elapsed: float,
max_message_bytes: int,
created_at_utc: str,
state: _CaptureState,
) -> CaptureSummary:
return {
"schema_version": 1,
"created_at_utc": created_at_utc,
"completed_at_utc": utc_now_iso(),
"sensitivity": "contains raw K1 MQTT payloads and local addressing; do not commit",
"target_ipv4": target_ipv4,
"target_port": port,
"mqtt_protocol": "3.1.1",
"subscription_qos": 0,
"clean_session": True,
"reconnect_enabled": False,
"publishing_enabled": False,
"subscriptions": list(REPORT_TOPICS),
"requested_duration_seconds": duration_seconds,
"capture_elapsed_seconds": round(capture_elapsed, 6),
"operation_elapsed_seconds": round(operation_elapsed, 6),
"max_message_bytes": max_message_bytes,
"connected": state.connected,
"subscribed": state.subscribed,
"stop_reason": state.stop_reason,
"error": state.error,
"message_count": writer.message_count,
"rejected_message_count": writer.rejected_message_count,
"payload_bytes": writer.payload_bytes,
"raw_bytes": writer.raw_bytes,
"topic_counts": dict(sorted(writer.topic_counts.items())),
"raw_format": {
"magic_hex": RAW_MAGIC.hex(),
"frame_header_struct": FRAME_HEADER.format,
"frame_layout": "topic_length:uint32, payload_length:uint64, topic_utf8, payload",
},
"artifacts": {
"raw": writer.raw_path.name,
"metadata_jsonl": writer.metadata_path.name,
"summary": writer.summary_path.name,
},
"artifact_hashes": {
"raw_sha256": _sha256_file(writer.raw_path),
"metadata_jsonl_sha256": _sha256_file(writer.metadata_path),
},
}
def _sha256_file(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _read_exact(stream: IO[bytes], length: int, *, description: str) -> bytes:
value = stream.read(length)
if len(value) != length:
raise CaptureFormatError(
f"{description} is truncated: expected {length} bytes, got {len(value)}"
)
return value
def _open_binary_exclusive(path: Path) -> IO[bytes]:
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
return os.fdopen(descriptor, "wb")
except BaseException:
os.close(descriptor)
raise
def _open_text_exclusive(path: Path) -> IO[str]:
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
try:
return os.fdopen(descriptor, "w", encoding="utf-8", newline="\n")
except BaseException:
os.close(descriptor)
raise
def _write_summary_exclusive(path: Path, summary: CaptureSummary) -> None:
serialized = json.dumps(summary, ensure_ascii=False, indent=2) + "\n"
with _open_text_exclusive(path) as stream:
stream.write(serialized)
stream.flush()
os.fsync(stream.fileno())
_fsync_directory(path.parent)
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
@@ -0,0 +1 @@
"""Passive network observation tools."""
@@ -0,0 +1,65 @@
from __future__ import annotations
import subprocess
from typing import TypedDict
from k1link.artifacts import utc_now_iso
class CommandRecord(TypedDict):
argv: list[str]
returncode: int | None
stdout: str
stderr: str
error: str | None
class NetworkSnapshot(TypedDict):
schema_version: int
created_at_utc: str
sensitivity: str
commands: list[CommandRecord]
def command_record(argv: list[str]) -> CommandRecord:
try:
result = subprocess.run(
argv,
check=False,
capture_output=True,
text=True,
timeout=10,
)
except (OSError, subprocess.SubprocessError) as exc:
return {
"argv": argv,
"returncode": None,
"stdout": "",
"stderr": "",
"error": f"{type(exc).__name__}: {exc}",
}
return {
"argv": argv,
"returncode": result.returncode,
"stdout": result.stdout,
"stderr": result.stderr,
"error": None,
}
def snapshot() -> NetworkSnapshot:
"""Collect a local read-only network snapshot; no packets are transmitted intentionally."""
commands = [
["route", "-n", "get", "default"],
["networksetup", "-listallhardwareports"],
["scutil", "--nwi"],
["arp", "-an"],
]
return {
"schema_version": 1,
"created_at_utc": utc_now_iso(),
"sensitivity": (
"contains local interface, route, IP/MAC and neighbor metadata; do not commit"
),
"commands": [command_record(command) for command in commands],
}
@@ -0,0 +1,260 @@
"""K1-native observation archive and Rerun preparation adapter."""
from __future__ import annotations
import json
import os
import stat
import threading
from pathlib import Path
from k1link.device_plugins.xgrids_k1.archive import (
LegacySessionCandidate,
discover_legacy_viewer_sessions,
)
from k1link.device_plugins.xgrids_k1.rrd_export import (
RrdExportCancelled,
RrdExportError,
export_k1mqtt_to_rrd,
)
from k1link.sessions.active import recover_stale_active_session_marker
from k1link.sessions.models import (
ObservationArtifactCandidate,
ObservationSessionCandidate,
SessionIntegrityError,
SessionSource,
)
from k1link.sessions.plugin_contract import (
ObservationArchiveSource,
ObservationRuntimeContribution,
PluginRecordingExportCancelled,
PluginRecordingExportError,
)
from k1link.sessions.store import resolve_missioncore_evidence_dir
from k1link.web.camera_archive import recover_incomplete_camera_archives
XGRIDS_K1_PLUGIN_ID = "nodedc.device.xgrids-lixelkity-k1"
MAX_TIMELINE_ORIGIN_LINE_BYTES = 64 * 1024
def build_xgrids_k1_observation(repository_root: Path) -> ObservationRuntimeContribution:
"""Compose every K1 evidence root behind the generic observation ABI."""
roots = (
("xgrids-k1.viewer-live.repository", repository_root.resolve() / "sessions"),
(
"xgrids-k1.viewer-live.evidence",
resolve_missioncore_evidence_dir(repository_root),
),
)
return ObservationRuntimeContribution(
archives=tuple(
ObservationArchiveSource(
plugin_id=XGRIDS_K1_PLUGIN_ID,
archive_id=archive_id,
root=root,
discover=_discover_archive,
recover=_recover_archive,
)
for archive_id, root in roots
),
recording_exporter=_export_recording,
)
def xgrids_k1_archive_source(
root: Path,
*,
archive_id: str = "xgrids-k1.viewer-live",
) -> ObservationArchiveSource:
"""Build one explicit archive source for tests, tools, or migration jobs."""
return ObservationArchiveSource(
plugin_id=XGRIDS_K1_PLUGIN_ID,
archive_id=archive_id,
root=root,
discover=_discover_archive,
recover=_recover_archive,
)
def _recover_archive(root: Path) -> None:
recover_stale_active_session_marker(root)
recover_incomplete_camera_archives(root)
def _discover_archive(root: Path) -> tuple[ObservationSessionCandidate, ...]:
return tuple(
_to_host_candidate(candidate) for candidate in discover_legacy_viewer_sessions(root)
)
def _to_host_candidate(candidate: LegacySessionCandidate) -> ObservationSessionCandidate:
session_id = candidate.session_id
raw_path = candidate.raw_path
raw_bytes = candidate.raw_byte_length
replay_raw_bytes = candidate.replay_raw_byte_length
replay_metadata_bytes = candidate.replay_metadata_byte_length
replayable = candidate.replayable
metadata_path = raw_path.with_name("mqtt.metadata.jsonl")
artifacts = [
ObservationArtifactCandidate(
artifact_id="raw-transport-primary",
kind="raw-transport",
media_type="application/x-nodedc-k1mqtt",
locator=raw_path,
byte_length=raw_bytes,
replay_byte_length=replay_raw_bytes if replayable else 0,
sha256=candidate.raw_sha256,
integrity_status=candidate.raw_integrity_status,
)
]
if replay_metadata_bytes > 0:
artifacts.append(
ObservationArtifactCandidate(
artifact_id="raw-transport-index",
kind="raw-transport-index",
media_type="application/x-ndjson",
locator=metadata_path,
byte_length=_regular_file_size(metadata_path),
replay_byte_length=replay_metadata_bytes if replayable else 0,
sha256=None,
integrity_status=candidate.raw_integrity_status,
)
)
media_sources = candidate.media_sources
artifacts.extend(
ObservationArtifactCandidate(
artifact_id=media.artifact_id,
kind="recorded-video",
media_type="video/mp4",
locator=media.locator,
byte_length=media.byte_length,
replay_byte_length=0,
sha256=None,
integrity_status="validated-structure",
)
for media in media_sources
)
sources: list[SessionSource] = []
modalities = candidate.modalities
if "point-cloud" in modalities:
sources.append(
SessionSource(
source_id="sensor.lidar.primary",
semantic_channel_id="spatial.point-cloud.recorded",
modality="point-cloud",
status="recorded",
seekable=replayable,
artifact_id="raw-transport-primary",
)
)
if "trajectory" in modalities:
sources.append(
SessionSource(
source_id="spatial.trajectory",
semantic_channel_id="spatial.pose.recorded",
modality="trajectory",
status="recorded",
seekable=replayable,
artifact_id="raw-transport-primary",
)
)
sources.extend(
SessionSource(
source_id=media.source_id,
semantic_channel_id="camera.video.recorded",
modality="video",
status="recorded",
seekable=True,
artifact_id=media.artifact_id,
)
for media in media_sources
)
timeline_origin = _timeline_origin(metadata_path) if replayable else None
return ObservationSessionCandidate(
session_id=session_id,
display_name=candidate.display_name,
status=candidate.status,
started_at_utc=candidate.started_at_utc,
completed_at_utc=candidate.completed_at_utc,
duration_seconds=candidate.duration_seconds,
modalities=modalities,
replayable=replayable,
total_bytes=candidate.total_bytes,
allowed_root=candidate.allowed_root,
session_root=candidate.session_root,
primary_replay_artifact_id="raw-transport-primary" if replayable else None,
timeline_origin_epoch_ns=None if timeline_origin is None else timeline_origin[0],
timeline_origin_monotonic_ns=None if timeline_origin is None else timeline_origin[1],
sources=tuple(sources),
artifacts=tuple(artifacts),
)
def _regular_file_size(path: Path) -> int:
try:
value = path.lstat()
except OSError as exc:
raise SessionIntegrityError("K1 transport index is unavailable") from exc
if not stat.S_ISREG(value.st_mode) or stat.S_ISLNK(value.st_mode):
raise SessionIntegrityError("K1 transport index is not a regular file")
return value.st_size
def _timeline_origin(path: Path) -> tuple[int, int]:
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
try:
descriptor = os.open(path, flags)
try:
payload = os.read(descriptor, MAX_TIMELINE_ORIGIN_LINE_BYTES + 1)
finally:
os.close(descriptor)
first_line = payload.splitlines(keepends=True)[0]
if len(first_line) > MAX_TIMELINE_ORIGIN_LINE_BYTES or not first_line.endswith(
(b"\n", b"\r")
):
raise ValueError
document = json.loads(first_line)
epoch_ns = document.get("received_at_epoch_ns")
monotonic_ns = document.get("received_monotonic_ns")
if (
document.get("record_type") != "message"
or document.get("sequence") != 1
or not _non_negative_int(epoch_ns)
or not _non_negative_int(monotonic_ns)
):
raise ValueError
return int(epoch_ns), int(monotonic_ns)
except (IndexError, OSError, UnicodeDecodeError, json.JSONDecodeError, ValueError) as exc:
raise SessionIntegrityError("K1 transport timeline origin is invalid") from exc
def _non_negative_int(value: object) -> bool:
return isinstance(value, int) and not isinstance(value, bool) and value >= 0
def _export_recording(
source: Path,
destination: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: object | None = None,
) -> dict[str, object]:
try:
return dict(
export_k1mqtt_to_rrd(
source,
destination,
cancel_event=cancel_event,
activity_callback=activity_callback if callable(activity_callback) else None,
)
)
except RrdExportCancelled as exc:
raise PluginRecordingExportCancelled("K1 recording export was cancelled") from exc
except RrdExportError as exc:
raise PluginRecordingExportError("K1 recording export failed") from exc
@@ -0,0 +1,35 @@
"""Verified protocol decoders for captured K1 application streams."""
from k1link.device_plugins.xgrids_k1.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",
]
@@ -0,0 +1,182 @@
from __future__ import annotations
from typing import Protocol
from k1link.data_plane import (
ConsumerFrameContext,
DecodedDataPlaneView,
DecodedPointCloudView,
DecodedPoseView,
NormalizationError,
)
from k1link.device_plugins.xgrids_k1.protocol.streams import (
LegacyPointCloudFrame,
LegacyPoseFrame,
LioPointCloudFrame,
LioPoseFrame,
StreamDecodeError,
decode_legacy_pointcloud,
decode_legacy_pose,
decode_lio_pcl,
decode_lio_pose,
)
CANONICAL_MAP_FRAME = "map"
CANONICAL_SENSOR_FRAME = "sensor"
class K1TransportMessage(Protocol):
"""Minimum raw transport shape accepted by the K1 normalizer."""
@property
def sequence(self) -> int: ...
@property
def topic(self) -> str: ...
@property
def payload(self) -> bytes: ...
@property
def received_at_epoch_ns(self) -> int: ...
@property
def received_monotonic_ns(self) -> int | None: ...
@property
def source(self) -> str: ...
def normalize_k1_message(
message: K1TransportMessage,
*,
processing_started_monotonic_ns: int,
) -> DecodedDataPlaneView | None:
"""Normalize one verified K1 transport message.
Unknown channels intentionally return ``None``. A known channel with an
invalid payload raises a transport-neutral ``NormalizationError``. Neither
raw channel names nor raw bytes are retained in the returned consumer view.
"""
try:
if message.topic.endswith("/lio_pcl"):
return _normalize_lio_points(
decode_lio_pcl(message.payload),
_context(message, processing_started_monotonic_ns),
)
if message.topic == "RealtimePointcloud":
return _normalize_legacy_points(
decode_legacy_pointcloud(message.payload),
_context(message, processing_started_monotonic_ns),
)
if message.topic.endswith("/lio_pose"):
return _normalize_lio_pose(
decode_lio_pose(message.payload),
_context(message, processing_started_monotonic_ns),
)
if message.topic == "RealtimePath":
return _normalize_legacy_pose(
decode_legacy_pose(message.payload),
_context(message, processing_started_monotonic_ns),
)
except StreamDecodeError as exc:
raise NormalizationError("known K1 data-plane message failed validation") from exc
return None
def _context(
message: K1TransportMessage,
processing_started_monotonic_ns: int,
) -> ConsumerFrameContext:
return ConsumerFrameContext(
sequence=message.sequence,
captured_at_epoch_ns=message.received_at_epoch_ns,
received_monotonic_ns=message.received_monotonic_ns,
processing_started_monotonic_ns=processing_started_monotonic_ns,
encoded_size_bytes=len(message.payload),
live=message.source == "live_mqtt",
)
def _with_device_context(
context: ConsumerFrameContext,
*,
source_device_alias: str,
source_session_alias: str,
) -> ConsumerFrameContext:
return ConsumerFrameContext(
sequence=context.sequence,
captured_at_epoch_ns=context.captured_at_epoch_ns,
received_monotonic_ns=context.received_monotonic_ns,
processing_started_monotonic_ns=context.processing_started_monotonic_ns,
encoded_size_bytes=context.encoded_size_bytes,
live=context.live,
source_device_alias=source_device_alias or None,
source_session_alias=source_session_alias or None,
)
def _normalize_lio_points(
frame: LioPointCloudFrame,
context: ConsumerFrameContext,
) -> DecodedPointCloudView:
context = _with_device_context(
context,
source_device_alias=frame.header.device_id,
source_session_alias=frame.header.session_id,
)
positions = tuple(point.scaled_xyz(frame.header.scaler) for point in frame.points)
intensities = bytes(point.intensity for point in frame.points)
return DecodedPointCloudView(
context=context,
frame_id=CANONICAL_MAP_FRAME,
positions_xyz=positions,
intensities=intensities,
)
def _normalize_legacy_points(
frame: LegacyPointCloudFrame,
context: ConsumerFrameContext,
) -> DecodedPointCloudView:
return DecodedPointCloudView(
context=context,
frame_id=CANONICAL_MAP_FRAME,
positions_xyz=tuple((point.x, point.y, point.z) for point in frame.points),
intensities=bytes(point.intensity for point in frame.points),
colors_rgb=bytes(
channel for point in frame.points for channel in (point.r, point.g, point.b)
),
)
def _normalize_lio_pose(
frame: LioPoseFrame,
context: ConsumerFrameContext,
) -> DecodedPoseView:
context = _with_device_context(
context,
source_device_alias=frame.header.device_id,
source_session_alias=frame.header.session_id,
)
return DecodedPoseView(
context=context,
frame_id=CANONICAL_MAP_FRAME,
child_frame_id=CANONICAL_SENSOR_FRAME,
position_xyz=frame.position_xyz,
orientation_xyzw=frame.orientation_xyzw,
)
def _normalize_legacy_pose(
frame: LegacyPoseFrame,
context: ConsumerFrameContext,
) -> DecodedPoseView:
return DecodedPoseView(
context=context,
frame_id=CANONICAL_MAP_FRAME,
child_frame_id=CANONICAL_SENSOR_FRAME,
position_xyz=frame.position_xyz,
orientation_xyzw=frame.orientation_xyzw,
)
@@ -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}")
@@ -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.device_plugins.xgrids_k1.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
@@ -0,0 +1,599 @@
from __future__ import annotations
import hashlib
import math
import os
import threading
from collections.abc import Callable
from contextlib import suppress
from dataclasses import dataclass
from pathlib import Path
from typing import TypedDict
from uuid import UUID, uuid4
import numpy as np
import rerun as rr
from rerun import blueprint as rrb
from k1link.data_plane import DecodedPointCloudView, DecodedPoseView, NormalizationError
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
from k1link.device_plugins.xgrids_k1.viewer.replay import (
ReplayFormatError,
detect_replay_format,
iter_replay_messages,
)
from k1link.viewer.rerun_bridge import (
MAX_TRAJECTORY_POSES,
TRAJECTORY_APPEND_INTERVAL_NS,
TRAJECTORY_FORCE_APPEND_NS,
TRAJECTORY_MIN_DISTANCE_METERS,
TRAJECTORY_PUBLISH_INTERVAL_NS,
RerunSceneSettings,
_parse_hex_color,
_point_colors,
)
APPLICATION_ID = "nodedc_mission_core_recorded"
SESSION_TIMELINE = "session_time"
CAPTURE_TIMELINE = "capture_time"
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
# blueprint update makes the update overwrite the existing scene instead of
# creating a fresh view/container (which would also reset the operator's eye
# position and layout).
RECORDED_SPATIAL_VIEW_ID = UUID("5f5f11d5-3b0a-4a81-887b-2be767cba1c0")
RECORDED_ROOT_CONTAINER_ID = UUID("b02f2aca-8471-4dcb-b786-53df5a320fc8")
RECORDED_POINTS_VISUALIZER_ID = UUID("ca037ec0-8761-4417-86ee-846fa2875303")
class RrdExportSummary(TypedDict):
schema_version: int
input_path: str
output_path: str
recording_id: str
timeline: str
capture_timeline: str
source_messages: int
decoded_messages: int
point_frames: int
pose_frames: int
ignored_messages: int
points: int
trajectory_poses: int
trajectory_updates: int
session_origin_monotonic_ns: int
timeline_start_ns: int
timeline_end_ns: int
timeline_span_ns: int
first_decoded_time_ns: int
last_decoded_time_ns: int
source_sha256: str
rrd_sha256: str
rrd_bytes: int
class RrdExportError(RuntimeError):
"""A raw capture could not be converted into a complete durable RRD."""
class RrdExportCancelled(RrdExportError):
"""A background RRD export was cooperatively cancelled."""
@dataclass(slots=True)
class _ExportCounters:
source_messages: int = 0
point_frames: int = 0
pose_frames: int = 0
ignored_messages: int = 0
points: int = 0
first_decoded_time_ns: int | None = None
last_decoded_time_ns: int | None = None
@property
def decoded_messages(self) -> int:
return self.point_frames + self.pose_frames
def observe_decoded(self, session_time_ns: int) -> None:
if self.first_decoded_time_ns is None:
self.first_decoded_time_ns = session_time_ns
self.last_decoded_time_ns = session_time_ns
@dataclass(slots=True)
class _TrajectoryBuffer:
positions: list[tuple[float, float, float]]
last_append_time_ns: int | None = None
last_publish_time_ns: int | None = None
updates: int = 0
@classmethod
def empty(cls) -> _TrajectoryBuffer:
return cls(positions=[])
def process(
self,
recording: rr.RecordingStream,
position: tuple[float, float, float],
session_time_ns: int,
) -> None:
if not self._append(position, session_time_ns):
return
if (
self.last_publish_time_ns is not None
and session_time_ns - self.last_publish_time_ns < TRAJECTORY_PUBLISH_INTERVAL_NS
):
return
self.last_publish_time_ns = session_time_ns
self.updates += 1
recording.log(
"/world/trajectory",
rr.LineStrips3D(
[list(self.positions)],
colors=[247, 248, 244, 255],
radii=rr.Radius.ui_points(2.0),
),
)
def _append(self, position: tuple[float, float, float], session_time_ns: int) -> bool:
if not self.positions:
self.positions.append(position)
self.last_append_time_ns = session_time_ns
return True
assert self.last_append_time_ns is not None
elapsed_ns = session_time_ns - self.last_append_time_ns
if elapsed_ns < TRAJECTORY_APPEND_INTERVAL_NS:
return False
if (
math.dist(self.positions[-1], position) < TRAJECTORY_MIN_DISTANCE_METERS
and elapsed_ns < TRAJECTORY_FORCE_APPEND_NS
):
return False
self.positions.append(position)
self.last_append_time_ns = session_time_ns
if len(self.positions) > MAX_TRAJECTORY_POSES:
last = self.positions[-1]
self.positions = self.positions[::2]
if self.positions[-1] != last:
self.positions.append(last)
return True
def export_k1mqtt_to_rrd(
input_path: Path,
output_path: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None,
) -> RrdExportSummary:
"""Losslessly project every decodable K1 data-plane frame into one RRD.
The raw capture remains the source of record. The derived RRD uses a
recording-local duration timeline whose zero is the first raw message's
receive-monotonic timestamp. It never traverses the bounded live-preview
queue, so export throughput cannot drop point or pose frames.
The destination is replaced only after the temporary RRD has been closed,
flushed and fsynced. Any decode, timing, sink or rename failure therefore
leaves an existing destination artifact untouched.
"""
_raise_if_cancelled(cancel_event)
source = input_path.expanduser().resolve()
destination = output_path.expanduser().resolve()
_validate_paths(source, destination)
destination.parent.mkdir(parents=True, exist_ok=True)
recording_id = str(uuid4())
temporary = destination.with_name(f".{destination.name}.{recording_id}.tmp")
source_sha256 = _sha256_file(
source,
cancel_event=cancel_event,
activity_callback=activity_callback,
)
settings = RerunSceneSettings()
blueprint = _recorded_blueprint(settings)
recording: rr.RecordingStream | None = None
recording_closed = False
published = False
counters = _ExportCounters()
trajectory = _TrajectoryBuffer.empty()
session_origin_ns: int | None = None
previous_monotonic_ns: int | None = None
try:
recording = rr.RecordingStream(APPLICATION_ID, recording_id=recording_id)
recording.set_sinks(
rr.FileSink(temporary, write_footer=True),
default_blueprint=blueprint,
)
_log_static_scene(recording)
_log_session_origin(recording)
for message in iter_replay_messages(source):
_raise_if_cancelled(cancel_event)
_notify_activity(activity_callback)
counters.source_messages += 1
monotonic_ns = message.received_monotonic_ns
if monotonic_ns is None:
raise RrdExportError(
"native capture metadata must provide received_monotonic_ns "
f"for message {message.sequence}"
)
if previous_monotonic_ns is not None and monotonic_ns < previous_monotonic_ns:
raise RrdExportError(
f"native capture monotonic time decreases at message {message.sequence}"
)
if session_origin_ns is None:
session_origin_ns = monotonic_ns
session_time_ns = monotonic_ns - session_origin_ns
if session_time_ns > JS_MAX_SAFE_INTEGER:
raise RrdExportError(
"session duration exceeds the exact JavaScript nanosecond range"
)
previous_monotonic_ns = monotonic_ns
try:
decoded = normalize_k1_message(
message,
processing_started_monotonic_ns=monotonic_ns,
)
except NormalizationError as exc:
raise RrdExportError(
f"known K1 frame {message.sequence} failed normalization"
) from exc
if decoded is None:
counters.ignored_messages += 1
continue
_set_frame_time(
recording,
decoded.context.sequence,
session_time_ns,
decoded.context.captured_at_epoch_ns,
)
counters.observe_decoded(session_time_ns)
if isinstance(decoded, DecodedPointCloudView):
_log_points(recording, decoded, settings)
counters.point_frames += 1
counters.points += decoded.point_count
elif isinstance(decoded, DecodedPoseView):
position = (
float(decoded.position_xyz[0]),
float(decoded.position_xyz[1]),
float(decoded.position_xyz[2]),
)
_log_pose(recording, decoded, position)
trajectory.process(recording, position, session_time_ns)
counters.pose_frames += 1
else:
counters.ignored_messages += 1
if session_origin_ns is None:
raise RrdExportError("native capture contains no messages")
if counters.decoded_messages == 0:
raise RrdExportError("native capture contains no decodable point or pose frames")
assert counters.first_decoded_time_ns is not None
assert counters.last_decoded_time_ns is not None
_raise_if_cancelled(cancel_event)
recording.flush(timeout_sec=30.0)
recording.disconnect()
recording_closed = True
_raise_if_cancelled(cancel_event)
_fsync_file(temporary)
rrd_bytes = temporary.stat().st_size
if rrd_bytes <= 0:
raise RrdExportError("Rerun produced an empty recording")
rrd_sha256 = _sha256_file(
temporary,
cancel_event=cancel_event,
activity_callback=activity_callback,
)
summary = RrdExportSummary(
schema_version=1,
input_path=str(source),
output_path=str(destination),
recording_id=recording_id,
timeline=SESSION_TIMELINE,
capture_timeline=CAPTURE_TIMELINE,
source_messages=counters.source_messages,
decoded_messages=counters.decoded_messages,
point_frames=counters.point_frames,
pose_frames=counters.pose_frames,
ignored_messages=counters.ignored_messages,
points=counters.points,
trajectory_poses=len(trajectory.positions),
trajectory_updates=trajectory.updates,
session_origin_monotonic_ns=session_origin_ns,
timeline_start_ns=0,
# Playback completeness is defined by data actually written to
# the RRD. K1 status/heartbeat packets may continue long after the
# final point or pose frame; advertising that raw tail as the RRD
# end makes a strict browser buffering gate wait forever.
timeline_end_ns=counters.last_decoded_time_ns,
timeline_span_ns=counters.last_decoded_time_ns,
first_decoded_time_ns=counters.first_decoded_time_ns,
last_decoded_time_ns=counters.last_decoded_time_ns,
source_sha256=source_sha256,
rrd_sha256=rrd_sha256,
rrd_bytes=rrd_bytes,
)
_raise_if_cancelled(cancel_event)
os.replace(temporary, destination)
_fsync_directory(destination.parent)
published = True
return summary
except (ReplayFormatError, OSError) as exc:
raise RrdExportError(f"RRD export failed: {exc}") from exc
finally:
if recording is not None and not recording_closed:
with suppress(BaseException):
recording.disconnect()
if not published:
temporary.unlink(missing_ok=True)
def _validate_paths(source: Path, destination: Path) -> None:
if not source.is_file():
raise RrdExportError(f"raw capture does not exist: {source}")
if source.suffix.casefold() != ".k1mqtt":
raise RrdExportError("RRD export accepts only native .k1mqtt captures")
if destination.suffix.casefold() != ".rrd":
raise RrdExportError("RRD destination must use the .rrd suffix")
if source == destination:
raise RrdExportError("raw capture and RRD destination must be different files")
try:
replay_format = detect_replay_format(source)
except ReplayFormatError as exc:
raise RrdExportError(str(exc)) from exc
if replay_format != "k1mqtt":
raise RrdExportError("RRD export accepts only native K1MQTT captures")
def _recorded_blueprint(
settings: RerunSceneSettings,
*,
include_initial_playback_state: bool = True,
) -> rrb.Blueprint:
accumulation = max(0.0, settings.accumulation_seconds)
# An omitted range means Rerun's native latest-at query: the most recent
# LiDAR frame at the cursor. A zero-width range is *not* equivalent; it
# only matches rows stamped at the cursor's exact nanosecond and therefore
# makes an ordinary recorded scan appear empty.
time_ranges: list[rr.VisibleTimeRange] | None = None
if accumulation > 0:
time_ranges = [
rr.VisibleTimeRange(
SESSION_TIMELINE,
start=rr.TimeRangeBoundary.cursor_relative(seconds=-accumulation),
end=rr.TimeRangeBoundary.cursor_relative(),
)
]
point_visualizer = rr.Points3D.from_fields(
# A negative Radius value is Rerun's serialized representation
# for UI points. Blueprint overrides broadcast this singleton
# value across every recorded point without rewriting the store.
radii=rr.Radius.ui_points(settings.point_size),
# Recorded height/intensity/distance/RGB palettes are baked into
# each Points3D row. A uniform custom color is the one color mode
# that can be replaced safely by a singleton blueprint override.
colors=(
[_parse_hex_color(settings.custom_color)]
if settings.palette == "custom"
else None
),
).visualizer()
point_visualizer.id = RECORDED_POINTS_VISUALIZER_ID
spatial_view = rrb.Spatial3DView(
origin="/world",
name="Пространственная сцена",
background=[7, 8, 10, 255],
line_grid=rrb.LineGrid3D(
visible=settings.show_grid,
color=[86, 91, 99, 110],
stroke_width=0.75,
),
overrides={
# EntityBehavior is evaluated from the blueprint store and can
# therefore hide/reveal already-recorded entities without
# rewriting the data RRD. Keep the point visualizer alongside it
# so size/color overrides remain active for the same entity.
"/world/points": [
rrb.EntityBehavior(visible=settings.show_points),
point_visualizer,
],
"/world/trajectory": rrb.EntityBehavior(
visible=settings.show_trajectory,
),
},
# A positive window accumulates historical frames. With no
# window, latest-at deliberately keeps one current LiDAR frame.
time_ranges=time_ranges,
)
spatial_view.id = RECORDED_SPATIAL_VIEW_ID
root_container = rrb.Tabs(spatial_view)
root_container.id = RECORDED_ROOT_CONTAINER_ID
if include_initial_playback_state:
# This state is appropriate only while opening a newly exported RRD.
# Settings-only blueprint messages must not pause an already playing
# recording or mutate the host-owned panel state.
return rrb.Blueprint(
root_container,
rrb.TimePanel(
timeline=SESSION_TIMELINE,
play_state="paused",
state="hidden",
),
auto_layout=False,
auto_views=False,
collapse_panels=True,
)
return rrb.Blueprint(
root_container,
auto_layout=False,
auto_views=False,
collapse_panels=False,
)
def recorded_blueprint_rrd(
settings: RerunSceneSettings,
*,
application_id: str = APPLICATION_ID,
recording_id: str,
) -> bytes:
"""Serialize a small active blueprint update for an already-open recording.
The returned RRD contains blueprint-store messages only; it never copies the
recorded data store and is therefore safe to push through a WebViewer log
channel when operator display settings change.
"""
recording = rr.RecordingStream(
application_id,
recording_id=recording_id,
send_properties=False,
)
stream = rr.binary_stream(recording)
try:
recording.send_blueprint(
_recorded_blueprint(
settings,
include_initial_playback_state=False,
),
make_active=True,
make_default=False,
)
payload = stream.read(flush=True, flush_timeout_sec=5.0)
except Exception as exc:
raise RrdExportError("failed to serialize recorded blueprint") from exc
finally:
with suppress(Exception):
recording.disconnect()
if not payload or not payload.startswith(b"RRF2") or len(payload) > 1_048_576:
raise RrdExportError("serialized recorded blueprint is invalid")
return payload
def _log_static_scene(recording: rr.RecordingStream) -> None:
recording.log("/world", rr.ViewCoordinates.RIGHT_HAND_Z_UP, static=True)
recording.log(
"/world/sensor_pose",
rr.TransformAxes3D(axis_length=0.45, show_frame=False),
static=True,
)
def _log_session_origin(recording: rr.RecordingStream) -> None:
"""Materialize the declared zero of ``session_time`` outside the 3D scene."""
recording.set_time(
SESSION_TIMELINE,
duration=np.timedelta64(0, "ns"),
)
recording.log(
"/__mission_core/session_origin",
rr.AnyValues(session_origin=True),
)
def _set_frame_time(
recording: rr.RecordingStream,
sequence: int,
session_time_ns: int,
capture_time_ns: int,
) -> None:
recording.set_time(
SESSION_TIMELINE,
duration=np.timedelta64(session_time_ns, "ns"),
)
recording.set_time(
CAPTURE_TIMELINE,
timestamp=np.datetime64(capture_time_ns, "ns"),
)
recording.set_time("message_sequence", sequence=sequence)
def _log_points(
recording: rr.RecordingStream,
frame: DecodedPointCloudView,
settings: RerunSceneSettings,
) -> None:
positions = np.asarray(frame.positions_xyz, dtype=np.float32).reshape((-1, 3))
if frame.intensities is None:
intensities = np.full(frame.point_count, 255, dtype=np.uint8)
else:
intensities = np.frombuffer(frame.intensities, dtype=np.uint8)
rgb = (
None
if frame.colors_rgb is None
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
)
recording.log(
"/world/points",
rr.Points3D(
positions,
colors=_point_colors(positions, intensities, rgb, settings),
radii=rr.Radius.ui_points(settings.point_size),
),
)
def _log_pose(
recording: rr.RecordingStream,
frame: DecodedPoseView,
position: tuple[float, float, float],
) -> None:
recording.log(
"/world/sensor_pose",
rr.Transform3D(
translation=position,
quaternion=rr.Quaternion(xyzw=frame.orientation_xyzw),
),
)
def _fsync_file(path: Path) -> None:
with path.open("rb") as stream:
os.fsync(stream.fileno())
def _fsync_directory(path: Path) -> None:
descriptor = os.open(path, os.O_RDONLY)
try:
os.fsync(descriptor)
finally:
os.close(descriptor)
def _sha256_file(
path: Path,
*,
cancel_event: threading.Event | None = None,
activity_callback: Callable[[], None] | None = None,
) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
_raise_if_cancelled(cancel_event)
_notify_activity(activity_callback)
digest.update(chunk)
return digest.hexdigest()
def _raise_if_cancelled(cancel_event: threading.Event | None) -> None:
if cancel_event is not None and cancel_event.is_set():
raise RrdExportCancelled("RRD export was cancelled")
def _notify_activity(callback: Callable[[], None] | None) -> None:
if callback is not None:
callback()
@@ -0,0 +1 @@
"""Read-only USB discovery helpers for macOS."""
@@ -0,0 +1,515 @@
from __future__ import annotations
import plistlib
import re
import subprocess
from collections.abc import Callable, Iterator
from dataclasses import dataclass
from pathlib import PurePosixPath
from typing import TypedDict, cast
from k1link.artifacts import utc_now_iso
USB_IOREG_COMMAND = (
"/usr/sbin/ioreg",
"-a",
"-r",
"-c",
"IOUSBHostDevice",
"-l",
"-w",
"0",
)
SERIAL_IOREG_COMMAND = (
"/usr/sbin/ioreg",
"-a",
"-r",
"-c",
"IOSerialBSDClient",
"-l",
"-w",
"0",
)
DISKUTIL_COMMAND = ("/usr/sbin/diskutil", "list", "-plist", "external", "physical")
@dataclass(frozen=True)
class CommandOutput:
argv: tuple[str, ...]
returncode: int | None
stdout: bytes
stderr: bytes
error: str | None
CommandRunner = Callable[[list[str]], CommandOutput]
class SourceStatus(TypedDict):
name: str
argv: list[str]
ok: bool
returncode: int | None
error: str | None
class UsbInterfaceRecord(TypedDict):
name: str | None
function_hints: list[str]
interface_number: int | None
interface_class: int | None
interface_class_hex: str | None
interface_class_name: str | None
interface_subclass: int | None
interface_protocol: int | None
alternate_setting: int | None
configuration_value: int | None
endpoint_count: int | None
class UsbDeviceRecord(TypedDict):
product_name: str | None
vendor_name: str | None
serial_number: str | None
vendor_id: int | None
vendor_id_hex: str | None
product_id: int | None
product_id_hex: str | None
device_class: int | None
device_subclass: int | None
device_protocol: int | None
usb_bcd: int | None
device_bcd: int | None
usb_speed: int | None
link_speed_bits_per_second: int | None
usb_address: int | None
location_id: int | None
registry_entry_id: int | None
bsd_names: list[str]
interface_capabilities: list[str]
interfaces: list[UsbInterfaceRecord]
class ExternalStorageEntry(TypedDict):
device_identifier: str
parent_device_identifier: str | None
content: str | None
size_bytes: int | None
volume_name: str | None
mount_point: str | None
os_internal: bool | None
xgrids_related: bool
class ExternalStorageRecord(TypedDict):
all_disks: list[str]
whole_disks: list[str]
volumes_from_disks: list[str]
entries: list[ExternalStorageEntry]
class UsbModemRecord(TypedDict):
callout_device: str | None
dialin_device: str | None
tty_base_name: str | None
client_type: str | None
class SafetyRecord(TypedDict):
metadata_only: bool
sudo_used: bool
device_file_contents_read: bool
device_writes_performed: bool
class UsbSnapshot(TypedDict):
schema_version: int
created_at_utc: str
sensitivity: str
safety: SafetyRecord
sources: list[SourceStatus]
xgrids_device_count: int
xgrids_devices: list[UsbDeviceRecord]
external_storage: ExternalStorageRecord
usbmodem_device_names: list[str]
usbmodem_devices: list[UsbModemRecord]
def run_command(argv: list[str]) -> CommandOutput:
"""Run a fixed read-only macOS metadata command without privilege escalation."""
try:
result = subprocess.run(
argv,
check=False,
capture_output=True,
timeout=20,
)
except (OSError, subprocess.SubprocessError) as exc:
return CommandOutput(
argv=tuple(argv),
returncode=None,
stdout=b"",
stderr=b"",
error=f"{type(exc).__name__}: {exc}",
)
return CommandOutput(
argv=tuple(argv),
returncode=result.returncode,
stdout=result.stdout,
stderr=result.stderr,
error=None,
)
def _node(value: object) -> dict[str, object] | None:
if not isinstance(value, dict):
return None
raw = cast("dict[object, object]", value)
return {key: item for key, item in raw.items() if isinstance(key, str)}
def _items(value: object) -> list[object]:
if not isinstance(value, list):
return []
return cast("list[object]", value)
def _walk_registry_nodes(value: object) -> Iterator[dict[str, object]]:
for item in _items(value):
yield from _walk_registry_nodes(item)
node = _node(value)
if node is None:
return
yield node
yield from _walk_registry_nodes(node.get("IORegistryEntryChildren"))
def _string(node: dict[str, object], *keys: str) -> str | None:
for key in keys:
value = node.get(key)
if isinstance(value, str):
return value
return None
def _integer(node: dict[str, object], key: str) -> int | None:
value = node.get(key)
if isinstance(value, int) and not isinstance(value, bool):
return value
return None
def _boolean(node: dict[str, object], key: str) -> bool | None:
value = node.get(key)
return value if isinstance(value, bool) else None
def _hex_id(value: int | None) -> str | None:
return None if value is None else f"0x{value:04x}"
def _is_xgrids_device(node: dict[str, object]) -> bool:
names = [
_string(node, "USB Product Name"),
_string(node, "kUSBProductString"),
_string(node, "IORegistryEntryName"),
_string(node, "USB Vendor Name"),
_string(node, "kUSBVendorString"),
]
identity = " ".join(name.casefold() for name in names if name is not None)
return "xgrids" in identity or "lixel" in identity
def _usb_class_name(interface_class: int | None) -> str | None:
if interface_class is None:
return None
return {
0x02: "communications_and_cdc_control",
0x08: "mass_storage",
0x0A: "cdc_data",
0xE0: "wireless_controller",
0xFF: "vendor_specific",
}.get(interface_class)
def _function_hints(
name: str | None,
interface_class: int | None,
interface_subclass: int | None,
) -> list[str]:
normalized = (name or "").casefold()
hints: set[str] = set()
if "rndis" in normalized:
hints.add("rndis")
if "mass storage" in normalized or interface_class == 0x08:
hints.add("mass_storage")
if "ncm" in normalized or (interface_class == 0x02 and interface_subclass == 0x0D):
hints.add("ncm")
if any(marker in normalized for marker in ("serial", "modem", "acm")) or (
interface_class == 0x02 and interface_subclass == 0x02
):
hints.add("serial")
if interface_class == 0x0A:
hints.add("cdc_data")
return sorted(hints)
def _interface_record(node: dict[str, object]) -> UsbInterfaceRecord:
name = _string(node, "kUSBString", "IORegistryEntryName")
interface_class = _integer(node, "bInterfaceClass")
interface_subclass = _integer(node, "bInterfaceSubClass")
return {
"name": name,
"function_hints": _function_hints(name, interface_class, interface_subclass),
"interface_number": _integer(node, "bInterfaceNumber"),
"interface_class": interface_class,
"interface_class_hex": _hex_id(interface_class),
"interface_class_name": _usb_class_name(interface_class),
"interface_subclass": interface_subclass,
"interface_protocol": _integer(node, "bInterfaceProtocol"),
"alternate_setting": _integer(node, "bAlternateSetting"),
"configuration_value": _integer(node, "bConfigurationValue"),
"endpoint_count": _integer(node, "bNumEndpoints"),
}
def _interface_sort_key(record: UsbInterfaceRecord) -> tuple[bool, int, str]:
number = record["interface_number"]
return (number is None, number if number is not None else 0, record["name"] or "")
def _device_record(node: dict[str, object]) -> UsbDeviceRecord:
interfaces = [
_interface_record(child)
for child in _walk_registry_nodes(node.get("IORegistryEntryChildren"))
if _string(child, "IOObjectClass") == "IOUSBHostInterface"
]
interfaces.sort(key=_interface_sort_key)
bsd_names = sorted(
{
name
for child in _walk_registry_nodes(node.get("IORegistryEntryChildren"))
if (name := _string(child, "BSD Name")) is not None
}
)
interface_capabilities = sorted(
{hint for interface in interfaces for hint in interface["function_hints"]}
)
vendor_id = _integer(node, "idVendor")
product_id = _integer(node, "idProduct")
return {
"product_name": _string(
node, "USB Product Name", "kUSBProductString", "IORegistryEntryName"
),
"vendor_name": _string(node, "USB Vendor Name", "kUSBVendorString"),
"serial_number": _string(node, "USB Serial Number", "kUSBSerialNumberString"),
"vendor_id": vendor_id,
"vendor_id_hex": _hex_id(vendor_id),
"product_id": product_id,
"product_id_hex": _hex_id(product_id),
"device_class": _integer(node, "bDeviceClass"),
"device_subclass": _integer(node, "bDeviceSubClass"),
"device_protocol": _integer(node, "bDeviceProtocol"),
"usb_bcd": _integer(node, "bcdUSB"),
"device_bcd": _integer(node, "bcdDevice"),
"usb_speed": _integer(node, "USBSpeed"),
"link_speed_bits_per_second": _integer(node, "UsbLinkSpeed"),
"usb_address": _integer(node, "USB Address"),
"location_id": _integer(node, "locationID"),
"registry_entry_id": _integer(node, "IORegistryEntryID"),
"bsd_names": bsd_names,
"interface_capabilities": interface_capabilities,
"interfaces": interfaces,
}
def parse_xgrids_devices(plist: object) -> list[UsbDeviceRecord]:
devices = [
_device_record(node)
for node in (_node(item) for item in _items(plist))
if node is not None and _is_xgrids_device(node)
]
devices.sort(
key=lambda record: (
record["product_name"] or "",
record["serial_number"] or "",
record["location_id"] or 0,
)
)
return devices
def _string_list(node: dict[str, object], key: str) -> list[str]:
return sorted(item for item in _items(node.get(key)) if isinstance(item, str))
def _disk_root(device_identifier: str) -> str | None:
match = re.fullmatch(r"(disk\d+)(?:s\d+)*", device_identifier)
return None if match is None else match.group(1)
def _external_storage_entries(
value: object,
parent_device_identifier: str | None,
xgrids_disk_roots: set[str],
) -> list[ExternalStorageEntry]:
entries: list[ExternalStorageEntry] = []
for item in _items(value):
node = _node(item)
if node is None:
continue
device_identifier = _string(node, "DeviceIdentifier")
next_parent = parent_device_identifier
if device_identifier is not None:
disk_root = _disk_root(device_identifier)
entries.append(
{
"device_identifier": device_identifier,
"parent_device_identifier": parent_device_identifier,
"content": _string(node, "Content"),
"size_bytes": _integer(node, "Size"),
"volume_name": _string(node, "VolumeName"),
"mount_point": _string(node, "MountPoint"),
"os_internal": _boolean(node, "OSInternal"),
"xgrids_related": bool(
disk_root is not None and disk_root in xgrids_disk_roots
),
}
)
next_parent = device_identifier
for child_key in ("Partitions", "APFSVolumes"):
entries.extend(
_external_storage_entries(
node.get(child_key),
next_parent,
xgrids_disk_roots,
)
)
return entries
def parse_external_storage(
plist: object,
xgrids_devices: list[UsbDeviceRecord],
) -> ExternalStorageRecord:
node = _node(plist) or {}
xgrids_disk_roots = {
root
for device in xgrids_devices
for name in device["bsd_names"]
if (root := _disk_root(name)) is not None
}
entries = _external_storage_entries(
node.get("AllDisksAndPartitions"),
None,
xgrids_disk_roots,
)
entries.sort(key=lambda entry: entry["device_identifier"])
return {
"all_disks": _string_list(node, "AllDisks"),
"whole_disks": _string_list(node, "WholeDisks"),
"volumes_from_disks": _string_list(node, "VolumesFromDisks"),
"entries": entries,
}
def _is_usbmodem_path(path: str | None) -> bool:
return path is not None and PurePosixPath(path).name.startswith(("cu.usbmodem", "tty.usbmodem"))
def parse_usbmodem_devices(plist: object) -> tuple[list[str], list[UsbModemRecord]]:
names: set[str] = set()
records: list[UsbModemRecord] = []
for item in _items(plist):
node = _node(item)
if node is None:
continue
callout = _string(node, "IOCalloutDevice")
dialin = _string(node, "IODialinDevice")
matched_paths: list[str] = []
for path in (callout, dialin):
if path is not None and _is_usbmodem_path(path):
matched_paths.append(path)
if not matched_paths:
continue
names.update(matched_paths)
records.append(
{
"callout_device": callout,
"dialin_device": dialin,
"tty_base_name": _string(node, "IOTTYBaseName"),
"client_type": _string(node, "IOSerialBSDClientType"),
}
)
records.sort(key=lambda record: (record["callout_device"] or "", record["dialin_device"] or ""))
return sorted(names), records
def _source_status(name: str, output: CommandOutput) -> SourceStatus:
error = output.error
if error is None and output.returncode != 0:
stderr = output.stderr.decode("utf-8", errors="replace").strip()
error = stderr[:500] or f"command exited with status {output.returncode}"
return {
"name": name,
"argv": list(output.argv),
"ok": error is None and output.returncode == 0,
"returncode": output.returncode,
"error": error,
}
def _load_plist(
name: str,
output: CommandOutput,
*,
empty_is_list: bool = False,
) -> tuple[object | None, SourceStatus]:
status = _source_status(name, output)
if not status["ok"]:
return None, status
if empty_is_list and not output.stdout.strip():
return [], status
try:
return plistlib.loads(output.stdout), status
except (plistlib.InvalidFileException, ValueError, TypeError, OverflowError) as exc:
status["ok"] = False
status["error"] = f"invalid plist: {type(exc).__name__}: {exc}"
return None, status
def snapshot(runner: CommandRunner = run_command) -> UsbSnapshot:
"""Collect USB registry and storage metadata without opening device files."""
usb_output = runner(list(USB_IOREG_COMMAND))
serial_output = runner(list(SERIAL_IOREG_COMMAND))
storage_output = runner(list(DISKUTIL_COMMAND))
usb_plist, usb_status = _load_plist("usb_ioreg", usb_output, empty_is_list=True)
serial_plist, serial_status = _load_plist("serial_ioreg", serial_output, empty_is_list=True)
storage_plist, storage_status = _load_plist("external_disks", storage_output)
devices = parse_xgrids_devices(usb_plist)
usbmodem_names, usbmodem_devices = parse_usbmodem_devices(serial_plist)
external_storage = parse_external_storage(storage_plist, devices)
return {
"schema_version": 1,
"created_at_utc": utc_now_iso(),
"sensitivity": (
"contains USB serial numbers, BSD device names and external volume metadata; "
"store only in an ignored session path and do not commit"
),
"safety": {
"metadata_only": True,
"sudo_used": False,
"device_file_contents_read": False,
"device_writes_performed": False,
},
"sources": [usb_status, serial_status, storage_status],
"xgrids_device_count": len(devices),
"xgrids_devices": devices,
"external_storage": external_storage,
"usbmodem_device_names": usbmodem_names,
"usbmodem_devices": usbmodem_devices,
}
@@ -0,0 +1,11 @@
"""K1-native capture, replay, and compatibility visualization runtime."""
from .messages import StreamMessage
from .replay import ReplayFormatError, detect_replay_format, iter_replay_messages
__all__ = [
"ReplayFormatError",
"StreamMessage",
"detect_replay_format",
"iter_replay_messages",
]
@@ -0,0 +1,288 @@
from __future__ import annotations
import struct
import time
from collections import deque
from dataclasses import dataclass
import foxglove
from foxglove import Channel
from foxglove.channels import (
PointCloudChannel,
PoseInFrameChannel,
SceneUpdateChannel,
)
from foxglove.messages import (
Color,
LinePrimitive,
LinePrimitiveLineType,
PackedElementField,
PackedElementFieldNumericType,
Point3,
PointCloud,
Pose,
PoseInFrame,
Quaternion,
SceneEntity,
SceneUpdate,
Timestamp,
Vector3,
)
from k1link.device_plugins.xgrids_k1.protocol.streams import (
LegacyPointCloudFrame,
LegacyPoseFrame,
LioPointCloudFrame,
LioPoseFrame,
StreamDecodeError,
decode_legacy_pointcloud,
decode_legacy_pose,
decode_lio_pcl,
decode_lio_pose,
)
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
from k1link.viewer.metrics import BridgeMetrics
POINT_STRUCT = struct.Struct("<fffB3x")
POINT_STRIDE = POINT_STRUCT.size
FRAME_ID = "map"
MAX_TRAJECTORY_POSES = 20_000
@dataclass(frozen=True, slots=True)
class PackedPointCloud:
data: bytes
point_count: int
class FoxgloveBridge:
"""Decode verified K1 topics and publish Foxglove-native visualization messages."""
def __init__(
self,
*,
host: str = "127.0.0.1",
port: int = 8765,
metrics: BridgeMetrics | None = None,
) -> None:
self.metrics = metrics or BridgeMetrics()
self._server = foxglove.start_server(
name="Mission Core K1 legacy bridge",
host=host,
port=port,
message_backlog_size=32,
)
self._points = PointCloudChannel("/k1/points")
self._pose = PoseInFrameChannel("/k1/pose")
self._trajectory = SceneUpdateChannel("/k1/trajectory")
self._metrics = Channel(
"/k1/metrics",
schema={
"type": "object",
"properties": {
"mqtt_to_publish_ms": {"type": ["number", "null"]},
"mqtt_to_publish_p50_ms": {"type": ["number", "null"]},
"mqtt_to_publish_p95_ms": {"type": ["number", "null"]},
"decode_publish_ms": {"type": ["number", "null"]},
"point_count": {"type": "integer"},
"pcl_fps": {"type": "number"},
"pose_fps": {"type": "number"},
"preview_dropped": {"type": "integer"},
},
},
)
self._path: deque[tuple[float, float, float]] = deque(maxlen=MAX_TRAJECTORY_POSES)
self._last_trajectory_publish_ns = 0
self._last_point_count = 0
self._closed = False
@property
def port(self) -> int:
return int(self._server.port)
@property
def websocket_url(self) -> str:
return f"ws://127.0.0.1:{self.port}"
@property
def viewer_url(self) -> str:
return self._server.app_url() or "https://app.foxglove.dev/"
def process(self, message: StreamMessage) -> None:
started_ns = time.monotonic_ns()
self.metrics.received(len(message.payload))
try:
if message.topic.endswith("/lio_pcl"):
self._publish_lio_pcl(decode_lio_pcl(message.payload), message)
elif message.topic == "RealtimePointcloud":
self._publish_legacy_pcl(decode_legacy_pointcloud(message.payload), message)
elif message.topic.endswith("/lio_pose"):
self._publish_lio_pose(decode_lio_pose(message.payload), message)
elif message.topic == "RealtimePath":
self._publish_legacy_pose(decode_legacy_pose(message.payload), message)
else:
return
except StreamDecodeError:
self.metrics.decode_error()
return
published_ns = time.monotonic_ns()
decode_publish_ms = (published_ns - started_ns) / 1_000_000
if message.topic.endswith("/lio_pcl") or message.topic == "RealtimePointcloud":
self.metrics.published_pcl(self._last_point_count, published_ns, decode_publish_ms)
else:
self.metrics.published_pose(published_ns, decode_publish_ms, len(self._path))
if message.received_monotonic_ns is not None:
self.metrics.record_latency((published_ns - message.received_monotonic_ns) / 1_000_000)
snapshot = self.metrics.snapshot()
self._metrics.log(
{
"mqtt_to_publish_ms": snapshot["mqtt_to_publish_ms"],
"mqtt_to_publish_p50_ms": snapshot["mqtt_to_publish_p50_ms"],
"mqtt_to_publish_p95_ms": snapshot["mqtt_to_publish_p95_ms"],
"decode_publish_ms": snapshot["decode_publish_ms"],
"point_count": snapshot["last_point_count"],
"pcl_fps": snapshot["pcl_fps"],
"pose_fps": snapshot["pose_fps"],
"preview_dropped": snapshot["preview_dropped"],
},
log_time=message.received_at_epoch_ns,
)
def close(self) -> None:
if self._closed:
return
self._closed = True
for channel in (self._points, self._pose, self._trajectory, self._metrics):
channel.close()
self._server.stop()
def _publish_lio_pcl(self, frame: LioPointCloudFrame, message: StreamMessage) -> None:
packed = pack_lio_point_cloud(frame)
self._publish_point_cloud(packed, message)
def _publish_legacy_pcl(
self,
frame: LegacyPointCloudFrame,
message: StreamMessage,
) -> None:
packed = pack_legacy_point_cloud(frame)
self._publish_point_cloud(packed, message)
def _publish_point_cloud(self, packed: PackedPointCloud, message: StreamMessage) -> None:
timestamp = _timestamp(message.received_at_epoch_ns)
self._points.log(
PointCloud(
timestamp=timestamp,
frame_id=FRAME_ID,
pose=_identity_pose(),
point_stride=POINT_STRIDE,
fields=_point_fields(),
data=packed.data,
),
log_time=message.received_at_epoch_ns,
)
self._last_point_count = packed.point_count
def _publish_lio_pose(self, frame: LioPoseFrame, message: StreamMessage) -> None:
self._publish_pose(frame.position_xyz, frame.orientation_xyzw, message)
def _publish_legacy_pose(self, frame: LegacyPoseFrame, message: StreamMessage) -> None:
self._publish_pose(frame.position_xyz, frame.orientation_xyzw, message)
def _publish_pose(
self,
position_xyz: tuple[float, float, float],
orientation_xyzw: tuple[float, float, float, float],
message: StreamMessage,
) -> None:
pose = Pose(
position=Vector3(x=position_xyz[0], y=position_xyz[1], z=position_xyz[2]),
orientation=Quaternion(
x=orientation_xyzw[0],
y=orientation_xyzw[1],
z=orientation_xyzw[2],
w=orientation_xyzw[3],
),
)
timestamp = _timestamp(message.received_at_epoch_ns)
self._pose.log(
PoseInFrame(timestamp=timestamp, frame_id=FRAME_ID, pose=pose),
log_time=message.received_at_epoch_ns,
)
self._path.append(position_xyz)
now_ns = time.monotonic_ns()
if (
len(self._path) > 2
and len(self._path) % 20
and now_ns - self._last_trajectory_publish_ns < 200_000_000
):
return
self._last_trajectory_publish_ns = now_ns
line_points = [Point3(x=item[0], y=item[1], z=item[2]) for item in self._path]
self._trajectory.log(
SceneUpdate(
entities=[
SceneEntity(
timestamp=timestamp,
frame_id=FRAME_ID,
id="k1-trajectory",
frame_locked=True,
lines=[
LinePrimitive(
type=LinePrimitiveLineType.LineStrip,
thickness=3.0,
scale_invariant=True,
points=line_points,
color=Color(r=0.08, g=0.82, b=1.0, a=1.0),
)
],
)
]
),
log_time=message.received_at_epoch_ns,
)
def pack_lio_point_cloud(frame: LioPointCloudFrame) -> PackedPointCloud:
data = bytearray(len(frame.points) * POINT_STRIDE)
scaler = frame.header.scaler
for index, point in enumerate(frame.points):
x, y, z = point.scaled_xyz(scaler)
POINT_STRUCT.pack_into(data, index * POINT_STRIDE, x, y, z, point.intensity)
return PackedPointCloud(data=bytes(data), point_count=len(frame.points))
def pack_legacy_point_cloud(frame: LegacyPointCloudFrame) -> PackedPointCloud:
data = bytearray(len(frame.points) * POINT_STRIDE)
for index, point in enumerate(frame.points):
POINT_STRUCT.pack_into(
data,
index * POINT_STRIDE,
point.x,
point.y,
point.z,
point.intensity,
)
return PackedPointCloud(data=bytes(data), point_count=len(frame.points))
def _point_fields() -> list[PackedElementField]:
return [
PackedElementField(name="x", offset=0, type=PackedElementFieldNumericType.Float32),
PackedElementField(name="y", offset=4, type=PackedElementFieldNumericType.Float32),
PackedElementField(name="z", offset=8, type=PackedElementFieldNumericType.Float32),
PackedElementField(
name="intensity",
offset=12,
type=PackedElementFieldNumericType.Uint8,
),
]
def _identity_pose() -> Pose:
return Pose(position=Vector3(), orientation=Quaternion(w=1.0))
def _timestamp(epoch_ns: int) -> Timestamp:
return Timestamp(epoch_ns // 1_000_000_000, epoch_ns % 1_000_000_000)
@@ -0,0 +1,15 @@
from __future__ import annotations
from dataclasses import dataclass
@dataclass(frozen=True, slots=True)
class StreamMessage:
"""One MQTT message entering the derived visualization pipeline."""
sequence: int
topic: str
payload: bytes
received_at_epoch_ns: int
received_monotonic_ns: int | None = None
source: str = "replay"
@@ -0,0 +1,170 @@
from __future__ import annotations
import json
import time
from collections.abc import Generator, Iterator
from decimal import Decimal, InvalidOperation
from pathlib import Path
from typing import IO, Literal
from k1link.device_plugins.xgrids_k1.mqtt import CaptureFormatError, iter_capture_frames
from k1link.device_plugins.xgrids_k1.mqtt.capture import RAW_MAGIC
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
MAX_REPLAY_PAYLOAD_BYTES = 2 * 1024 * 1024
MAX_LEGACY_LINE_BYTES = MAX_REPLAY_PAYLOAD_BYTES * 2 + 64 * 1024
MAX_METADATA_LINE_CHARS = 1024 * 1024
ReplayFormat = Literal["k1mqtt", "legacy_tsv"]
class ReplayFormatError(ValueError):
"""A replay input is corrupt or outside the reviewed bounds."""
def detect_replay_format(path: Path) -> ReplayFormat:
resolved = path.expanduser()
with resolved.open("rb") as stream:
prefix = stream.read(len(RAW_MAGIC))
if prefix == RAW_MAGIC:
return "k1mqtt"
if resolved.suffix.casefold() == ".tsv":
return "legacy_tsv"
raise ReplayFormatError("input is neither a K1MQTT capture nor the reviewed legacy TSV")
def iter_replay_messages(path: Path) -> Generator[StreamMessage, None, None]:
"""Yield bounded messages from native evidence or the one reviewed TSV export."""
resolved = path.expanduser().resolve()
replay_format = detect_replay_format(resolved)
if replay_format == "legacy_tsv":
yield from _iter_legacy_tsv(resolved)
return
yield from _iter_native_capture(resolved)
def _iter_native_capture(path: Path) -> Iterator[StreamMessage]:
metadata_path = path.with_name("mqtt.metadata.jsonl")
metadata_stream: IO[str] | None = None
if metadata_path.is_file():
metadata_stream = metadata_path.open("r", encoding="utf-8")
fallback_epoch_ns = time.time_ns()
try:
for frame in iter_capture_frames(path, max_payload_bytes=MAX_REPLAY_PAYLOAD_BYTES):
epoch_ns = fallback_epoch_ns + frame.sequence - 1
monotonic_ns: int | None = None
if metadata_stream is not None:
timing = _read_native_timing(
metadata_stream,
expected_sequence=frame.sequence,
fallback_epoch_ns=epoch_ns,
)
if timing is None:
# A crash may leave one raw frame ahead of the last fully
# committed metadata line. Only the aligned prefix has a
# trustworthy source timeline and is safe to replay.
return
epoch_ns, monotonic_ns = timing
yield StreamMessage(
sequence=frame.sequence,
topic=frame.topic,
payload=frame.payload,
received_at_epoch_ns=epoch_ns,
received_monotonic_ns=monotonic_ns,
source="k1mqtt",
)
except CaptureFormatError as exc:
raise ReplayFormatError(str(exc)) from exc
finally:
if metadata_stream is not None:
metadata_stream.close()
def _read_native_timing(
stream: IO[str],
*,
expected_sequence: int,
fallback_epoch_ns: int,
) -> tuple[int, int | None] | None:
line = stream.readline(MAX_METADATA_LINE_CHARS + 1)
if not line:
return None
if len(line) > MAX_METADATA_LINE_CHARS:
raise ReplayFormatError("native metadata line exceeds the reviewed bound")
try:
record = json.loads(line)
except json.JSONDecodeError as exc:
if not line.endswith(("\n", "\r")):
# A non-newline final tail is the only tolerated corruption: the
# writer may have crashed between raw and metadata group commits.
return None
raise ReplayFormatError(
f"native metadata line {expected_sequence} is not valid JSON"
) from exc
if record.get("record_type") != "message" or record.get("sequence") != expected_sequence:
raise ReplayFormatError(f"native metadata is not aligned at message {expected_sequence}")
epoch_ns = record.get("received_at_epoch_ns", fallback_epoch_ns)
monotonic_ns = record.get("received_monotonic_ns")
if not isinstance(epoch_ns, int) or epoch_ns < 0:
raise ReplayFormatError("native metadata received_at_epoch_ns is invalid")
if monotonic_ns is not None and (not isinstance(monotonic_ns, int) or monotonic_ns < 0):
raise ReplayFormatError("native metadata received_monotonic_ns is invalid")
return epoch_ns, monotonic_ns
def _iter_legacy_tsv(path: Path) -> Iterator[StreamMessage]:
with path.open("rb") as stream:
line_number = 0
while True:
raw_line = stream.readline(MAX_LEGACY_LINE_BYTES + 1)
if not raw_line:
return
line_number += 1
if len(raw_line) > MAX_LEGACY_LINE_BYTES:
raise ReplayFormatError(
f"legacy TSV line {line_number} exceeds {MAX_LEGACY_LINE_BYTES} bytes"
)
stripped = raw_line.rstrip(b"\r\n")
if not stripped:
continue
columns = stripped.split(b"\t")
if len(columns) != 4:
raise ReplayFormatError(
f"legacy TSV line {line_number} must contain exactly four columns"
)
timestamp_raw, topic_raw, length_raw, payload_hex = columns
try:
timestamp = Decimal(timestamp_raw.decode("ascii"))
declared_length = int(length_raw.decode("ascii"), 10)
topic = topic_raw.decode("utf-8")
except (InvalidOperation, UnicodeDecodeError, ValueError) as exc:
raise ReplayFormatError(
f"legacy TSV line {line_number} has invalid timestamp/topic/length"
) from exc
if not timestamp.is_finite() or timestamp < 0:
raise ReplayFormatError(
f"legacy TSV line {line_number} timestamp is outside bounds"
)
if not topic:
raise ReplayFormatError(f"legacy TSV line {line_number} has an empty topic")
if declared_length < 0 or declared_length > MAX_REPLAY_PAYLOAD_BYTES:
raise ReplayFormatError(
f"legacy TSV line {line_number} payload length is outside bounds"
)
if len(payload_hex) != declared_length * 2:
raise ReplayFormatError(
f"legacy TSV line {line_number} declared payload length does not match hex"
)
try:
payload = bytes.fromhex(payload_hex.decode("ascii"))
except (UnicodeDecodeError, ValueError) as exc:
raise ReplayFormatError(
f"legacy TSV line {line_number} payload is not valid hex"
) from exc
epoch_ns = int(timestamp * Decimal(1_000_000_000))
yield StreamMessage(
sequence=line_number,
topic=topic,
payload=payload,
received_at_epoch_ns=epoch_ns,
source="legacy_tsv",
)
@@ -0,0 +1,569 @@
from __future__ import annotations
import math
import queue
import threading
import time
from collections.abc import Callable
from datetime import UTC, datetime
from pathlib import Path
from typing import Literal, Protocol, TypedDict
from k1link.artifacts import utc_now_iso, write_json_atomic
from k1link.data_plane import DecodedDataPlaneView, NormalizationError
from k1link.device_plugins.xgrids_k1.mqtt import CapturedMqttMessage, CaptureError, capture_mqtt
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
from k1link.viewer.metrics import BridgeMetrics, MetricsSnapshot
from k1link.viewer.rerun_bridge import DEFAULT_GRPC_PORT, RerunBridge, RerunSceneSettings
RuntimePhase = Literal[
"idle",
"starting_live",
"live",
"replay",
"stopping",
"error",
]
SourceMode = Literal["idle", "live", "replay"]
StateCallback = Callable[[], None]
BridgeFactory = Callable[..., RerunBridge]
# TODO: replace the mixed-modality FIFO with a latest point-cloud slot and a
# bounded pose queue. The compact queue protects acquisition from a slow
# visualizer, but under sustained pressure it can still evict pose messages.
PREVIEW_QUEUE_SIZE = 4
class CanonicalNormalizer(Protocol):
def __call__(
self,
message: StreamMessage,
*,
processing_started_monotonic_ns: int,
) -> DecodedDataPlaneView | None: ...
class RuntimeSnapshot(TypedDict):
phase: RuntimePhase
message: str
source_mode: SourceMode
source_ready: bool
foxglove_ws_url: str | None
foxglove_viewer_url: str | None
rerun_grpc_url: str | None
viewer_settings: dict[str, object]
metrics: MetricsSnapshot
class VisualizationRuntime:
"""Own one bounded live/replay source and one deterministic publisher thread."""
def __init__(
self,
*,
on_state_change: StateCallback | None = None,
grpc_port: int = DEFAULT_GRPC_PORT,
bridge_factory: BridgeFactory | None = None,
normalizer: CanonicalNormalizer,
) -> None:
self._lock = threading.Lock()
self._on_state_change = on_state_change
self._thread: threading.Thread | None = None
self._stop_event = threading.Event()
self._phase: RuntimePhase = "idle"
self._message = "Готово. Включите устройство и начните с поиска по Bluetooth."
self._source_mode: SourceMode = "idle"
self._source_ready = False
self._foxglove_ws_url: str | None = None
self._foxglove_viewer_url: str | None = None
self._rerun_grpc_url: str | None = None
self._grpc_port = grpc_port
self._bridge_factory = bridge_factory or RerunBridge
self._normalizer = normalizer
self._bridge: RerunBridge | None = None
self._closed = False
self._scene_settings = RerunSceneSettings()
self._metrics = BridgeMetrics()
def snapshot(self) -> RuntimeSnapshot:
with self._lock:
return {
"phase": self._phase,
"message": self._message,
"source_mode": self._source_mode,
"source_ready": self._source_ready,
"foxglove_ws_url": self._foxglove_ws_url,
"foxglove_viewer_url": self._foxglove_viewer_url,
"rerun_grpc_url": self._rerun_grpc_url,
"viewer_settings": self._scene_settings.as_dict(),
"metrics": self._metrics.snapshot(),
}
def update_scene_settings(self, settings: RerunSceneSettings) -> RuntimeSnapshot:
with self._lock:
self._scene_settings = settings
self._notify()
return self.snapshot()
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
resolved = path.expanduser().resolve()
if not resolved.is_file():
raise ValueError("файл записи не найден на этом компьютере")
if not math.isfinite(speed) or speed < 0:
raise ValueError("скорость повтора должна быть неотрицательным числом")
# Validate the reviewed shape before changing runtime state.
iterator = iter_replay_messages(resolved)
try:
next(iterator)
except StopIteration as exc:
raise ValueError("в записи нет сообщений") from exc
finally:
iterator.close()
self._start(
source_mode="replay",
phase="replay",
message=f"Запускаем повтор записи: {resolved.name}",
target=lambda: self._run_replay(resolved, speed=speed, loop=loop),
)
def start_live(
self,
host: str,
out_dir: Path,
*,
duration_seconds: float = 3600.0,
) -> None:
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
raise ValueError("длительность приёма должна быть больше нуля")
self._start(
source_mode="live",
phase="starting_live",
message="Запускаем приём MQTT и локальный мост визуализации.",
target=lambda: self._run_live(
host,
out_dir.expanduser().resolve(),
duration_seconds=duration_seconds,
),
)
def stop(self, *, wait_seconds: float = 5.0) -> None:
notify_only = False
with self._lock:
thread = self._thread
if thread is None or not thread.is_alive():
self._phase = "idle"
self._source_mode = "idle"
self._source_ready = False
self._message = "Активного потока нет."
notify_only = True
else:
self._phase = "stopping"
self._message = "Останавливаем поток и сохраняем полученные данные."
self._stop_event.set()
self._notify()
if notify_only:
return
assert thread is not None
thread.join(timeout=wait_seconds)
if thread.is_alive():
raise RuntimeError("поток не завершился за отведённое время; повторите остановку")
def close(self, *, wait_seconds: float = 5.0) -> None:
"""Stop the active source and release the process-wide visual bridge."""
with self._lock:
self._closed = True
thread = self._thread
if thread is not None and thread.is_alive():
self._phase = "stopping"
self._message = "Завершаем локальный поток и визуальный мост."
self._stop_event.set()
else:
self._phase = "idle"
self._source_mode = "idle"
self._source_ready = False
self._message = "Локальный поток завершён."
self._notify()
if thread is not None and thread.is_alive():
thread.join(timeout=wait_seconds)
with self._lock:
thread_alive = self._thread is not None and self._thread.is_alive()
bridge = None if thread_alive else self._bridge
if not thread_alive:
self._bridge = None
self._rerun_grpc_url = None
if bridge is not None:
try:
bridge.close()
except BaseException as exc:
self._finish_error(
f"Ошибка завершения визуального моста: {type(exc).__name__}: {exc}"
)
raise
self._notify()
def _start(
self,
*,
source_mode: SourceMode,
phase: RuntimePhase,
message: str,
target: Callable[[], None],
) -> None:
with self._lock:
if self._closed:
raise RuntimeError("runtime завершён; перезапустите локальный сервер")
if self._thread is not None and self._thread.is_alive():
raise RuntimeError("поток уже запущен; сначала остановите текущую сессию")
self._stop_event = threading.Event()
self._metrics = BridgeMetrics()
self._phase = phase
self._source_mode = source_mode
self._source_ready = False
self._message = message
self._foxglove_ws_url = None
self._foxglove_viewer_url = None
self._thread = threading.Thread(
target=lambda: self._run_target_safely(target, source_mode),
name=f"k1-{source_mode}-session",
daemon=True,
)
self._thread.start()
self._notify()
def _run_target_safely(
self,
target: Callable[[], None],
source_mode: SourceMode,
) -> None:
"""Convert setup failures before the pipeline into observable runtime state."""
try:
target()
except BaseException as exc:
with self._lock:
closed = self._closed
if closed:
return
self._finish_error(f"Ошибка {source_mode}-источника: {type(exc).__name__}: {exc}")
def _run_replay(self, path: Path, *, speed: float, loop: bool) -> None:
def produce(put: Callable[[StreamMessage], None]) -> str:
while not self._stop_event.is_set():
first_source_ns: int | None = None
replay_started_ns = time.monotonic_ns()
count = 0
for message in iter_replay_messages(path):
if self._stop_event.is_set():
return "Повтор записи остановлен."
source_ns = (
message.received_monotonic_ns
if message.received_monotonic_ns is not None
else message.received_at_epoch_ns
)
if first_source_ns is None:
first_source_ns = source_ns
if speed > 0:
target_ns = replay_started_ns + int((source_ns - first_source_ns) / speed)
remaining = (target_ns - time.monotonic_ns()) / 1_000_000_000
if remaining > 0 and self._stop_event.wait(remaining):
return "Повтор записи остановлен."
put(message)
count += 1
if not loop:
return f"Повтор завершён: обработано сообщений MQTT — {count}."
return "Повтор записи остановлен."
self._run_pipeline(produce, running_phase="replay")
def _run_live(self, host: str, out_dir: Path, *, duration_seconds: float) -> None:
_write_live_session_preamble(out_dir, host, duration_seconds)
def produce(put: Callable[[StreamMessage], None]) -> str:
def on_message(message: CapturedMqttMessage) -> None:
put(
StreamMessage(
sequence=message.sequence,
topic=message.topic,
payload=message.payload,
received_at_epoch_ns=message.received_at_epoch_ns,
received_monotonic_ns=message.received_monotonic_ns,
source="live_mqtt",
)
)
summary = capture_mqtt(
host,
out_dir / "captures" / "mqtt_live",
duration_seconds=duration_seconds,
on_ready=lambda: self._set_running(
"live",
"Приём запущен. Теперь дважды нажмите физическую кнопку устройства.",
),
on_message_recorded=on_message,
should_stop=self._stop_event.is_set,
)
return f"Приём остановлен. Сохранено сообщений: {summary['message_count']}."
try:
self._run_pipeline(produce, running_phase="live")
finally:
_finalize_live_session(out_dir, self.snapshot())
def _run_pipeline(
self,
producer: Callable[[Callable[[StreamMessage], None]], str],
*,
running_phase: RuntimePhase,
) -> None:
messages: queue.Queue[StreamMessage] = queue.Queue(maxsize=PREVIEW_QUEUE_SIZE)
source_done = threading.Event()
publisher_ready = threading.Event()
publisher_aborted = threading.Event()
publisher_error: list[BaseException] = []
def enqueue(message: StreamMessage) -> None:
try:
messages.put_nowait(message)
return
except queue.Full:
pass
try:
messages.get_nowait()
messages.task_done()
except queue.Empty:
pass
self._metrics.preview_dropped()
try:
messages.put_nowait(message)
except queue.Full:
self._metrics.preview_dropped()
def publish() -> None:
bridge: RerunBridge | None = None
try:
with self._lock:
bridge = self._bridge
if self._closed:
publisher_aborted.set()
if publisher_aborted.is_set():
publisher_ready.set()
return
if bridge is None:
candidate = self._bridge_factory(
grpc_port=self._grpc_port,
metrics=self._metrics,
settings_provider=self._current_scene_settings,
)
bridge = candidate
with self._lock:
if self._closed:
publisher_aborted.set()
else:
self._bridge = candidate
if publisher_aborted.is_set():
publisher_ready.set()
return
assert bridge is not None
bridge.begin_session(self._metrics)
with self._lock:
if self._closed:
publisher_aborted.set()
else:
self._rerun_grpc_url = bridge.grpc_url
if running_phase == "replay" and not self._closed and self._phase != "stopping":
self._phase = running_phase
self._source_ready = True
self._message = "Локальный Rerun-мост готов; источник данных запущен."
publisher_ready.set()
self._notify()
if publisher_aborted.is_set():
return
while not source_done.is_set() or not messages.empty():
if self._stop_event.is_set() and source_done.is_set() and messages.empty():
break
try:
message = messages.get(timeout=0.1)
except queue.Empty:
continue
try:
processing_started_ns = time.monotonic_ns()
self._metrics.received(len(message.payload))
try:
envelope = self._normalizer(
message,
processing_started_monotonic_ns=processing_started_ns,
)
except NormalizationError:
self._metrics.decode_error()
else:
if envelope is not None:
bridge.process(envelope)
finally:
messages.task_done()
if self._metrics.snapshot()["messages_received"] % 10 == 0:
self._notify()
except BaseException as exc:
with self._lock:
closed = self._closed
if not closed:
publisher_error.append(exc)
else:
publisher_aborted.set()
publisher_ready.set()
self._stop_event.set()
finally:
if bridge is not None:
with self._lock:
close_bridge = self._closed and (
self._bridge is bridge or publisher_aborted.is_set()
)
if close_bridge and self._bridge is bridge:
self._bridge = None
self._rerun_grpc_url = None
if close_bridge:
try:
bridge.close()
except BaseException as exc:
# Process shutdown must not become a false successful
# idle state when the native bridge failed to close.
publisher_error.append(exc)
def join_publisher() -> None:
# Never orphan a publisher: the session thread remains its owner.
# On process shutdown both are daemon threads, so an irrecoverably
# blocked native call cannot prevent the operating system from exit.
while publisher.is_alive():
publisher.join(timeout=0.25)
publisher = threading.Thread(target=publish, name="k1-rerun-publisher", daemon=True)
publisher.start()
if not publisher_ready.wait(timeout=15.0):
self._finish_error("Локальный мост визуализации не запустился за 15 секунд.")
source_done.set()
self._stop_event.set()
join_publisher()
return
if publisher_aborted.is_set():
source_done.set()
self._stop_event.set()
join_publisher()
return
if publisher_error:
self._finish_error(
"Ошибка локального моста визуализации: "
f"{type(publisher_error[0]).__name__}: {publisher_error[0]}"
)
source_done.set()
join_publisher()
return
final_message = "Поток остановлен."
try:
final_message = producer(enqueue)
except CaptureError as exc:
self._finish_error(f"Ошибка приёма MQTT: {exc}")
except (OSError, RuntimeError, ValueError) as exc:
self._finish_error(f"Ошибка источника: {type(exc).__name__}: {exc}")
finally:
source_done.set()
join_publisher()
if publisher_error:
self._finish_error(
f"Ошибка публикации: {type(publisher_error[0]).__name__}: {publisher_error[0]}"
)
elif self.snapshot()["phase"] != "error":
self._finish_idle(final_message)
def _set_running(self, phase: RuntimePhase, message: str) -> None:
with self._lock:
if self._phase != "stopping":
self._phase = phase
self._source_ready = True
self._message = message
self._notify()
def _finish_idle(self, message: str) -> None:
with self._lock:
self._phase = "idle"
self._source_mode = "idle"
self._source_ready = False
self._message = message
self._foxglove_ws_url = None
self._foxglove_viewer_url = None
self._notify()
def _finish_error(self, message: str) -> None:
with self._lock:
self._phase = "error"
self._source_ready = False
self._message = message
self._foxglove_ws_url = None
self._foxglove_viewer_url = None
self._notify()
def _current_scene_settings(self) -> RerunSceneSettings:
with self._lock:
return self._scene_settings
def _notify(self) -> None:
callback = self._on_state_change
if callback is not None:
callback()
def new_live_session_dir(sessions_root: Path) -> Path:
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
base = sessions_root / f"{stamp}_viewer_live"
candidate = base
suffix = 1
while candidate.exists():
suffix += 1
candidate = base.with_name(f"{base.name}_{suffix:02d}")
return candidate
def _write_live_session_preamble(out_dir: Path, host: str, duration_seconds: float) -> None:
out_dir.mkdir(parents=True, exist_ok=False)
started_at_utc = utc_now_iso()
write_json_atomic(
out_dir / "manifest.redacted.json",
{
"schema_version": 1,
"started_at_utc": started_at_utc,
"started_monotonic_ns": time.monotonic_ns(),
"operation": "k1_live_mqtt_to_rerun",
"target": "owner-controlled K1 at redacted RFC1918 address",
"requested_duration_seconds": duration_seconds,
"raw_capture": "captures/mqtt_live/mqtt.raw.k1mqtt",
"credential_storage": "none",
},
)
notes = (
"# K1 live visualization session\n\n"
f"Started UTC: {started_at_utc}\n\n"
"The local control API started a read-only MQTT subscription and Rerun "
"preview. Raw MQTT evidence is written before preview decoding. The target "
f"was a validated private IPv4 address ({host.rsplit('.', 1)[0]}.x).\n"
)
notes_path = out_dir / "operator-notes.md"
notes_path.write_text(notes, encoding="utf-8")
notes_path.chmod(0o600)
def _finalize_live_session(out_dir: Path, snapshot: RuntimeSnapshot) -> None:
manifest_path = out_dir / "manifest.redacted.json"
try:
import json
payload = json.loads(manifest_path.read_text(encoding="utf-8"))
payload["completed_at_utc"] = utc_now_iso()
payload["completed_monotonic_ns"] = time.monotonic_ns()
payload["final_phase"] = snapshot["phase"]
payload["aggregate_metrics"] = snapshot["metrics"]
write_json_atomic(manifest_path, payload)
except (OSError, ValueError):
# The MQTT capture summary remains authoritative if final annotation fails.
return