feat(plugins): isolate device integrations
This commit is contained in:
@@ -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
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user