feat(perception): integrate calibrated operator pipeline
Add calibrated K1 projection, recorded and near-live perception qualification, unified Rerun operator layers, bounded replay admission, audited viewer controls, worker experiments, and lab evidence.
This commit is contained in:
@@ -1,15 +1,51 @@
|
||||
"""Bounded, offline analysis of sensitive K1 evidence artifacts."""
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_overlay import (
|
||||
CalibratedOverlayExperiment,
|
||||
CalibratedOverlayExperimentError,
|
||||
run_calibrated_overlay_experiment,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
CalibratedProjectionError,
|
||||
Kb4ProjectionProfile,
|
||||
ProjectedPointCloud,
|
||||
depth_colors,
|
||||
map_points_to_lidar,
|
||||
project_map_points_kb4,
|
||||
quaternion_xyzw_to_rotation_matrix,
|
||||
)
|
||||
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,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.analyze.valid_fov import (
|
||||
DEFAULT_EDGE_MARGIN_PIXELS,
|
||||
K1ValidFovMask,
|
||||
K1ValidFovMaskError,
|
||||
prepare_k1_valid_fov_mask,
|
||||
validate_k1_valid_fov_mask,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"CalibratedOverlayExperiment",
|
||||
"CalibratedOverlayExperimentError",
|
||||
"CalibratedProjectionError",
|
||||
"DEFAULT_EDGE_MARGIN_PIXELS",
|
||||
"DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES",
|
||||
"Kb4ProjectionProfile",
|
||||
"K1ValidFovMask",
|
||||
"K1ValidFovMaskError",
|
||||
"MAX_STREAM_SUMMARY_PAYLOAD_BYTES",
|
||||
"ProjectedPointCloud",
|
||||
"StreamSummary",
|
||||
"depth_colors",
|
||||
"map_points_to_lidar",
|
||||
"project_map_points_kb4",
|
||||
"prepare_k1_valid_fov_mask",
|
||||
"quaternion_xyzw_to_rotation_matrix",
|
||||
"run_calibrated_overlay_experiment",
|
||||
"summarize_mqtt_streams",
|
||||
"validate_k1_valid_fov_mask",
|
||||
]
|
||||
|
||||
@@ -0,0 +1,950 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import subprocess
|
||||
import threading
|
||||
import time
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
import rerun as rr
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
Kb4ProjectionProfile,
|
||||
ProjectedPointCloud,
|
||||
depth_colors,
|
||||
project_map_points_kb4,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.calibration_schema import (
|
||||
K1FactoryCalibration,
|
||||
parse_k1_factory_calibration,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.calibration_snapshot import (
|
||||
CALIBRATION_SNAPSHOT_MANIFEST_VERSION,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import (
|
||||
read_capture_clock_envelope,
|
||||
read_capture_clock_origin,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.streams import (
|
||||
LioPointCloudFrame,
|
||||
LioPoseFrame,
|
||||
decode_lio_pcl,
|
||||
decode_lio_pose,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.viewer.replay import iter_replay_messages
|
||||
from k1link.sessions import inspect_recorded_media_epoch
|
||||
|
||||
CALIBRATED_OVERLAY_SCHEMA = "missioncore.k1-calibrated-overlay-experiment/v1"
|
||||
CALIBRATED_OVERLAY_SUFFIX = "k1_calibrated_overlay"
|
||||
MAX_JSON_BYTES = 2 * 1024 * 1024
|
||||
MAX_INDEX_LINE_BYTES = 64 * 1024
|
||||
MAX_OPERATOR_NOTES_BYTES = 64 * 1024
|
||||
FFMPEG_TIMEOUT_SECONDS = 180.0
|
||||
MAX_SYNC_DELTA_SECONDS = 0.25
|
||||
_READ_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
RgbImage = npt.NDArray[np.uint8]
|
||||
|
||||
|
||||
class CalibratedOverlayExperimentError(RuntimeError):
|
||||
"""Raised when a recorded calibrated-overlay experiment cannot be sealed."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalibratedOverlayExperiment:
|
||||
experiment_id: str
|
||||
experiment_root: Path
|
||||
manifest_path: Path
|
||||
rerun_path: Path
|
||||
mosaic_path: Path
|
||||
frame_count: int
|
||||
source_id: str
|
||||
calibration_content_identity: str
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _CameraFrameAnchor:
|
||||
requested_video_offset_seconds: float
|
||||
sequence: int
|
||||
session_time_seconds: float
|
||||
host_epoch_ns: int
|
||||
host_monotonic_ns: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _LidarSample:
|
||||
camera: _CameraFrameAnchor
|
||||
point_session_time_seconds: float
|
||||
pose_session_time_seconds: float
|
||||
point_frame: LioPointCloudFrame
|
||||
pose_frame: LioPoseFrame
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _RenderedFrame:
|
||||
camera: _CameraFrameAnchor
|
||||
lidar: _LidarSample
|
||||
image_rgb: RgbImage
|
||||
overlay_rgb: RgbImage
|
||||
projection: ProjectedPointCloud
|
||||
colors_rgb: RgbImage
|
||||
camera_artifact_name: str
|
||||
artifact_name: str
|
||||
|
||||
|
||||
def run_calibrated_overlay_experiment(
|
||||
*,
|
||||
session_root: Path,
|
||||
calibration_snapshot_root: Path,
|
||||
source_id: str,
|
||||
video_offsets_seconds: tuple[float, ...],
|
||||
output_root: Path,
|
||||
ffmpeg_path: Path,
|
||||
temporal_offset_seconds: float = 0.0,
|
||||
) -> CalibratedOverlayExperiment:
|
||||
"""Seal a derived LiDAR→KB4→camera diagnostic without changing native evidence."""
|
||||
|
||||
started_monotonic_ns = time.monotonic_ns()
|
||||
session = session_root.expanduser().resolve(strict=True)
|
||||
if not session.is_dir() or session.name in {"", ".", ".."}:
|
||||
raise CalibratedOverlayExperimentError("session root is invalid")
|
||||
if not video_offsets_seconds:
|
||||
raise CalibratedOverlayExperimentError("at least one video offset is required")
|
||||
if len(video_offsets_seconds) > 16:
|
||||
raise CalibratedOverlayExperimentError("at most sixteen diagnostic frames are allowed")
|
||||
offsets = tuple(sorted(video_offsets_seconds))
|
||||
if len(set(offsets)) != len(offsets) or any(
|
||||
not math.isfinite(value) or value < 0.0 for value in offsets
|
||||
):
|
||||
raise CalibratedOverlayExperimentError(
|
||||
"video offsets must be unique finite non-negative seconds"
|
||||
)
|
||||
if not math.isfinite(temporal_offset_seconds) or abs(temporal_offset_seconds) > 5.0:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
"temporal offset must be finite and inside the reviewed ±5 second window"
|
||||
)
|
||||
|
||||
calibration, calibration_identity = _load_calibration_snapshot(
|
||||
calibration_snapshot_root
|
||||
)
|
||||
profile = Kb4ProjectionProfile.from_factory_calibration(calibration, source_id)
|
||||
capture_root = session / "captures" / "mqtt_live"
|
||||
raw_path = capture_root / "mqtt.raw.k1mqtt"
|
||||
metadata_path = capture_root / "mqtt.metadata.jsonl"
|
||||
summary_path = capture_root / "mqtt.summary.json"
|
||||
clock_origin_path = capture_root / "mqtt.timeline.origin.json"
|
||||
clock_summary = _read_json(summary_path, MAX_JSON_BYTES)
|
||||
clock_name = _required_text(
|
||||
_required_mapping(clock_summary.get("artifacts"), "artifacts").get(
|
||||
"capture_clock"
|
||||
),
|
||||
"capture clock filename",
|
||||
)
|
||||
clock_path = capture_root / clock_name
|
||||
capture_clock = read_capture_clock_envelope(clock_path)
|
||||
capture_origin = read_capture_clock_origin(clock_origin_path)
|
||||
if (
|
||||
capture_clock.started_at_epoch_ns != capture_origin.started_at_epoch_ns
|
||||
or capture_clock.started_monotonic_ns != capture_origin.started_monotonic_ns
|
||||
):
|
||||
raise CalibratedOverlayExperimentError("MQTT capture clock does not match its origin")
|
||||
if not raw_path.is_file() or not metadata_path.is_file():
|
||||
raise CalibratedOverlayExperimentError("sealed MQTT evidence is incomplete")
|
||||
|
||||
camera_epoch_root = session / "media" / source_id / "epoch-1"
|
||||
inspected_epoch = inspect_recorded_media_epoch(
|
||||
camera_epoch_root,
|
||||
expected_source_name=source_id,
|
||||
origin_epoch_ns=capture_origin.started_at_epoch_ns,
|
||||
origin_monotonic_ns=capture_origin.started_monotonic_ns,
|
||||
)
|
||||
camera_duration_seconds = (
|
||||
inspected_epoch.timeline_end_seconds - inspected_epoch.timeline_start_seconds
|
||||
)
|
||||
if offsets[-1] >= camera_duration_seconds:
|
||||
raise CalibratedOverlayExperimentError("a video offset is outside the camera epoch")
|
||||
camera_anchors = _select_camera_anchors(
|
||||
camera_epoch_root / "index.jsonl",
|
||||
expected_count=len(inspected_epoch.segments),
|
||||
offsets_seconds=offsets,
|
||||
timeline_start_seconds=inspected_epoch.timeline_start_seconds,
|
||||
origin_monotonic_ns=capture_origin.started_monotonic_ns,
|
||||
)
|
||||
camera_images = _extract_camera_frames(
|
||||
ffmpeg_path.expanduser().resolve(strict=True),
|
||||
init_path=inspected_epoch.init_path,
|
||||
segment_paths=tuple(segment.path for segment in inspected_epoch.segments),
|
||||
frame_sequences=tuple(anchor.sequence for anchor in camera_anchors),
|
||||
width=profile.width,
|
||||
height=profile.height,
|
||||
)
|
||||
lidar_samples = _select_lidar_samples(
|
||||
raw_path,
|
||||
camera_anchors,
|
||||
origin_monotonic_ns=capture_origin.started_monotonic_ns,
|
||||
temporal_offset_seconds=temporal_offset_seconds,
|
||||
)
|
||||
|
||||
created_at = datetime.now(UTC)
|
||||
experiment_uuid = uuid4().hex
|
||||
experiment_id = (
|
||||
f"{created_at.strftime('%Y%m%dT%H%M%SZ')}_{CALIBRATED_OVERLAY_SUFFIX}_"
|
||||
f"{experiment_uuid[:12]}"
|
||||
)
|
||||
private_root = output_root.expanduser().resolve() / "private" / "perception-experiments"
|
||||
private_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
_chmod_private(private_root)
|
||||
final_root = private_root / experiment_id
|
||||
staging_root = private_root / f".{experiment_id}.incomplete"
|
||||
staging_root.mkdir(mode=0o700, exist_ok=False)
|
||||
published = False
|
||||
try:
|
||||
rendered: list[_RenderedFrame] = []
|
||||
for image_rgb, camera_anchor, lidar_sample in zip(
|
||||
camera_images,
|
||||
camera_anchors,
|
||||
lidar_samples,
|
||||
strict=True,
|
||||
):
|
||||
positions = np.asarray(
|
||||
[
|
||||
point.scaled_xyz(lidar_sample.point_frame.header.scaler)
|
||||
for point in lidar_sample.point_frame.points
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
projection = project_map_points_kb4(
|
||||
positions,
|
||||
position_map_xyz=lidar_sample.pose_frame.position_xyz,
|
||||
orientation_map_from_lidar_xyzw=(
|
||||
lidar_sample.pose_frame.orientation_xyzw
|
||||
),
|
||||
profile=profile,
|
||||
)
|
||||
if projection.projected_point_count == 0:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
f"camera frame {camera_anchor.sequence} has no projected LiDAR points"
|
||||
)
|
||||
colors = depth_colors(projection.depths_m)
|
||||
overlay = _render_overlay(
|
||||
image_rgb,
|
||||
projection,
|
||||
colors,
|
||||
camera_anchor=camera_anchor,
|
||||
lidar_sample=lidar_sample,
|
||||
)
|
||||
artifact_name = (
|
||||
f"frame-{camera_anchor.sequence:06d}-"
|
||||
f"session-{round(camera_anchor.session_time_seconds * 1000):09d}ms.png"
|
||||
)
|
||||
camera_artifact_name = (
|
||||
f"camera-{camera_anchor.sequence:06d}-"
|
||||
f"session-{round(camera_anchor.session_time_seconds * 1000):09d}ms.png"
|
||||
)
|
||||
_write_image_exclusive(staging_root / camera_artifact_name, image_rgb)
|
||||
_write_image_exclusive(staging_root / artifact_name, overlay)
|
||||
rendered.append(
|
||||
_RenderedFrame(
|
||||
camera=camera_anchor,
|
||||
lidar=lidar_sample,
|
||||
image_rgb=image_rgb,
|
||||
overlay_rgb=overlay,
|
||||
projection=projection,
|
||||
colors_rgb=colors,
|
||||
camera_artifact_name=camera_artifact_name,
|
||||
artifact_name=artifact_name,
|
||||
)
|
||||
)
|
||||
|
||||
mosaic_path = staging_root / "overlay-mosaic.png"
|
||||
_write_image_exclusive(mosaic_path, _mosaic(tuple(rendered)))
|
||||
rerun_path = staging_root / "diagnostic.rrd"
|
||||
_write_rerun_diagnostic(
|
||||
rerun_path,
|
||||
experiment_id=experiment_id,
|
||||
rendered=tuple(rendered),
|
||||
calibration_identity=calibration_identity,
|
||||
profile=profile,
|
||||
)
|
||||
operator_notes = _operator_notes(
|
||||
experiment_id=experiment_id,
|
||||
session_id=session.name,
|
||||
source_id=source_id,
|
||||
calibration_identity=calibration_identity,
|
||||
temporal_offset_seconds=temporal_offset_seconds,
|
||||
)
|
||||
if len(operator_notes) > MAX_OPERATOR_NOTES_BYTES:
|
||||
raise CalibratedOverlayExperimentError("operator notes exceed their byte bound")
|
||||
_write_exclusive(staging_root / "operator-notes.md", operator_notes)
|
||||
|
||||
input_identity = {
|
||||
"session_id": session.name,
|
||||
"source_id": source_id,
|
||||
"raw_mqtt_sha256": _verified_raw_sha256(raw_path, clock_summary),
|
||||
"camera_stream_sha256": _required_text(
|
||||
_read_json(camera_epoch_root / "summary.json", MAX_JSON_BYTES).get(
|
||||
"stream_sha256"
|
||||
),
|
||||
"camera stream sha256",
|
||||
),
|
||||
"calibration_content_identity_sha256": calibration_identity,
|
||||
"video_offsets_seconds": list(offsets),
|
||||
"temporal_offset_seconds": temporal_offset_seconds,
|
||||
"projection_contract": "map→inverse(T_map_from_lidar)→T_camera_1_from_lidar→KB4",
|
||||
}
|
||||
generation_sha256 = hashlib.sha256(_canonical_json(input_identity)).hexdigest()
|
||||
frame_documents = [_frame_document(item) for item in rendered]
|
||||
output_names = [
|
||||
*(item.camera_artifact_name for item in rendered),
|
||||
*(item.artifact_name for item in rendered),
|
||||
mosaic_path.name,
|
||||
rerun_path.name,
|
||||
"operator-notes.md",
|
||||
]
|
||||
manifest = {
|
||||
"schema_version": CALIBRATED_OVERLAY_SCHEMA,
|
||||
"experiment_id": experiment_id,
|
||||
"created_at_utc": created_at.isoformat(timespec="milliseconds").replace(
|
||||
"+00:00", "Z"
|
||||
),
|
||||
"completed_monotonic_ns": time.monotonic_ns(),
|
||||
"elapsed_seconds": round(
|
||||
(time.monotonic_ns() - started_monotonic_ns) / 1_000_000_000,
|
||||
6,
|
||||
),
|
||||
"classification": "private-derived-calibration-diagnostic",
|
||||
"generation_sha256": generation_sha256,
|
||||
"input": input_identity,
|
||||
"camera_epoch": {
|
||||
"codec_epoch": 1,
|
||||
"resolution": [profile.width, profile.height],
|
||||
"timeline_start_seconds": inspected_epoch.timeline_start_seconds,
|
||||
"timeline_end_seconds": inspected_epoch.timeline_end_seconds,
|
||||
"synchronization": "host-arrival-best-effort",
|
||||
},
|
||||
"geometry": {
|
||||
"calibration_slot": profile.calibration_slot,
|
||||
"camera_model": "kb4",
|
||||
"transform_notation": "T_destination_from_source",
|
||||
"point_frame": "map",
|
||||
"pose_interpretation": "T_map_from_lidar",
|
||||
"behind_camera_policy": "reject-z-less-than-or-equal-to-zero",
|
||||
"occlusion_policy": "diagnostic-draw-far-to-near",
|
||||
},
|
||||
"frames": frame_documents,
|
||||
"outputs": [_artifact_document(staging_root / name) for name in output_names],
|
||||
"acceptance": {
|
||||
"geometry_input_subgate": "accepted",
|
||||
"diagnostic_layer": "generated",
|
||||
"measured_reprojection": "pending-static-landmark-correspondences",
|
||||
"temporal_calibration": "unverified-host-arrival-only",
|
||||
"p0": "not-yet-accepted",
|
||||
},
|
||||
}
|
||||
_write_exclusive(
|
||||
staging_root / "manifest.redacted.json",
|
||||
json.dumps(
|
||||
manifest,
|
||||
ensure_ascii=False,
|
||||
indent=2,
|
||||
sort_keys=True,
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
+ b"\n",
|
||||
)
|
||||
_fsync_directory(staging_root)
|
||||
os.rename(staging_root, final_root)
|
||||
_fsync_directory(private_root)
|
||||
published = True
|
||||
finally:
|
||||
if not published and staging_root.exists():
|
||||
shutil.rmtree(staging_root)
|
||||
|
||||
return CalibratedOverlayExperiment(
|
||||
experiment_id=experiment_id,
|
||||
experiment_root=final_root,
|
||||
manifest_path=final_root / "manifest.redacted.json",
|
||||
rerun_path=final_root / "diagnostic.rrd",
|
||||
mosaic_path=final_root / "overlay-mosaic.png",
|
||||
frame_count=len(camera_anchors),
|
||||
source_id=source_id,
|
||||
calibration_content_identity=calibration_identity,
|
||||
)
|
||||
|
||||
|
||||
def _load_calibration_snapshot(
|
||||
snapshot_root: Path,
|
||||
) -> tuple[K1FactoryCalibration, str]:
|
||||
root = snapshot_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir():
|
||||
raise CalibratedOverlayExperimentError("calibration snapshot is not a directory")
|
||||
manifest = _read_json(root / "manifest.json", MAX_JSON_BYTES)
|
||||
if manifest.get("schema_version") != CALIBRATION_SNAPSHOT_MANIFEST_VERSION:
|
||||
raise CalibratedOverlayExperimentError("calibration snapshot schema is incompatible")
|
||||
camera_payload = _read_regular(root / "camera.yaml", 64 * 1024)
|
||||
extrinsic_payload = _read_regular(root / "extrinsic_camera_lidar.yaml", 64 * 1024)
|
||||
artifact_digests = {
|
||||
item.get("artifact_name"): item.get("sha256")
|
||||
for item in _required_list(manifest.get("artifacts"), "calibration artifacts")
|
||||
if isinstance(item, dict)
|
||||
}
|
||||
expected = {
|
||||
"camera.yaml": hashlib.sha256(camera_payload).hexdigest(),
|
||||
"extrinsic_camera_lidar.yaml": hashlib.sha256(extrinsic_payload).hexdigest(),
|
||||
}
|
||||
if artifact_digests != expected:
|
||||
raise CalibratedOverlayExperimentError("calibration artifact identity changed")
|
||||
calibration = parse_k1_factory_calibration(camera_payload, extrinsic_payload)
|
||||
if manifest.get("normalized_calibration") != calibration.normalized_profile():
|
||||
raise CalibratedOverlayExperimentError("normalized calibration changed")
|
||||
identity = _required_sha256(
|
||||
manifest.get("content_identity_sha256"),
|
||||
"calibration content identity",
|
||||
)
|
||||
return calibration, identity
|
||||
|
||||
|
||||
def _select_camera_anchors(
|
||||
index_path: Path,
|
||||
*,
|
||||
expected_count: int,
|
||||
offsets_seconds: tuple[float, ...],
|
||||
timeline_start_seconds: float,
|
||||
origin_monotonic_ns: int,
|
||||
) -> tuple[_CameraFrameAnchor, ...]:
|
||||
targets = tuple(timeline_start_seconds + offset for offset in offsets_seconds)
|
||||
best: list[tuple[float, _CameraFrameAnchor] | None] = [None] * len(targets)
|
||||
with index_path.open("rb") as stream:
|
||||
for expected_sequence in range(1, expected_count + 1):
|
||||
line = stream.readline(MAX_INDEX_LINE_BYTES + 1)
|
||||
if not line or len(line) > MAX_INDEX_LINE_BYTES or not line.endswith(b"\n"):
|
||||
raise CalibratedOverlayExperimentError("camera index is incomplete or unbounded")
|
||||
try:
|
||||
entry = json.loads(line)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
"camera index contains invalid JSON"
|
||||
) from exc
|
||||
if (
|
||||
not isinstance(entry, dict)
|
||||
or entry.get("sequence") != expected_sequence
|
||||
or entry.get("kind") != "media"
|
||||
or entry.get("path") != f"segments/{expected_sequence}.m4s"
|
||||
):
|
||||
raise CalibratedOverlayExperimentError("camera index sequence is inconsistent")
|
||||
host_monotonic_ns = entry.get("host_monotonic_ns")
|
||||
host_epoch_ns = entry.get("host_epoch_ns")
|
||||
if (
|
||||
not isinstance(host_monotonic_ns, int)
|
||||
or host_monotonic_ns < origin_monotonic_ns
|
||||
or not isinstance(host_epoch_ns, int)
|
||||
or host_epoch_ns < 0
|
||||
):
|
||||
raise CalibratedOverlayExperimentError("camera index time is invalid")
|
||||
session_time = (host_monotonic_ns - origin_monotonic_ns) / 1_000_000_000
|
||||
for index, (offset, target) in enumerate(zip(offsets_seconds, targets, strict=True)):
|
||||
delta = abs(session_time - target)
|
||||
current = best[index]
|
||||
if current is None or delta < current[0]:
|
||||
best[index] = (
|
||||
delta,
|
||||
_CameraFrameAnchor(
|
||||
requested_video_offset_seconds=offset,
|
||||
sequence=expected_sequence,
|
||||
session_time_seconds=session_time,
|
||||
host_epoch_ns=host_epoch_ns,
|
||||
host_monotonic_ns=host_monotonic_ns,
|
||||
),
|
||||
)
|
||||
if stream.read(1):
|
||||
raise CalibratedOverlayExperimentError("camera index has undeclared rows")
|
||||
anchors_list: list[_CameraFrameAnchor] = []
|
||||
for item in best:
|
||||
if item is None:
|
||||
raise CalibratedOverlayExperimentError("camera offset has no frame")
|
||||
anchors_list.append(item[1])
|
||||
anchors = tuple(anchors_list)
|
||||
if len({item.sequence for item in anchors}) != len(anchors):
|
||||
raise CalibratedOverlayExperimentError("camera offsets do not select unique frames")
|
||||
return anchors
|
||||
|
||||
|
||||
def _extract_camera_frames(
|
||||
ffmpeg_path: Path,
|
||||
*,
|
||||
init_path: Path,
|
||||
segment_paths: tuple[Path, ...],
|
||||
frame_sequences: tuple[int, ...],
|
||||
width: int,
|
||||
height: int,
|
||||
) -> tuple[RgbImage, ...]:
|
||||
if not ffmpeg_path.is_file():
|
||||
raise CalibratedOverlayExperimentError("ffmpeg is unavailable")
|
||||
frame_indices = tuple(sequence - 1 for sequence in frame_sequences)
|
||||
select = "+".join(f"eq(n\\,{index})" for index in frame_indices)
|
||||
read_fd, write_fd = os.pipe()
|
||||
process = subprocess.Popen(
|
||||
[
|
||||
str(ffmpeg_path),
|
||||
"-hide_banner",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-i",
|
||||
"pipe:0",
|
||||
"-vf",
|
||||
f"select={select}",
|
||||
"-fps_mode",
|
||||
"passthrough",
|
||||
"-frames:v",
|
||||
str(len(frame_indices)),
|
||||
"-f",
|
||||
"rawvideo",
|
||||
"-pix_fmt",
|
||||
"rgb24",
|
||||
"pipe:1",
|
||||
],
|
||||
stdin=read_fd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
shell=False,
|
||||
)
|
||||
os.close(read_fd)
|
||||
feeder_errors: list[BaseException] = []
|
||||
|
||||
def feed() -> None:
|
||||
try:
|
||||
with os.fdopen(write_fd, "wb", buffering=0) as sink:
|
||||
for path in (init_path, *segment_paths):
|
||||
with path.open("rb") as source:
|
||||
shutil.copyfileobj(source, sink, length=_READ_CHUNK_BYTES)
|
||||
except BrokenPipeError:
|
||||
return
|
||||
except BaseException as exc: # pragma: no cover - defensive child-pipe boundary
|
||||
feeder_errors.append(exc)
|
||||
|
||||
feeder = threading.Thread(target=feed, name="k1-calibrated-overlay-ffmpeg", daemon=True)
|
||||
feeder.start()
|
||||
try:
|
||||
stdout, stderr = process.communicate(timeout=FFMPEG_TIMEOUT_SECONDS)
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
process.kill()
|
||||
process.communicate()
|
||||
raise CalibratedOverlayExperimentError("ffmpeg frame extraction timed out") from exc
|
||||
feeder.join(timeout=5.0)
|
||||
if feeder.is_alive():
|
||||
raise CalibratedOverlayExperimentError("camera archive feeder did not stop")
|
||||
if feeder_errors:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
"camera archive streaming failed"
|
||||
) from feeder_errors[0]
|
||||
if process.returncode != 0:
|
||||
message = stderr.decode("utf-8", errors="replace").strip()[-1000:]
|
||||
raise CalibratedOverlayExperimentError(
|
||||
f"ffmpeg rejected the sealed camera epoch: {message}"
|
||||
)
|
||||
frame_bytes = width * height * 3
|
||||
if len(stdout) != frame_bytes * len(frame_indices):
|
||||
raise CalibratedOverlayExperimentError("ffmpeg returned an unexpected frame count")
|
||||
return tuple(
|
||||
np.frombuffer(stdout, dtype=np.uint8, count=frame_bytes, offset=index * frame_bytes)
|
||||
.reshape((height, width, 3))
|
||||
.copy()
|
||||
for index in range(len(frame_indices))
|
||||
)
|
||||
|
||||
|
||||
def _select_lidar_samples(
|
||||
raw_path: Path,
|
||||
camera_anchors: tuple[_CameraFrameAnchor, ...],
|
||||
*,
|
||||
origin_monotonic_ns: int,
|
||||
temporal_offset_seconds: float,
|
||||
) -> tuple[_LidarSample, ...]:
|
||||
targets = tuple(
|
||||
anchor.session_time_seconds + temporal_offset_seconds for anchor in camera_anchors
|
||||
)
|
||||
best_points: list[tuple[float, float, LioPointCloudFrame] | None] = [None] * len(targets)
|
||||
nearby_poses: list[list[tuple[float, LioPoseFrame]]] = [[] for _ in targets]
|
||||
last_target = max(targets)
|
||||
first_target = min(targets)
|
||||
for message in iter_replay_messages(raw_path):
|
||||
monotonic_ns = message.received_monotonic_ns
|
||||
if monotonic_ns is None:
|
||||
raise CalibratedOverlayExperimentError("MQTT metadata has no monotonic time")
|
||||
session_time = (monotonic_ns - origin_monotonic_ns) / 1_000_000_000
|
||||
if session_time < first_target - 1.0:
|
||||
continue
|
||||
if session_time > last_target + 1.0:
|
||||
break
|
||||
if message.topic.endswith("/lio_pcl"):
|
||||
for index, target in enumerate(targets):
|
||||
delta = abs(session_time - target)
|
||||
current = best_points[index]
|
||||
if delta <= MAX_SYNC_DELTA_SECONDS and (current is None or delta < current[0]):
|
||||
best_points[index] = (delta, session_time, decode_lio_pcl(message.payload))
|
||||
elif message.topic.endswith("/lio_pose"):
|
||||
for index, target in enumerate(targets):
|
||||
if abs(session_time - target) <= 0.5:
|
||||
nearby_poses[index].append((session_time, decode_lio_pose(message.payload)))
|
||||
|
||||
samples: list[_LidarSample] = []
|
||||
for index, anchor in enumerate(camera_anchors):
|
||||
point = best_points[index]
|
||||
if point is None or not nearby_poses[index]:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
f"camera frame {anchor.sequence} has no temporally compatible LiDAR sample"
|
||||
)
|
||||
_delta, point_time, point_frame = point
|
||||
pose_time, pose_frame = min(
|
||||
nearby_poses[index],
|
||||
key=lambda item: abs(item[0] - point_time),
|
||||
)
|
||||
if abs(pose_time - point_time) > MAX_SYNC_DELTA_SECONDS:
|
||||
raise CalibratedOverlayExperimentError(
|
||||
f"camera frame {anchor.sequence} has no compatible pose"
|
||||
)
|
||||
samples.append(
|
||||
_LidarSample(
|
||||
camera=anchor,
|
||||
point_session_time_seconds=point_time,
|
||||
pose_session_time_seconds=pose_time,
|
||||
point_frame=point_frame,
|
||||
pose_frame=pose_frame,
|
||||
)
|
||||
)
|
||||
return tuple(samples)
|
||||
|
||||
|
||||
def _render_overlay(
|
||||
image_rgb: RgbImage,
|
||||
projection: ProjectedPointCloud,
|
||||
colors_rgb: RgbImage,
|
||||
*,
|
||||
camera_anchor: _CameraFrameAnchor,
|
||||
lidar_sample: _LidarSample,
|
||||
) -> RgbImage:
|
||||
image = Image.fromarray(image_rgb, mode="RGB").convert("RGBA")
|
||||
draw = ImageDraw.Draw(image, "RGBA")
|
||||
order = np.argsort(projection.depths_m)[::-1]
|
||||
for index in order:
|
||||
u, v = projection.pixels_xy[index]
|
||||
red, green, blue = (int(value) for value in colors_rgb[index])
|
||||
draw.ellipse(
|
||||
(u - 2.0, v - 2.0, u + 2.0, v + 2.0),
|
||||
fill=(red, green, blue, 205),
|
||||
)
|
||||
point_delta_ms = (
|
||||
lidar_sample.point_session_time_seconds - camera_anchor.session_time_seconds
|
||||
) * 1000.0
|
||||
draw.rectangle((0, 0, image.width, 34), fill=(0, 0, 0, 190))
|
||||
draw.text(
|
||||
(8, 9),
|
||||
(
|
||||
f"session={camera_anchor.session_time_seconds:.3f}s "
|
||||
f"LiDAR-camera={point_delta_ms:+.1f}ms "
|
||||
f"projected={projection.projected_point_count}/"
|
||||
f"{projection.source_point_count}"
|
||||
),
|
||||
fill=(255, 255, 255, 255),
|
||||
)
|
||||
return np.asarray(image.convert("RGB"), dtype=np.uint8)
|
||||
|
||||
|
||||
def _mosaic(rendered: tuple[_RenderedFrame, ...]) -> RgbImage:
|
||||
columns = 2 if len(rendered) > 1 else 1
|
||||
rows = math.ceil(len(rendered) / columns)
|
||||
width = rendered[0].overlay_rgb.shape[1]
|
||||
height = rendered[0].overlay_rgb.shape[0]
|
||||
canvas = Image.new("RGB", (columns * width, rows * height), "black")
|
||||
for index, item in enumerate(rendered):
|
||||
canvas.paste(
|
||||
Image.fromarray(item.overlay_rgb, mode="RGB"),
|
||||
((index % columns) * width, (index // columns) * height),
|
||||
)
|
||||
return np.asarray(canvas, dtype=np.uint8)
|
||||
|
||||
|
||||
def _write_rerun_diagnostic(
|
||||
path: Path,
|
||||
*,
|
||||
experiment_id: str,
|
||||
rendered: tuple[_RenderedFrame, ...],
|
||||
calibration_identity: str,
|
||||
profile: Kb4ProjectionProfile,
|
||||
) -> None:
|
||||
recording = rr.RecordingStream(
|
||||
"nodedc_mission_core_k1_calibration",
|
||||
recording_id=experiment_id,
|
||||
)
|
||||
recording.set_sinks(rr.FileSink(path, write_footer=True))
|
||||
try:
|
||||
recording.log(
|
||||
"k1/calibrated/contract",
|
||||
rr.TextDocument(
|
||||
"\n".join(
|
||||
(
|
||||
"K1 factory-calibrated diagnostic",
|
||||
f"source={profile.source_id}",
|
||||
f"slot={profile.calibration_slot}",
|
||||
f"calibration={calibration_identity}",
|
||||
"projection=map→inverse(T_map_from_lidar)→T_camera_from_lidar→KB4",
|
||||
"timing=host-arrival-best-effort; P0 temporal acceptance pending",
|
||||
)
|
||||
)
|
||||
),
|
||||
static=True,
|
||||
)
|
||||
for item in rendered:
|
||||
session_time_ns = round(item.camera.session_time_seconds * 1_000_000_000)
|
||||
recording.set_time(
|
||||
"session_time",
|
||||
duration=np.timedelta64(session_time_ns, "ns"),
|
||||
)
|
||||
recording.log("k1/calibrated/camera", rr.Image(item.image_rgb))
|
||||
recording.log(
|
||||
"k1/calibrated/camera/projected_lidar",
|
||||
rr.Points2D(
|
||||
item.projection.pixels_xy.astype(np.float32),
|
||||
colors=item.colors_rgb,
|
||||
radii=rr.Radius.ui_points(2.0),
|
||||
),
|
||||
)
|
||||
recording.flush(timeout_sec=30.0)
|
||||
recording.disconnect()
|
||||
finally:
|
||||
path.chmod(0o600)
|
||||
_fsync_file(path)
|
||||
|
||||
|
||||
def _frame_document(item: _RenderedFrame) -> dict[str, object]:
|
||||
depth = item.projection.depths_m
|
||||
return {
|
||||
"camera_sequence": item.camera.sequence,
|
||||
"requested_video_offset_seconds": item.camera.requested_video_offset_seconds,
|
||||
"camera_session_time_seconds": item.camera.session_time_seconds,
|
||||
"point_session_time_seconds": item.lidar.point_session_time_seconds,
|
||||
"pose_session_time_seconds": item.lidar.pose_session_time_seconds,
|
||||
"point_minus_camera_ms": round(
|
||||
(
|
||||
item.lidar.point_session_time_seconds
|
||||
- item.camera.session_time_seconds
|
||||
)
|
||||
* 1000.0,
|
||||
6,
|
||||
),
|
||||
"pose_minus_point_ms": round(
|
||||
(
|
||||
item.lidar.pose_session_time_seconds
|
||||
- item.lidar.point_session_time_seconds
|
||||
)
|
||||
* 1000.0,
|
||||
6,
|
||||
),
|
||||
"source_points": item.projection.source_point_count,
|
||||
"camera_front_points": item.projection.camera_front_point_count,
|
||||
"projected_points": item.projection.projected_point_count,
|
||||
"projected_fraction": round(
|
||||
item.projection.projected_point_count / item.projection.source_point_count,
|
||||
9,
|
||||
),
|
||||
"depth_m": {
|
||||
"p05": float(np.percentile(depth, 5.0)),
|
||||
"median": float(np.median(depth)),
|
||||
"p95": float(np.percentile(depth, 95.0)),
|
||||
},
|
||||
"artifact": item.artifact_name,
|
||||
"camera_artifact": item.camera_artifact_name,
|
||||
}
|
||||
|
||||
|
||||
def _operator_notes(
|
||||
*,
|
||||
experiment_id: str,
|
||||
session_id: str,
|
||||
source_id: str,
|
||||
calibration_identity: str,
|
||||
temporal_offset_seconds: float,
|
||||
) -> bytes:
|
||||
return (
|
||||
f"# {experiment_id}\n\n"
|
||||
"Offline read-only diagnostic. Native MQTT/camera evidence was not modified.\n\n"
|
||||
f"- session: {session_id}\n"
|
||||
f"- source: {source_id}\n"
|
||||
f"- calibration content identity: {calibration_identity}\n"
|
||||
f"- requested host-arrival temporal offset: {temporal_offset_seconds:+.6f} s\n"
|
||||
"- point frame: K1 map\n"
|
||||
"- pose interpretation: T_map_from_lidar; inverted before factory extrinsic\n"
|
||||
"- camera model: KB4 at admitted 800x600\n\n"
|
||||
"This run proves a reproducible diagnostic layer only. Static landmark pixel "
|
||||
"correspondences and a temporal-offset error budget are still required before "
|
||||
"P0 acceptance.\n"
|
||||
).encode()
|
||||
|
||||
|
||||
def _verified_raw_sha256(raw_path: Path, summary: dict[str, object]) -> str:
|
||||
expected = _required_sha256(
|
||||
_required_mapping(summary.get("artifact_hashes"), "artifact hashes").get(
|
||||
"raw_sha256"
|
||||
),
|
||||
"raw MQTT sha256",
|
||||
)
|
||||
actual = _sha256_file(raw_path)
|
||||
if actual != expected:
|
||||
raise CalibratedOverlayExperimentError("raw MQTT identity changed")
|
||||
return actual
|
||||
|
||||
|
||||
def _artifact_document(path: Path) -> dict[str, object]:
|
||||
metadata = path.stat()
|
||||
if not stat.S_ISREG(metadata.st_mode) or metadata.st_size <= 0:
|
||||
raise CalibratedOverlayExperimentError("derived artifact is unavailable")
|
||||
return {
|
||||
"name": path.name,
|
||||
"bytes": metadata.st_size,
|
||||
"sha256": _sha256_file(path),
|
||||
}
|
||||
|
||||
|
||||
def _read_json(path: Path, max_bytes: int) -> dict[str, object]:
|
||||
payload = _read_regular(path, max_bytes)
|
||||
try:
|
||||
value = json.loads(payload)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise CalibratedOverlayExperimentError(f"{path.name} is not valid JSON") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise CalibratedOverlayExperimentError(f"{path.name} is not a JSON object")
|
||||
return value
|
||||
|
||||
|
||||
def _read_regular(path: Path, max_bytes: int) -> bytes:
|
||||
flags = os.O_RDONLY | getattr(os, "O_CLOEXEC", 0) | getattr(os, "O_NOFOLLOW", 0)
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
before = os.fstat(descriptor)
|
||||
if not stat.S_ISREG(before.st_mode) or not 1 <= before.st_size <= max_bytes:
|
||||
raise CalibratedOverlayExperimentError(f"{path.name} is outside bounds")
|
||||
chunks: list[bytes] = []
|
||||
remaining = before.st_size
|
||||
while remaining:
|
||||
chunk = os.read(descriptor, min(_READ_CHUNK_BYTES, remaining))
|
||||
if not chunk:
|
||||
break
|
||||
chunks.append(chunk)
|
||||
remaining -= len(chunk)
|
||||
after = os.fstat(descriptor)
|
||||
if (
|
||||
remaining
|
||||
or (before.st_dev, before.st_ino, before.st_size, before.st_mtime_ns)
|
||||
!= (after.st_dev, after.st_ino, after.st_size, after.st_mtime_ns)
|
||||
):
|
||||
raise CalibratedOverlayExperimentError(f"{path.name} changed during read")
|
||||
return b"".join(chunks)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _write_image_exclusive(path: Path, image_rgb: RgbImage) -> None:
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb", closefd=False) as stream:
|
||||
Image.fromarray(image_rgb, mode="RGB").save(stream, format="PNG")
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _write_exclusive(path: Path, payload: bytes) -> None:
|
||||
descriptor = os.open(
|
||||
path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
view = memoryview(payload)
|
||||
while view:
|
||||
written = os.write(descriptor, view)
|
||||
view = view[written:]
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(_READ_CHUNK_BYTES), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _required_mapping(value: object, label: str) -> dict[str, object]:
|
||||
if not isinstance(value, dict):
|
||||
raise CalibratedOverlayExperimentError(f"{label} is missing")
|
||||
return value
|
||||
|
||||
|
||||
def _required_list(value: object, label: str) -> list[object]:
|
||||
if not isinstance(value, list):
|
||||
raise CalibratedOverlayExperimentError(f"{label} is missing")
|
||||
return value
|
||||
|
||||
|
||||
def _required_text(value: object, label: str) -> str:
|
||||
if not isinstance(value, str) or not value:
|
||||
raise CalibratedOverlayExperimentError(f"{label} is missing")
|
||||
return value
|
||||
|
||||
|
||||
def _required_sha256(value: object, label: str) -> str:
|
||||
text = _required_text(value, label)
|
||||
if len(text) != 64 or any(character not in "0123456789abcdef" for character in text):
|
||||
raise CalibratedOverlayExperimentError(f"{label} is invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _chmod_private(path: Path) -> None:
|
||||
current = path
|
||||
while current.name in {"private", "perception-experiments"}:
|
||||
with suppress(OSError):
|
||||
current.chmod(0o700)
|
||||
current = current.parent
|
||||
|
||||
|
||||
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)
|
||||
@@ -0,0 +1,235 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
import numpy as np
|
||||
import numpy.typing as npt
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.calibration_schema import (
|
||||
ADMITTED_MAIN_STREAM_RESOLUTION,
|
||||
MAIN_CAMERA_SLOT_BY_SOURCE,
|
||||
K1FactoryCalibration,
|
||||
)
|
||||
|
||||
FloatArray = npt.NDArray[np.float64]
|
||||
IntArray = npt.NDArray[np.int64]
|
||||
|
||||
|
||||
class CalibratedProjectionError(ValueError):
|
||||
"""Raised when a calibrated projection input violates the reviewed contract."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class Kb4ProjectionProfile:
|
||||
source_id: str
|
||||
calibration_slot: str
|
||||
width: int
|
||||
height: int
|
||||
intrinsic_fx_fy_cx_cy: tuple[float, float, float, float]
|
||||
distortion_kb4: tuple[float, float, float, float]
|
||||
t_camera_from_lidar: FloatArray
|
||||
|
||||
@classmethod
|
||||
def from_factory_calibration(
|
||||
cls,
|
||||
calibration: K1FactoryCalibration,
|
||||
source_id: str,
|
||||
) -> Kb4ProjectionProfile:
|
||||
try:
|
||||
slot = MAIN_CAMERA_SLOT_BY_SOURCE[source_id]
|
||||
except KeyError as exc:
|
||||
raise CalibratedProjectionError("source is not an admitted K1 main camera") from exc
|
||||
camera = calibration.camera(slot)
|
||||
width, height = ADMITTED_MAIN_STREAM_RESOLUTION
|
||||
scale_x = width / camera.image_width
|
||||
scale_y = height / camera.image_height
|
||||
intrinsic = (
|
||||
camera.intrinsic[0] * scale_x,
|
||||
camera.intrinsic[1] * scale_y,
|
||||
camera.intrinsic[2] * scale_x,
|
||||
camera.intrinsic[3] * scale_y,
|
||||
)
|
||||
transform = np.asarray(calibration.t_camera_from_lidar(slot), dtype=np.float64)
|
||||
transform.setflags(write=False)
|
||||
return cls(
|
||||
source_id=source_id,
|
||||
calibration_slot=slot,
|
||||
width=width,
|
||||
height=height,
|
||||
intrinsic_fx_fy_cx_cy=intrinsic,
|
||||
distortion_kb4=camera.distortion,
|
||||
t_camera_from_lidar=transform,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProjectedPointCloud:
|
||||
pixels_xy: FloatArray
|
||||
depths_m: FloatArray
|
||||
source_indices: IntArray
|
||||
source_point_count: int
|
||||
camera_front_point_count: int
|
||||
|
||||
@property
|
||||
def projected_point_count(self) -> int:
|
||||
return int(self.pixels_xy.shape[0])
|
||||
|
||||
|
||||
def quaternion_xyzw_to_rotation_matrix(
|
||||
orientation_xyzw: tuple[float, float, float, float],
|
||||
) -> FloatArray:
|
||||
"""Return R_map_from_lidar for a finite, non-zero xyzw quaternion."""
|
||||
|
||||
quaternion = np.asarray(orientation_xyzw, dtype=np.float64)
|
||||
if quaternion.shape != (4,) or not np.isfinite(quaternion).all():
|
||||
raise CalibratedProjectionError("pose quaternion must contain four finite values")
|
||||
norm = float(np.linalg.norm(quaternion))
|
||||
if not math.isfinite(norm) or norm < 1e-9:
|
||||
raise CalibratedProjectionError("pose quaternion has no usable norm")
|
||||
x, y, z, w = quaternion / norm
|
||||
rotation = np.asarray(
|
||||
[
|
||||
[
|
||||
1.0 - 2.0 * (y * y + z * z),
|
||||
2.0 * (x * y - z * w),
|
||||
2.0 * (x * z + y * w),
|
||||
],
|
||||
[
|
||||
2.0 * (x * y + z * w),
|
||||
1.0 - 2.0 * (x * x + z * z),
|
||||
2.0 * (y * z - x * w),
|
||||
],
|
||||
[
|
||||
2.0 * (x * z - y * w),
|
||||
2.0 * (y * z + x * w),
|
||||
1.0 - 2.0 * (x * x + y * y),
|
||||
],
|
||||
],
|
||||
dtype=np.float64,
|
||||
)
|
||||
rotation.setflags(write=False)
|
||||
return rotation
|
||||
|
||||
|
||||
def map_points_to_lidar(
|
||||
points_map_xyz: npt.ArrayLike,
|
||||
*,
|
||||
position_map_xyz: tuple[float, float, float],
|
||||
orientation_map_from_lidar_xyzw: tuple[float, float, float, float],
|
||||
) -> FloatArray:
|
||||
"""Invert the K1 T_map_from_lidar pose for row-vector map points."""
|
||||
|
||||
points = _finite_points(points_map_xyz)
|
||||
position = np.asarray(position_map_xyz, dtype=np.float64)
|
||||
if position.shape != (3,) or not np.isfinite(position).all():
|
||||
raise CalibratedProjectionError("pose position must contain three finite values")
|
||||
rotation_map_from_lidar = quaternion_xyzw_to_rotation_matrix(
|
||||
orientation_map_from_lidar_xyzw
|
||||
)
|
||||
# Column-vector form is R.T @ (p_map - t). With row vectors this is
|
||||
# (p_map - t) @ R.
|
||||
return (points - position) @ rotation_map_from_lidar
|
||||
|
||||
|
||||
def project_map_points_kb4(
|
||||
points_map_xyz: npt.ArrayLike,
|
||||
*,
|
||||
position_map_xyz: tuple[float, float, float],
|
||||
orientation_map_from_lidar_xyzw: tuple[float, float, float, float],
|
||||
profile: Kb4ProjectionProfile,
|
||||
) -> ProjectedPointCloud:
|
||||
"""Project one world-frame K1 lio_pcl frame into an admitted camera image."""
|
||||
|
||||
points_map = _finite_points(points_map_xyz)
|
||||
points_lidar = map_points_to_lidar(
|
||||
points_map,
|
||||
position_map_xyz=position_map_xyz,
|
||||
orientation_map_from_lidar_xyzw=orientation_map_from_lidar_xyzw,
|
||||
)
|
||||
transform = profile.t_camera_from_lidar
|
||||
if transform.shape != (4, 4) or not np.isfinite(transform).all():
|
||||
raise CalibratedProjectionError("camera transform must be a finite 4x4 matrix")
|
||||
points_camera = points_lidar @ transform[:3, :3].T + transform[:3, 3]
|
||||
x, y, z = points_camera.T
|
||||
front = z > 1e-6
|
||||
front_indices = np.flatnonzero(front)
|
||||
front_points = points_camera[front]
|
||||
if front_points.size == 0:
|
||||
return ProjectedPointCloud(
|
||||
pixels_xy=np.empty((0, 2), dtype=np.float64),
|
||||
depths_m=np.empty((0,), dtype=np.float64),
|
||||
source_indices=np.empty((0,), dtype=np.int64),
|
||||
source_point_count=int(points_map.shape[0]),
|
||||
camera_front_point_count=0,
|
||||
)
|
||||
|
||||
x, y, z = front_points.T
|
||||
radial = np.hypot(x, y)
|
||||
theta = np.arctan2(radial, z)
|
||||
theta_squared = theta * theta
|
||||
k1, k2, k3, k4 = profile.distortion_kb4
|
||||
theta_distorted = theta * (
|
||||
1.0
|
||||
+ k1 * theta_squared
|
||||
+ k2 * theta_squared**2
|
||||
+ k3 * theta_squared**3
|
||||
+ k4 * theta_squared**4
|
||||
)
|
||||
radial_scale = np.divide(
|
||||
theta_distorted,
|
||||
radial,
|
||||
out=np.zeros_like(theta_distorted),
|
||||
where=radial > 1e-12,
|
||||
)
|
||||
fx, fy, cx, cy = profile.intrinsic_fx_fy_cx_cy
|
||||
u = fx * x * radial_scale + cx
|
||||
v = fy * y * radial_scale + cy
|
||||
in_frame = (
|
||||
np.isfinite(u)
|
||||
& np.isfinite(v)
|
||||
& (u >= 0.0)
|
||||
& (u < profile.width)
|
||||
& (v >= 0.0)
|
||||
& (v < profile.height)
|
||||
)
|
||||
pixels = np.column_stack((u[in_frame], v[in_frame])).astype(np.float64, copy=False)
|
||||
depths = z[in_frame].astype(np.float64, copy=False)
|
||||
indices = front_indices[in_frame].astype(np.int64, copy=False)
|
||||
return ProjectedPointCloud(
|
||||
pixels_xy=pixels,
|
||||
depths_m=depths,
|
||||
source_indices=indices,
|
||||
source_point_count=int(points_map.shape[0]),
|
||||
camera_front_point_count=int(front_points.shape[0]),
|
||||
)
|
||||
|
||||
|
||||
def depth_colors(depths_m: npt.ArrayLike) -> npt.NDArray[np.uint8]:
|
||||
"""Return deterministic blue→cyan→green→yellow→red diagnostic colors."""
|
||||
|
||||
depths = np.asarray(depths_m, dtype=np.float64)
|
||||
if depths.ndim != 1 or not np.isfinite(depths).all() or np.any(depths <= 0.0):
|
||||
raise CalibratedProjectionError("projected depths must be a positive finite vector")
|
||||
if depths.size == 0:
|
||||
return np.empty((0, 3), dtype=np.uint8)
|
||||
lower, upper = np.percentile(depths, [5.0, 95.0])
|
||||
span = max(float(upper - lower), 1e-9)
|
||||
normalized = np.clip((depths - lower) / span, 0.0, 1.0)
|
||||
colors = np.column_stack(
|
||||
(
|
||||
255.0 * (1.0 - normalized),
|
||||
255.0 * (1.0 - np.abs(2.0 * normalized - 1.0)),
|
||||
255.0 * normalized,
|
||||
)
|
||||
)
|
||||
return np.rint(colors).astype(np.uint8)
|
||||
|
||||
|
||||
def _finite_points(points_xyz: npt.ArrayLike) -> FloatArray:
|
||||
points = np.asarray(points_xyz, dtype=np.float64)
|
||||
if points.ndim != 2 or points.shape[1:] != (3,):
|
||||
raise CalibratedProjectionError("point cloud must have shape (N, 3)")
|
||||
if not np.isfinite(points).all():
|
||||
raise CalibratedProjectionError("point cloud contains a non-finite coordinate")
|
||||
return points
|
||||
@@ -0,0 +1,472 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import secrets
|
||||
import shutil
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
from k1link.device_plugins.xgrids_k1.analyze.calibrated_projection import (
|
||||
Kb4ProjectionProfile,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.calibration_schema import (
|
||||
MAIN_CAMERA_SLOT_BY_SOURCE,
|
||||
parse_k1_factory_calibration,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.calibration_snapshot import (
|
||||
CALIBRATION_SNAPSHOT_MANIFEST_VERSION,
|
||||
)
|
||||
|
||||
VALID_FOV_MASK_SCHEMA = "missioncore.k1-valid-fov-mask/v1"
|
||||
VALID_FOV_MASK_IDENTITY_SCHEMA = "missioncore.k1-valid-fov-mask-identity/v1"
|
||||
DEFAULT_EDGE_MARGIN_PIXELS = 4.0
|
||||
MAX_MANIFEST_BYTES = 4 * 1024 * 1024
|
||||
|
||||
_SHA256 = re.compile(r"^[a-f0-9]{64}$")
|
||||
_SAFE_GENERATION = re.compile(r"^valid-fov-mask-[a-f0-9]{64}$")
|
||||
|
||||
|
||||
class K1ValidFovMaskError(RuntimeError):
|
||||
"""A K1 valid-FOV mask could not be derived or validated safely."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class K1ValidFovMask:
|
||||
generation_id: str
|
||||
root: Path
|
||||
manifest_path: Path
|
||||
mask_path: Path
|
||||
source_id: str
|
||||
calibration_slot: str
|
||||
calibration_sha256: str
|
||||
width: int
|
||||
height: int
|
||||
center_xy: tuple[float, float]
|
||||
radius_pixels: float
|
||||
crop_xyxy: tuple[int, int, int, int]
|
||||
valid_pixel_count: int
|
||||
valid_fraction: float
|
||||
|
||||
|
||||
def prepare_k1_valid_fov_mask(
|
||||
*,
|
||||
calibration_snapshot_root: Path,
|
||||
source_id: str,
|
||||
output_root: Path,
|
||||
edge_margin_pixels: float = DEFAULT_EDGE_MARGIN_PIXELS,
|
||||
) -> K1ValidFovMask:
|
||||
"""Seal one reusable circular valid-FOV mask bound to exact K1 calibration.
|
||||
|
||||
The mask is generated once from the calibrated principal point and the
|
||||
admitted image transform. It is content-addressed by calibration, source,
|
||||
geometry and construction policy, so repeated experiments load the same
|
||||
PNG rather than estimating a lens boundary from every frame.
|
||||
"""
|
||||
|
||||
if source_id not in MAIN_CAMERA_SLOT_BY_SOURCE:
|
||||
raise K1ValidFovMaskError("source is not an admitted K1 main camera")
|
||||
if (
|
||||
isinstance(edge_margin_pixels, bool)
|
||||
or not isinstance(edge_margin_pixels, (int, float))
|
||||
or not math.isfinite(float(edge_margin_pixels))
|
||||
or not 0.0 <= float(edge_margin_pixels) <= 64.0
|
||||
):
|
||||
raise K1ValidFovMaskError("edge margin must be finite and between 0 and 64 pixels")
|
||||
|
||||
snapshot_root = calibration_snapshot_root.expanduser().resolve(strict=True)
|
||||
snapshot = _validated_snapshot(snapshot_root)
|
||||
calibration = parse_k1_factory_calibration(
|
||||
(snapshot_root / "camera.yaml").read_bytes(),
|
||||
(snapshot_root / "extrinsic_camera_lidar.yaml").read_bytes(),
|
||||
)
|
||||
normalized = calibration.normalized_profile()
|
||||
if snapshot.get("normalized_calibration") != normalized:
|
||||
raise K1ValidFovMaskError("calibration snapshot normalized profile changed")
|
||||
profile = Kb4ProjectionProfile.from_factory_calibration(calibration, source_id)
|
||||
stream_bindings = normalized.get("stream_bindings")
|
||||
if not isinstance(stream_bindings, dict):
|
||||
raise K1ValidFovMaskError("calibration stream bindings are unavailable")
|
||||
stream_binding = stream_bindings.get(source_id)
|
||||
if not isinstance(stream_binding, dict):
|
||||
raise K1ValidFovMaskError("calibration source binding is unavailable")
|
||||
|
||||
fx, fy, cx, cy = profile.intrinsic_fx_fy_cx_cy
|
||||
image_edge_radius = min(
|
||||
cx,
|
||||
float(profile.width - 1) - cx,
|
||||
cy,
|
||||
float(profile.height - 1) - cy,
|
||||
)
|
||||
radius = image_edge_radius - float(edge_margin_pixels)
|
||||
if not math.isfinite(radius) or radius < 32.0:
|
||||
raise K1ValidFovMaskError("valid-FOV construction leaves no usable image circle")
|
||||
|
||||
identity = {
|
||||
"schema_version": VALID_FOV_MASK_IDENTITY_SCHEMA,
|
||||
"calibration_sha256": snapshot["content_identity_sha256"],
|
||||
"source_id": source_id,
|
||||
"calibration_slot": profile.calibration_slot,
|
||||
"camera_model": "kb4",
|
||||
"admitted_resolution": [profile.width, profile.height],
|
||||
"admitted_intrinsic_fx_fy_cx_cy": [fx, fy, cx, cy],
|
||||
"distortion_kb4": list(profile.distortion_kb4),
|
||||
"image_transform": stream_binding.get("image_transform"),
|
||||
"construction": {
|
||||
"kind": "calibrated-principal-point-inscribed-circle",
|
||||
"pixel_coordinate_convention": "integer-pixel-centers",
|
||||
"edge_radius_policy": "minimum-distance-to-admitted-image-edge",
|
||||
"edge_margin_pixels": float(edge_margin_pixels),
|
||||
"outside_value": 0,
|
||||
"inside_value": 255,
|
||||
},
|
||||
}
|
||||
identity_sha256 = hashlib.sha256(_canonical_json(identity)).hexdigest()
|
||||
generation_id = f"valid-fov-mask-{identity_sha256}"
|
||||
root = _prepare_private_directory(output_root)
|
||||
final = root / generation_id
|
||||
if final.exists():
|
||||
existing = validate_k1_valid_fov_mask(final)
|
||||
if existing.calibration_sha256 != snapshot["content_identity_sha256"]:
|
||||
raise K1ValidFovMaskError("valid-FOV generation collides with another calibration")
|
||||
return existing
|
||||
|
||||
staging = root / f".{generation_id}.{secrets.token_hex(12)}.incomplete"
|
||||
published = False
|
||||
try:
|
||||
staging.mkdir(mode=0o700)
|
||||
mask = _circle_mask(profile.width, profile.height, cx, cy, radius)
|
||||
valid_y, valid_x = np.nonzero(mask)
|
||||
if valid_x.size == 0 or valid_y.size == 0:
|
||||
raise K1ValidFovMaskError("valid-FOV mask is empty")
|
||||
crop = (
|
||||
int(valid_x.min()),
|
||||
int(valid_y.min()),
|
||||
int(valid_x.max()) + 1,
|
||||
int(valid_y.max()) + 1,
|
||||
)
|
||||
mask_path = staging / "mask.png"
|
||||
Image.fromarray(mask, mode="L").save(mask_path, format="PNG", optimize=False)
|
||||
_fsync_file(mask_path)
|
||||
os.chmod(mask_path, 0o600)
|
||||
mask_sha256 = _sha256_file(mask_path)
|
||||
valid_pixel_count = int(np.count_nonzero(mask))
|
||||
valid_fraction = valid_pixel_count / int(mask.size)
|
||||
manifest = {
|
||||
"schema_version": VALID_FOV_MASK_SCHEMA,
|
||||
"generation_id": generation_id,
|
||||
"identity_sha256": identity_sha256,
|
||||
"identity": identity,
|
||||
"created_at_utc": utc_now_iso(),
|
||||
"geometry": {
|
||||
"center_xy": [cx, cy],
|
||||
"image_edge_radius_pixels": image_edge_radius,
|
||||
"radius_pixels": radius,
|
||||
"crop_xyxy_exclusive": list(crop),
|
||||
"valid_pixel_count": valid_pixel_count,
|
||||
"total_pixel_count": int(mask.size),
|
||||
"valid_fraction": valid_fraction,
|
||||
},
|
||||
"artifact": {
|
||||
"path": "mask.png",
|
||||
"media_type": "image/png",
|
||||
"mode": "L",
|
||||
"inside_value": 255,
|
||||
"outside_value": 0,
|
||||
"byte_length": mask_path.stat().st_size,
|
||||
"sha256": mask_sha256,
|
||||
},
|
||||
"usage": {
|
||||
"quality": "set invalid lens exterior to the model profile's fixed fill value",
|
||||
"speed": (
|
||||
"apply crop_xyxy_exclusive before model preprocessing and map outputs back"
|
||||
),
|
||||
"dense_compute_warning": (
|
||||
"multiplying an unchanged 800x600 tensor by this mask alone does not reduce "
|
||||
"dense neural-network FLOPs"
|
||||
),
|
||||
},
|
||||
}
|
||||
write_json_atomic(staging / "manifest.json", manifest)
|
||||
os.chmod(staging / "manifest.json", 0o600)
|
||||
_fsync_directory(staging)
|
||||
os.replace(staging, final)
|
||||
_fsync_directory(root)
|
||||
published = True
|
||||
finally:
|
||||
if not published and staging.exists():
|
||||
shutil.rmtree(staging)
|
||||
|
||||
return validate_k1_valid_fov_mask(final)
|
||||
|
||||
|
||||
def validate_k1_valid_fov_mask(mask_root: Path) -> K1ValidFovMask:
|
||||
root = mask_root.expanduser().resolve(strict=True)
|
||||
if not root.is_dir() or _SAFE_GENERATION.fullmatch(root.name) is None:
|
||||
raise K1ValidFovMaskError("valid-FOV root is invalid")
|
||||
manifest = _read_json_object(root / "manifest.json", root, MAX_MANIFEST_BYTES)
|
||||
identity = manifest.get("identity")
|
||||
identity_sha256 = manifest.get("identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != VALID_FOV_MASK_SCHEMA
|
||||
or manifest.get("generation_id") != root.name
|
||||
or not isinstance(identity, dict)
|
||||
or identity.get("schema_version") != VALID_FOV_MASK_IDENTITY_SCHEMA
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
or root.name != f"valid-fov-mask-{identity_sha256}"
|
||||
or hashlib.sha256(_canonical_json(identity)).hexdigest() != identity_sha256
|
||||
):
|
||||
raise K1ValidFovMaskError("valid-FOV identity is inconsistent")
|
||||
|
||||
artifact = manifest.get("artifact")
|
||||
geometry = manifest.get("geometry")
|
||||
resolution = identity.get("admitted_resolution")
|
||||
intrinsic = identity.get("admitted_intrinsic_fx_fy_cx_cy")
|
||||
if (
|
||||
not isinstance(artifact, dict)
|
||||
or artifact.get("path") != "mask.png"
|
||||
or artifact.get("media_type") != "image/png"
|
||||
or artifact.get("mode") != "L"
|
||||
or artifact.get("inside_value") != 255
|
||||
or artifact.get("outside_value") != 0
|
||||
or not isinstance(geometry, dict)
|
||||
or not isinstance(resolution, list)
|
||||
or len(resolution) != 2
|
||||
or not all(isinstance(value, int) and not isinstance(value, bool) for value in resolution)
|
||||
or not isinstance(intrinsic, list)
|
||||
or len(intrinsic) != 4
|
||||
):
|
||||
raise K1ValidFovMaskError("valid-FOV manifest contract is invalid")
|
||||
width, height = int(resolution[0]), int(resolution[1])
|
||||
if width < 1 or height < 1:
|
||||
raise K1ValidFovMaskError("valid-FOV resolution is invalid")
|
||||
mask_path = root / "mask.png"
|
||||
metadata = _confined_regular_file(mask_path, root)
|
||||
if (
|
||||
artifact.get("byte_length") != metadata.st_size
|
||||
or not isinstance(artifact.get("sha256"), str)
|
||||
or _SHA256.fullmatch(str(artifact["sha256"])) is None
|
||||
or _sha256_file(mask_path) != artifact["sha256"]
|
||||
):
|
||||
raise K1ValidFovMaskError("valid-FOV mask artifact changed")
|
||||
try:
|
||||
with Image.open(mask_path) as opened:
|
||||
if opened.mode != "L" or opened.size != (width, height):
|
||||
raise K1ValidFovMaskError("valid-FOV PNG dimensions or mode changed")
|
||||
mask = np.asarray(opened, dtype=np.uint8)
|
||||
except (OSError, ValueError) as exc:
|
||||
raise K1ValidFovMaskError("valid-FOV PNG is unavailable") from exc
|
||||
if not np.isin(mask, (0, 255)).all():
|
||||
raise K1ValidFovMaskError("valid-FOV PNG is not binary")
|
||||
valid_y, valid_x = np.nonzero(mask)
|
||||
if valid_x.size == 0 or valid_y.size == 0:
|
||||
raise K1ValidFovMaskError("valid-FOV PNG is empty")
|
||||
crop = (
|
||||
int(valid_x.min()),
|
||||
int(valid_y.min()),
|
||||
int(valid_x.max()) + 1,
|
||||
int(valid_y.max()) + 1,
|
||||
)
|
||||
count = int(valid_x.size)
|
||||
center = geometry.get("center_xy")
|
||||
radius = geometry.get("radius_pixels")
|
||||
expected_crop = geometry.get("crop_xyxy_exclusive")
|
||||
expected_count = geometry.get("valid_pixel_count")
|
||||
expected_total = geometry.get("total_pixel_count")
|
||||
expected_fraction = geometry.get("valid_fraction")
|
||||
if (
|
||||
not isinstance(center, list)
|
||||
or len(center) != 2
|
||||
or not all(isinstance(value, (int, float)) for value in center)
|
||||
or not isinstance(radius, (int, float))
|
||||
or not math.isfinite(float(radius))
|
||||
or list(crop) != expected_crop
|
||||
or count != expected_count
|
||||
or mask.size != expected_total
|
||||
or not isinstance(expected_fraction, (int, float))
|
||||
or not math.isclose(count / mask.size, float(expected_fraction), abs_tol=1e-12)
|
||||
):
|
||||
raise K1ValidFovMaskError("valid-FOV geometry changed")
|
||||
source_id = identity.get("source_id")
|
||||
calibration_slot = identity.get("calibration_slot")
|
||||
calibration_sha256 = identity.get("calibration_sha256")
|
||||
if (
|
||||
not isinstance(source_id, str)
|
||||
or source_id not in MAIN_CAMERA_SLOT_BY_SOURCE
|
||||
or not isinstance(calibration_slot, str)
|
||||
or calibration_slot != MAIN_CAMERA_SLOT_BY_SOURCE[source_id]
|
||||
or not isinstance(calibration_sha256, str)
|
||||
or _SHA256.fullmatch(calibration_sha256) is None
|
||||
):
|
||||
raise K1ValidFovMaskError("valid-FOV calibration binding is invalid")
|
||||
return K1ValidFovMask(
|
||||
generation_id=root.name,
|
||||
root=root,
|
||||
manifest_path=root / "manifest.json",
|
||||
mask_path=mask_path,
|
||||
source_id=source_id,
|
||||
calibration_slot=calibration_slot,
|
||||
calibration_sha256=calibration_sha256,
|
||||
width=width,
|
||||
height=height,
|
||||
center_xy=(float(center[0]), float(center[1])),
|
||||
radius_pixels=float(radius),
|
||||
crop_xyxy=crop,
|
||||
valid_pixel_count=count,
|
||||
valid_fraction=count / mask.size,
|
||||
)
|
||||
|
||||
|
||||
def _validated_snapshot(root: Path) -> dict[str, Any]:
|
||||
manifest = _read_json_object(root / "manifest.json", root, MAX_MANIFEST_BYTES)
|
||||
artifacts = manifest.get("artifacts")
|
||||
device = manifest.get("device")
|
||||
identity_sha256 = manifest.get("content_identity_sha256")
|
||||
if (
|
||||
manifest.get("schema_version") != CALIBRATION_SNAPSHOT_MANIFEST_VERSION
|
||||
or not isinstance(artifacts, list)
|
||||
or len(artifacts) != 2
|
||||
or not isinstance(device, dict)
|
||||
or not isinstance(identity_sha256, str)
|
||||
or _SHA256.fullmatch(identity_sha256) is None
|
||||
):
|
||||
raise K1ValidFovMaskError("calibration snapshot manifest is incompatible")
|
||||
expected_names = {"camera.yaml", "extrinsic_camera_lidar.yaml"}
|
||||
by_name: dict[str, dict[str, Any]] = {}
|
||||
by_source: dict[str, str] = {}
|
||||
for artifact in artifacts:
|
||||
if not isinstance(artifact, dict):
|
||||
raise K1ValidFovMaskError("calibration snapshot artifact is invalid")
|
||||
name = artifact.get("artifact_name")
|
||||
source_path = artifact.get("source_path")
|
||||
digest = artifact.get("sha256")
|
||||
byte_length = artifact.get("bytes")
|
||||
if (
|
||||
not isinstance(name, str)
|
||||
or name not in expected_names
|
||||
or name in by_name
|
||||
or not isinstance(source_path, str)
|
||||
or not source_path.startswith("/mnt/system/factory-data/config/")
|
||||
or not isinstance(digest, str)
|
||||
or _SHA256.fullmatch(digest) is None
|
||||
or not isinstance(byte_length, int)
|
||||
or isinstance(byte_length, bool)
|
||||
or byte_length < 1
|
||||
):
|
||||
raise K1ValidFovMaskError("calibration snapshot artifact descriptor is invalid")
|
||||
path = root / name
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if metadata.st_size != byte_length or _sha256_file(path) != digest:
|
||||
raise K1ValidFovMaskError("calibration snapshot artifact changed")
|
||||
by_name[name] = artifact
|
||||
by_source[source_path] = digest
|
||||
if set(by_name) != expected_names or len(by_source) != 2:
|
||||
raise K1ValidFovMaskError("calibration snapshot artifact set changed")
|
||||
vendor_device_id = device.get("vendor_device_id")
|
||||
device_serial = device.get("device_serial")
|
||||
if not isinstance(vendor_device_id, str) or not isinstance(device_serial, str):
|
||||
raise K1ValidFovMaskError("calibration snapshot device binding is unavailable")
|
||||
digest = hashlib.sha256()
|
||||
try:
|
||||
digest.update(vendor_device_id.encode("ascii"))
|
||||
digest.update(b"\x00")
|
||||
digest.update(device_serial.encode("ascii"))
|
||||
except UnicodeEncodeError as exc:
|
||||
raise K1ValidFovMaskError("calibration snapshot device binding is invalid") from exc
|
||||
for source_path in sorted(by_source):
|
||||
digest.update(b"\x00")
|
||||
digest.update(source_path.encode("utf-8"))
|
||||
digest.update(bytes.fromhex(by_source[source_path]))
|
||||
if digest.hexdigest() != identity_sha256:
|
||||
raise K1ValidFovMaskError("calibration snapshot content identity changed")
|
||||
return manifest
|
||||
|
||||
|
||||
def _circle_mask(width: int, height: int, cx: float, cy: float, radius: float) -> np.ndarray:
|
||||
y, x = np.ogrid[:height, :width]
|
||||
inside = (x - cx) ** 2 + (y - cy) ** 2 <= radius**2
|
||||
return np.where(inside, 255, 0).astype(np.uint8)
|
||||
|
||||
|
||||
def _prepare_private_directory(path: Path) -> Path:
|
||||
candidate = path.expanduser()
|
||||
candidate.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
metadata = candidate.lstat()
|
||||
if stat.S_ISLNK(metadata.st_mode) or not stat.S_ISDIR(metadata.st_mode):
|
||||
raise K1ValidFovMaskError("valid-FOV output root must be a real directory")
|
||||
root = candidate.resolve(strict=True)
|
||||
os.chmod(root, 0o700)
|
||||
return root
|
||||
|
||||
|
||||
def _read_json_object(path: Path, root: Path, maximum_bytes: int) -> dict[str, Any]:
|
||||
metadata = _confined_regular_file(path, root)
|
||||
if not 0 < metadata.st_size <= maximum_bytes:
|
||||
raise K1ValidFovMaskError("JSON manifest is outside bounds")
|
||||
try:
|
||||
value = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise K1ValidFovMaskError("JSON manifest is unavailable") from exc
|
||||
if not isinstance(value, dict):
|
||||
raise K1ValidFovMaskError("JSON manifest is not an object")
|
||||
return value
|
||||
|
||||
|
||||
def _confined_regular_file(path: Path, root: Path) -> os.stat_result:
|
||||
try:
|
||||
resolved_root = root.resolve(strict=True)
|
||||
resolved = path.resolve(strict=True)
|
||||
metadata = path.lstat()
|
||||
except OSError as exc:
|
||||
raise K1ValidFovMaskError("artifact is unavailable") from exc
|
||||
if (
|
||||
stat.S_ISLNK(metadata.st_mode)
|
||||
or not stat.S_ISREG(metadata.st_mode)
|
||||
or not resolved.is_relative_to(resolved_root)
|
||||
):
|
||||
raise K1ValidFovMaskError("artifact is not a confined regular file")
|
||||
return metadata
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(1024 * 1024):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: object) -> bytes:
|
||||
return json.dumps(
|
||||
value,
|
||||
ensure_ascii=False,
|
||||
sort_keys=True,
|
||||
separators=(",", ":"),
|
||||
allow_nan=False,
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _fsync_file(path: Path) -> None:
|
||||
with path.open("rb") as stream:
|
||||
os.fsync(stream.fileno())
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_DIRECTORY"):
|
||||
flags |= os.O_DIRECTORY
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -42,10 +42,7 @@ 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)
|
||||
@@ -560,20 +557,12 @@ def _scan_capture_prefix(
|
||||
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)
|
||||
while True:
|
||||
line = metadata_stream.readline(MAX_RECOVERY_METADATA_LINE_BYTES + 1)
|
||||
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
|
||||
):
|
||||
if len(line) > MAX_RECOVERY_METADATA_LINE_BYTES:
|
||||
return None
|
||||
if not line.endswith(b"\n"):
|
||||
if tolerate_incomplete_metadata_tail:
|
||||
@@ -1015,7 +1004,7 @@ def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
|
||||
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 index_path.stat().st_size <= 0
|
||||
or not summary_path.is_file()
|
||||
):
|
||||
return False
|
||||
@@ -1027,30 +1016,37 @@ def _validated_media_epoch(epoch: Path, expected_source_id: str) -> bool:
|
||||
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
|
||||
segment_sequences: set[int] = set()
|
||||
for path in segments_root.iterdir():
|
||||
match = MEDIA_SEGMENT_PATTERN.fullmatch(path.name)
|
||||
if match is None or not path.is_file():
|
||||
continue
|
||||
resolved = path.resolve()
|
||||
sequence = int(path.stem)
|
||||
if (
|
||||
resolved.parent != segments_root
|
||||
or path.name != f"{sequence}.m4s"
|
||||
or resolved.stat().st_size <= 0
|
||||
or sequence in segment_sequences
|
||||
):
|
||||
return False
|
||||
segment_sequences.add(sequence)
|
||||
if (
|
||||
len(segment_sequences) != segment_count
|
||||
or min(segment_sequences, default=0) != 1
|
||||
or max(segment_sequences, default=0) != segment_count
|
||||
):
|
||||
return False
|
||||
try:
|
||||
index_records = [
|
||||
json.loads(line)
|
||||
for line in index_path.read_text(encoding="utf-8").splitlines()
|
||||
if line.strip()
|
||||
]
|
||||
with index_path.open("r", encoding="utf-8") as stream:
|
||||
index_count = 0
|
||||
for index_count, line in enumerate(stream, start=1):
|
||||
record = json.loads(line)
|
||||
if not isinstance(record, dict) or record.get("sequence") != index_count:
|
||||
return False
|
||||
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))
|
||||
return index_count == segment_count
|
||||
|
||||
|
||||
def _media_epoch_bytes(epoch: Path) -> int:
|
||||
|
||||
@@ -0,0 +1,293 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
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
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
AP_FALLBACK_IPV4,
|
||||
SERVICE_UUID,
|
||||
STATUS_CHARACTERISTIC_UUID,
|
||||
WRITE_CHARACTERISTIC_UUID,
|
||||
ResolvedWriteMode,
|
||||
StatusObservation,
|
||||
WifiStatus,
|
||||
WriteMode,
|
||||
parse_wifi_status,
|
||||
)
|
||||
|
||||
PROFILE_ID = "xgrids-k1-fw3-quick-connect-ap-v1"
|
||||
FRAME_LENGTH = 100
|
||||
COMMAND_OFFSET = 99
|
||||
ENABLE_AP_COMMAND = 1
|
||||
|
||||
ApActivationOutcome = Literal[
|
||||
"already_active",
|
||||
"ap_ready_observed",
|
||||
"status_changed",
|
||||
"ble_disconnected_after_write",
|
||||
"no_status_change_before_timeout",
|
||||
]
|
||||
|
||||
|
||||
class ApActivationResult(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_performed: bool
|
||||
write_mode: ResolvedWriteMode | None
|
||||
write_without_response_advertised: bool
|
||||
max_write_without_response_size: int
|
||||
frame_length: int
|
||||
baseline_status: WifiStatus
|
||||
observations: list[StatusObservation]
|
||||
ready_observed: bool
|
||||
outcome: ApActivationOutcome
|
||||
|
||||
|
||||
def build_ap_activation_frame() -> bytearray:
|
||||
"""Build LixelGO's fixed 100-byte Quick Connect AP-enable frame."""
|
||||
|
||||
frame = bytearray(FRAME_LENGTH)
|
||||
frame[COMMAND_OFFSET] = ENABLE_AP_COMMAND
|
||||
return frame
|
||||
|
||||
|
||||
def is_ap_ready_status(status: WifiStatus) -> bool:
|
||||
# The reviewed LixelGO build maps byte 51 of the 7f02 response to its
|
||||
# Wi-Fi-AP-ready flag. WIFI_AP plus the fallback address describes the
|
||||
# selected control mode, but can remain stale after the beacon disappears.
|
||||
return (
|
||||
status["mode"] == "WIFI_AP"
|
||||
and status["ipv4"] == AP_FALLBACK_IPV4
|
||||
and status["reserved"] not in (None, 0)
|
||||
)
|
||||
|
||||
|
||||
def _outcome(
|
||||
baseline: WifiStatus,
|
||||
observations: list[StatusObservation],
|
||||
disconnected: bool,
|
||||
) -> ApActivationOutcome:
|
||||
if observations:
|
||||
final = observations[-1]["status"]
|
||||
if is_ap_ready_status(final):
|
||||
return "ap_ready_observed"
|
||||
if final != baseline:
|
||||
return "status_changed"
|
||||
if disconnected:
|
||||
return "ble_disconnected_after_write"
|
||||
return "no_status_change_before_timeout"
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def device_ap_activation_session(
|
||||
device_macos_uuid: str,
|
||||
timeout_seconds: float = 15.0,
|
||||
poll_interval_seconds: float = 0.5,
|
||||
write_mode: WriteMode = "auto",
|
||||
) -> AsyncIterator[ApActivationResult]:
|
||||
"""Keep BLE connected around one reviewed Quick Connect AP-enable write.
|
||||
|
||||
The payload is the exact fixed frame used by the reviewed LixelGO build.
|
||||
``auto`` follows the live GATT properties because the owner-controlled K1
|
||||
advertises a write with response even though the Android client requests a
|
||||
write without response. The caller receives the result while the same BLE
|
||||
session is still alive, matching LixelGO's AP-ready -> native Wi-Fi handoff.
|
||||
No transport or command retry is attempted.
|
||||
"""
|
||||
|
||||
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_ap_activation_frame()
|
||||
started_at = utc_now_iso()
|
||||
observations: list[StatusObservation] = []
|
||||
disconnected = False
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
device = discovered_device(device_macos_uuid)
|
||||
if device is None:
|
||||
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:
|
||||
async with asyncio.timeout(timeout_seconds + 10.0):
|
||||
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(
|
||||
"Reviewed K1 AP-control characteristic not found: "
|
||||
f"{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 AP-control 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
|
||||
baseline = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
# WIFI_AP is a control-mode status, not proof that the radio is
|
||||
# still beaconing. A physical run found the exact SSID shortly
|
||||
# after AP-enable, then found no beacon while 7f02 continued to
|
||||
# report WIFI_AP. LixelGO emits the reviewed enable frame for
|
||||
# each explicit Quick Connect action, so Mission Core does the
|
||||
# same once per operator action instead of short-circuiting on
|
||||
# a stale-ready status. There is still no automatic retry.
|
||||
|
||||
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(
|
||||
"AP activation frame exceeds the negotiated "
|
||||
"write-without-response size"
|
||||
)
|
||||
resolved_write_mode = "without_response"
|
||||
|
||||
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:
|
||||
status = parse_wifi_status(
|
||||
bytes(await client.read_gatt_char(status_characteristic))
|
||||
)
|
||||
except BleakError:
|
||||
if not client.is_connected:
|
||||
disconnected = True
|
||||
break
|
||||
raise
|
||||
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 is_ap_ready_status(status):
|
||||
break
|
||||
await asyncio.sleep(poll_interval_seconds)
|
||||
|
||||
result: ApActivationResult = {
|
||||
"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_quick_connect_ap_activation",
|
||||
"write_performed": True,
|
||||
"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,
|
||||
"ready_observed": bool(
|
||||
observations and is_ap_ready_status(observations[-1]["status"])
|
||||
),
|
||||
"outcome": _outcome(baseline, observations, disconnected),
|
||||
}
|
||||
# Keep the same CoreBluetooth session alive while the caller waits
|
||||
# for and performs the host-side CoreWLAN association. LixelGO does
|
||||
# not tear down this BLE manager between its AP-ready callback and
|
||||
# native Wi-Fi connect call.
|
||||
yield result
|
||||
finally:
|
||||
frame[:] = b"\x00" * len(frame)
|
||||
|
||||
|
||||
async def activate_device_ap_once(
|
||||
device_macos_uuid: str,
|
||||
timeout_seconds: float = 15.0,
|
||||
poll_interval_seconds: float = 0.5,
|
||||
write_mode: WriteMode = "auto",
|
||||
) -> ApActivationResult:
|
||||
"""Run one AP activation and release BLE immediately after its result.
|
||||
|
||||
Host association flows must use :func:`device_ap_activation_session` so
|
||||
the reviewed LixelGO BLE-to-Wi-Fi handoff remains one connected session.
|
||||
"""
|
||||
|
||||
async with device_ap_activation_session(
|
||||
device_macos_uuid,
|
||||
timeout_seconds=timeout_seconds,
|
||||
poll_interval_seconds=poll_interval_seconds,
|
||||
write_mode=write_mode,
|
||||
) as result:
|
||||
return result
|
||||
@@ -1,6 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from importlib.metadata import version
|
||||
from threading import Lock
|
||||
from typing import TypedDict
|
||||
|
||||
from bleak import BleakScanner
|
||||
@@ -9,6 +10,9 @@ from bleak.backends.scanner import AdvertisementData
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
_runtime_handle_lock = Lock()
|
||||
_runtime_handles: dict[str, BLEDevice] = {}
|
||||
|
||||
|
||||
class BleDeviceRecord(TypedDict):
|
||||
macos_uuid: str
|
||||
@@ -58,12 +62,24 @@ def advertisement_record(device: BLEDevice, advertisement: AdvertisementData) ->
|
||||
}
|
||||
|
||||
|
||||
def discovered_device(macos_uuid: str) -> BLEDevice | None:
|
||||
"""Return the live CoreBluetooth handle retained by the latest explicit scan."""
|
||||
|
||||
with _runtime_handle_lock:
|
||||
return _runtime_handles.get(macos_uuid)
|
||||
|
||||
|
||||
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)
|
||||
with _runtime_handle_lock:
|
||||
_runtime_handles.clear()
|
||||
_runtime_handles.update(
|
||||
{device.address: device for device, _advertisement in discovered.values()}
|
||||
)
|
||||
devices = [
|
||||
advertisement_record(device, advertisement) for device, advertisement in discovered.values()
|
||||
]
|
||||
|
||||
@@ -10,6 +10,7 @@ from bleak import BleakClient, BleakScanner
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import discovered_device
|
||||
|
||||
PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
|
||||
SERVICE_UUID = "00007f00-0000-1000-8000-00805f9b34fb"
|
||||
@@ -66,6 +67,21 @@ class WifiProvisioningResult(TypedDict):
|
||||
outcome: ProvisioningOutcome
|
||||
|
||||
|
||||
class WifiStatusReadResult(TypedDict):
|
||||
schema_version: int
|
||||
profile_id: str
|
||||
observed_at_utc: str
|
||||
adapter: str
|
||||
bleak_version: str
|
||||
device_macos_uuid: str
|
||||
device_name: str
|
||||
service_uuid: str
|
||||
status_characteristic_uuid: str
|
||||
operation: Literal["single_reviewed_wifi_status_read"]
|
||||
write_performed: Literal[False]
|
||||
status: WifiStatus
|
||||
|
||||
|
||||
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")
|
||||
@@ -143,6 +159,62 @@ def _outcome(
|
||||
return "no_status_change_before_timeout"
|
||||
|
||||
|
||||
async def read_wifi_status_once(
|
||||
device_macos_uuid: str,
|
||||
*,
|
||||
timeout_seconds: float = 20.0,
|
||||
) -> WifiStatusReadResult:
|
||||
"""Read the K1's current DHCP status over BLE without writing a characteristic."""
|
||||
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
async with asyncio.timeout(timeout_seconds + 5.0):
|
||||
device = discovered_device(device_macos_uuid)
|
||||
if device is None:
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=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:
|
||||
service = client.services.get_service(SERVICE_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 status_characteristic is None:
|
||||
raise ValueError(
|
||||
f"Reviewed K1 status characteristic not found: {STATUS_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
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")
|
||||
value = bytes(await client.read_gatt_char(status_characteristic))
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"profile_id": PROFILE_ID,
|
||||
"observed_at_utc": utc_now_iso(),
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_macos_uuid": device_macos_uuid,
|
||||
"device_name": client.name or "",
|
||||
"service_uuid": service.uuid,
|
||||
"status_characteristic_uuid": status_characteristic.uuid,
|
||||
"operation": "single_reviewed_wifi_status_read",
|
||||
"write_performed": False,
|
||||
"status": parse_wifi_status(value),
|
||||
}
|
||||
|
||||
|
||||
async def provision_wifi_once(
|
||||
device_macos_uuid: str,
|
||||
ssid: str,
|
||||
@@ -166,10 +238,12 @@ async def provision_wifi_once(
|
||||
|
||||
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),
|
||||
)
|
||||
device = discovered_device(device_macos_uuid)
|
||||
if device is None:
|
||||
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,
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Final, cast
|
||||
|
||||
import yaml
|
||||
from yaml.constructor import ConstructorError
|
||||
from yaml.tokens import AliasToken, AnchorToken, TagToken
|
||||
|
||||
MAX_FACTORY_YAML_BYTES: Final = 64 * 1024
|
||||
CAMERA_KEYS: Final = ("camera_0", "camera_1", "camera_2", "camera_3")
|
||||
MAIN_CAMERA_SLOT_BY_SOURCE: Final = {
|
||||
"sensor.camera.left": "camera_0",
|
||||
"sensor.camera.right": "camera_1",
|
||||
}
|
||||
MAIN_CAMERA_RTSP_PATH_BY_SOURCE: Final = {
|
||||
"sensor.camera.left": "/live/chn_left_main",
|
||||
"sensor.camera.right": "/live/chn_right_main",
|
||||
}
|
||||
EXPECTED_CAMERA_RESOLUTION: Final = {
|
||||
"camera_0": (4000, 3000),
|
||||
"camera_1": (4000, 3000),
|
||||
"camera_2": (1280, 800),
|
||||
"camera_3": (1280, 800),
|
||||
}
|
||||
ADMITTED_MAIN_STREAM_RESOLUTION: Final = (800, 600)
|
||||
|
||||
Matrix4 = tuple[
|
||||
tuple[float, float, float, float],
|
||||
tuple[float, float, float, float],
|
||||
tuple[float, float, float, float],
|
||||
tuple[float, float, float, float],
|
||||
]
|
||||
|
||||
|
||||
class FactoryCalibrationSchemaError(ValueError):
|
||||
"""A factory calibration document is unsafe or outside the reviewed K1 schema."""
|
||||
|
||||
|
||||
class _UniqueSafeLoader(yaml.SafeLoader):
|
||||
pass
|
||||
|
||||
|
||||
def _construct_unique_mapping(
|
||||
loader: _UniqueSafeLoader,
|
||||
node: yaml.MappingNode,
|
||||
deep: bool = False,
|
||||
) -> dict[object, object]:
|
||||
mapping: dict[object, object] = {}
|
||||
for key_node, value_node in node.value:
|
||||
key = loader.construct_object(key_node, deep=deep)
|
||||
try:
|
||||
duplicate = key in mapping
|
||||
except TypeError as exc:
|
||||
raise ConstructorError(
|
||||
"while constructing a mapping",
|
||||
node.start_mark,
|
||||
"found an unhashable key",
|
||||
key_node.start_mark,
|
||||
) from exc
|
||||
if duplicate:
|
||||
raise ConstructorError(
|
||||
"while constructing a mapping",
|
||||
node.start_mark,
|
||||
f"found duplicate key {key!r}",
|
||||
key_node.start_mark,
|
||||
)
|
||||
mapping[key] = loader.construct_object(value_node, deep=deep)
|
||||
return mapping
|
||||
|
||||
|
||||
_UniqueSafeLoader.add_constructor(
|
||||
yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG,
|
||||
_construct_unique_mapping,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CameraCalibration:
|
||||
slot: str
|
||||
camera_model: str
|
||||
camera_pose: Matrix4
|
||||
distortion: tuple[float, float, float, float]
|
||||
image_width: int
|
||||
image_height: int
|
||||
intrinsic: tuple[float, float, float, float]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class K1FactoryCalibration:
|
||||
version: str
|
||||
cameras: tuple[
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
]
|
||||
t_camera_0_from_lidar: Matrix4
|
||||
|
||||
def camera(self, slot: str) -> CameraCalibration:
|
||||
for camera in self.cameras:
|
||||
if camera.slot == slot:
|
||||
return camera
|
||||
raise KeyError(slot)
|
||||
|
||||
def t_camera_from_lidar(self, slot: str) -> Matrix4:
|
||||
camera = self.camera(slot)
|
||||
return _matrix_multiply(
|
||||
_rigid_inverse(camera.camera_pose),
|
||||
self.t_camera_0_from_lidar,
|
||||
)
|
||||
|
||||
def normalized_profile(self) -> dict[str, object]:
|
||||
cameras: list[dict[str, object]] = []
|
||||
for camera in self.cameras:
|
||||
cameras.append(
|
||||
{
|
||||
"slot": camera.slot,
|
||||
"model": camera.camera_model,
|
||||
"native_resolution": [camera.image_width, camera.image_height],
|
||||
"intrinsic_fx_fy_cx_cy": list(camera.intrinsic),
|
||||
"distortion_kb4": list(camera.distortion),
|
||||
"serialized_camera_pose": {
|
||||
"direction": f"T_camera_0_from_{camera.slot}",
|
||||
"row_major": _matrix_as_lists(camera.camera_pose),
|
||||
},
|
||||
"t_camera_from_lidar": {
|
||||
"direction": f"T_{camera.slot}_from_lidar",
|
||||
"row_major": _matrix_as_lists(
|
||||
self.t_camera_from_lidar(camera.slot)
|
||||
),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
streams: dict[str, object] = {}
|
||||
target_width, target_height = ADMITTED_MAIN_STREAM_RESOLUTION
|
||||
for source_id, slot in MAIN_CAMERA_SLOT_BY_SOURCE.items():
|
||||
camera = self.camera(slot)
|
||||
scale_x = target_width / camera.image_width
|
||||
scale_y = target_height / camera.image_height
|
||||
streams[source_id] = {
|
||||
"calibration_slot": slot,
|
||||
"rtsp_path": MAIN_CAMERA_RTSP_PATH_BY_SOURCE[source_id],
|
||||
"native_resolution": [camera.image_width, camera.image_height],
|
||||
"admitted_resolution": [target_width, target_height],
|
||||
"image_transform": {
|
||||
"kind": "firmware-configured-linear-resize",
|
||||
"scale_x": scale_x,
|
||||
"scale_y": scale_y,
|
||||
"crop": None,
|
||||
"warp": None,
|
||||
},
|
||||
"admitted_intrinsic_fx_fy_cx_cy": [
|
||||
camera.intrinsic[0] * scale_x,
|
||||
camera.intrinsic[1] * scale_y,
|
||||
camera.intrinsic[2] * scale_x,
|
||||
camera.intrinsic[3] * scale_y,
|
||||
],
|
||||
"distortion_kb4": list(camera.distortion),
|
||||
"t_camera_from_lidar": {
|
||||
"direction": f"T_{slot}_from_lidar",
|
||||
"row_major": _matrix_as_lists(self.t_camera_from_lidar(slot)),
|
||||
},
|
||||
}
|
||||
|
||||
return {
|
||||
"schema_version": "missioncore.k1-normalized-calibration/v1",
|
||||
"vendor_schema_version": self.version,
|
||||
"transform_notation": "T_destination_from_source",
|
||||
"matrix_storage": "row-major-homogeneous-4x4",
|
||||
"translation_unit": "meter",
|
||||
"base_transform": {
|
||||
"direction": "T_camera_0_from_lidar",
|
||||
"row_major": _matrix_as_lists(self.t_camera_0_from_lidar),
|
||||
},
|
||||
"camera_pose_interpretation": (
|
||||
"serialized T_camera_0_from_camera_N; firmware xcolor inverts it "
|
||||
"before composing T_camera_N_from_lidar"
|
||||
),
|
||||
"cameras": cameras,
|
||||
"stream_bindings": streams,
|
||||
"mapping_proof": {
|
||||
"status": "firmware-profile-verified",
|
||||
"basis": [
|
||||
"K1 firmware main-camera declaration order",
|
||||
"xcolor camera_N sequential loader and main-topic order",
|
||||
"factory native resolutions matching K1 main/secondary profiles",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_k1_factory_calibration(
|
||||
camera_yaml: bytes,
|
||||
camera_lidar_yaml: bytes,
|
||||
) -> K1FactoryCalibration:
|
||||
camera_document = _load_reviewed_yaml(camera_yaml, "camera.yaml")
|
||||
extrinsic_document = _load_reviewed_yaml(
|
||||
camera_lidar_yaml,
|
||||
"extrinsic_camera_lidar.yaml",
|
||||
)
|
||||
camera_root = _exact_mapping(
|
||||
camera_document,
|
||||
{"calibrated", "version", *CAMERA_KEYS},
|
||||
"camera.yaml",
|
||||
)
|
||||
extrinsic_root = _exact_mapping(
|
||||
extrinsic_document,
|
||||
{"calibrated", "version", "transform"},
|
||||
"extrinsic_camera_lidar.yaml",
|
||||
)
|
||||
if camera_root["calibrated"] is not True:
|
||||
raise FactoryCalibrationSchemaError("camera.yaml is not marked calibrated")
|
||||
if extrinsic_root["calibrated"] is not True:
|
||||
raise FactoryCalibrationSchemaError(
|
||||
"extrinsic_camera_lidar.yaml is not marked calibrated"
|
||||
)
|
||||
camera_version = _short_text(camera_root["version"], "camera.yaml.version")
|
||||
extrinsic_version = _short_text(
|
||||
extrinsic_root["version"],
|
||||
"extrinsic_camera_lidar.yaml.version",
|
||||
)
|
||||
if camera_version != extrinsic_version:
|
||||
raise FactoryCalibrationSchemaError("factory calibration versions do not match")
|
||||
|
||||
parsed_cameras = tuple(
|
||||
_parse_camera(slot, camera_root[slot]) for slot in CAMERA_KEYS
|
||||
)
|
||||
camera_tuple = cast(
|
||||
tuple[
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
CameraCalibration,
|
||||
],
|
||||
parsed_cameras,
|
||||
)
|
||||
if not _matrix_close(camera_tuple[0].camera_pose, _identity_matrix(), 1e-6):
|
||||
raise FactoryCalibrationSchemaError("camera_0 pose must be the identity reference")
|
||||
|
||||
base_transform = _matrix4(
|
||||
extrinsic_root["transform"],
|
||||
"extrinsic_camera_lidar.yaml.transform",
|
||||
)
|
||||
_validate_rigid_transform(
|
||||
base_transform,
|
||||
"extrinsic_camera_lidar.yaml.transform",
|
||||
)
|
||||
return K1FactoryCalibration(
|
||||
version=camera_version,
|
||||
cameras=camera_tuple,
|
||||
t_camera_0_from_lidar=base_transform,
|
||||
)
|
||||
|
||||
|
||||
def _load_reviewed_yaml(payload: bytes, label: str) -> object:
|
||||
if len(payload) > MAX_FACTORY_YAML_BYTES:
|
||||
raise FactoryCalibrationSchemaError(f"{label} exceeds the parser byte bound")
|
||||
if b"\x00" in payload:
|
||||
raise FactoryCalibrationSchemaError(f"{label} contains NUL bytes")
|
||||
try:
|
||||
text = payload.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise FactoryCalibrationSchemaError(f"{label} is not valid UTF-8") from exc
|
||||
try:
|
||||
for token in yaml.scan(text, Loader=_UniqueSafeLoader):
|
||||
if isinstance(token, (AliasToken, AnchorToken, TagToken)):
|
||||
raise FactoryCalibrationSchemaError(
|
||||
f"{label} contains YAML anchors, aliases or explicit tags"
|
||||
)
|
||||
value = yaml.load(text, Loader=_UniqueSafeLoader)
|
||||
except FactoryCalibrationSchemaError:
|
||||
raise
|
||||
except yaml.YAMLError as exc:
|
||||
raise FactoryCalibrationSchemaError(f"{label} is not safe valid YAML") from exc
|
||||
return value
|
||||
|
||||
|
||||
def _exact_mapping(value: object, keys: set[str], label: str) -> dict[str, object]:
|
||||
if type(value) is not dict:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must be a mapping")
|
||||
mapping = cast(dict[object, object], value)
|
||||
if not all(type(key) is str for key in mapping):
|
||||
raise FactoryCalibrationSchemaError(f"{label} keys must be strings")
|
||||
typed = cast(dict[str, object], mapping)
|
||||
if set(typed) != keys:
|
||||
raise FactoryCalibrationSchemaError(f"{label} has an unexpected key set")
|
||||
return typed
|
||||
|
||||
|
||||
def _parse_camera(slot: str, value: object) -> CameraCalibration:
|
||||
label = f"camera.yaml.{slot}"
|
||||
node = _exact_mapping(
|
||||
value,
|
||||
{
|
||||
"camera_model",
|
||||
"camera_pose",
|
||||
"distortion",
|
||||
"image_height",
|
||||
"image_width",
|
||||
"intrinsic",
|
||||
},
|
||||
label,
|
||||
)
|
||||
model = _short_text(node["camera_model"], f"{label}.camera_model")
|
||||
if model != "kb4":
|
||||
raise FactoryCalibrationSchemaError(f"{label} must use the reviewed kb4 model")
|
||||
width = _positive_integer(node["image_width"], f"{label}.image_width")
|
||||
height = _positive_integer(node["image_height"], f"{label}.image_height")
|
||||
if (width, height) != EXPECTED_CAMERA_RESOLUTION[slot]:
|
||||
raise FactoryCalibrationSchemaError(f"{label} resolution does not match K1 FW 3.0.2")
|
||||
intrinsic = _float4(node["intrinsic"], f"{label}.intrinsic")
|
||||
fx, fy, cx, cy = intrinsic
|
||||
if fx <= 0 or fy <= 0 or not (0 <= cx <= width) or not (0 <= cy <= height):
|
||||
raise FactoryCalibrationSchemaError(f"{label}.intrinsic is not physically admissible")
|
||||
pose = _matrix4(node["camera_pose"], f"{label}.camera_pose")
|
||||
_validate_rigid_transform(pose, f"{label}.camera_pose")
|
||||
return CameraCalibration(
|
||||
slot=slot,
|
||||
camera_model=model,
|
||||
camera_pose=pose,
|
||||
distortion=_float4(node["distortion"], f"{label}.distortion"),
|
||||
image_width=width,
|
||||
image_height=height,
|
||||
intrinsic=intrinsic,
|
||||
)
|
||||
|
||||
|
||||
def _short_text(value: object, label: str) -> str:
|
||||
if type(value) is not str:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must be a string")
|
||||
text = value
|
||||
if not text or len(text) > 64 or any(ord(character) < 32 for character in text):
|
||||
raise FactoryCalibrationSchemaError(f"{label} is invalid")
|
||||
return text
|
||||
|
||||
|
||||
def _positive_integer(value: object, label: str) -> int:
|
||||
if type(value) is not int or value <= 0:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must be a positive integer")
|
||||
return value
|
||||
|
||||
|
||||
def _finite_number(value: object, label: str) -> float:
|
||||
if type(value) not in {int, float}:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must be numeric")
|
||||
number = float(cast(int | float, value))
|
||||
if not math.isfinite(number):
|
||||
raise FactoryCalibrationSchemaError(f"{label} must be finite")
|
||||
return number
|
||||
|
||||
|
||||
def _float4(value: object, label: str) -> tuple[float, float, float, float]:
|
||||
if type(value) is not list or len(cast(list[object], value)) != 4:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must contain exactly four numbers")
|
||||
numbers = tuple(
|
||||
_finite_number(item, f"{label}[{index}]")
|
||||
for index, item in enumerate(cast(list[object], value))
|
||||
)
|
||||
return cast(tuple[float, float, float, float], numbers)
|
||||
|
||||
|
||||
def _matrix4(value: object, label: str) -> Matrix4:
|
||||
if type(value) is not list or len(cast(list[object], value)) != 16:
|
||||
raise FactoryCalibrationSchemaError(f"{label} must contain exactly 16 numbers")
|
||||
numbers = [
|
||||
_finite_number(item, f"{label}[{index}]")
|
||||
for index, item in enumerate(cast(list[object], value))
|
||||
]
|
||||
return cast(
|
||||
Matrix4,
|
||||
tuple(tuple(numbers[row * 4 : row * 4 + 4]) for row in range(4)),
|
||||
)
|
||||
|
||||
|
||||
def _validate_rigid_transform(matrix: Matrix4, label: str) -> None:
|
||||
if not _matrix_close_row(matrix[3], (0.0, 0.0, 0.0, 1.0), 1e-6):
|
||||
raise FactoryCalibrationSchemaError(f"{label} has an invalid homogeneous row")
|
||||
rotation = tuple(tuple(matrix[row][column] for column in range(3)) for row in range(3))
|
||||
for left in range(3):
|
||||
for right in range(3):
|
||||
dot = sum(rotation[left][axis] * rotation[right][axis] for axis in range(3))
|
||||
expected = 1.0 if left == right else 0.0
|
||||
if abs(dot - expected) > 1e-3:
|
||||
raise FactoryCalibrationSchemaError(f"{label} rotation is not orthonormal")
|
||||
determinant = (
|
||||
rotation[0][0]
|
||||
* (rotation[1][1] * rotation[2][2] - rotation[1][2] * rotation[2][1])
|
||||
- rotation[0][1]
|
||||
* (rotation[1][0] * rotation[2][2] - rotation[1][2] * rotation[2][0])
|
||||
+ rotation[0][2]
|
||||
* (rotation[1][0] * rotation[2][1] - rotation[1][1] * rotation[2][0])
|
||||
)
|
||||
if abs(determinant - 1.0) > 1e-3:
|
||||
raise FactoryCalibrationSchemaError(f"{label} rotation determinant is not +1")
|
||||
if math.sqrt(sum(matrix[row][3] ** 2 for row in range(3))) > 10.0:
|
||||
raise FactoryCalibrationSchemaError(f"{label} translation is outside the meter bound")
|
||||
|
||||
|
||||
def _rigid_inverse(matrix: Matrix4) -> Matrix4:
|
||||
rotation_transpose = tuple(
|
||||
tuple(matrix[column][row] for column in range(3)) for row in range(3)
|
||||
)
|
||||
translation = tuple(matrix[row][3] for row in range(3))
|
||||
inverted_translation = tuple(
|
||||
-sum(rotation_transpose[row][axis] * translation[axis] for axis in range(3))
|
||||
for row in range(3)
|
||||
)
|
||||
return cast(
|
||||
Matrix4,
|
||||
tuple(
|
||||
tuple((*rotation_transpose[row], inverted_translation[row]))
|
||||
for row in range(3)
|
||||
)
|
||||
+ ((0.0, 0.0, 0.0, 1.0),),
|
||||
)
|
||||
|
||||
|
||||
def _matrix_multiply(left: Matrix4, right: Matrix4) -> Matrix4:
|
||||
return cast(
|
||||
Matrix4,
|
||||
tuple(
|
||||
tuple(
|
||||
sum(left[row][axis] * right[axis][column] for axis in range(4))
|
||||
for column in range(4)
|
||||
)
|
||||
for row in range(4)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _identity_matrix() -> Matrix4:
|
||||
return (
|
||||
(1.0, 0.0, 0.0, 0.0),
|
||||
(0.0, 1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0, 0.0),
|
||||
(0.0, 0.0, 0.0, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def _matrix_close(left: Matrix4, right: Matrix4, tolerance: float) -> bool:
|
||||
return all(
|
||||
abs(left[row][column] - right[row][column]) <= tolerance
|
||||
for row in range(4)
|
||||
for column in range(4)
|
||||
)
|
||||
|
||||
|
||||
def _matrix_close_row(
|
||||
left: tuple[float, float, float, float],
|
||||
right: tuple[float, float, float, float],
|
||||
tolerance: float,
|
||||
) -> bool:
|
||||
return all(abs(left[index] - right[index]) <= tolerance for index in range(4))
|
||||
|
||||
|
||||
def _matrix_as_lists(matrix: Matrix4) -> list[list[float]]:
|
||||
return [list(row) for row in matrix]
|
||||
@@ -0,0 +1,258 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.calibration_schema import (
|
||||
parse_k1_factory_calibration,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_execution import (
|
||||
ApplicationAuthorityLoader,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.calibration_file import (
|
||||
FACTORY_CAMERA_CALIBRATION_PATH,
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
||||
CalibrationFileContent,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.calibration_mqtt import (
|
||||
FactoryCalibrationReadResult,
|
||||
ReviewedCalibrationMqttReader,
|
||||
)
|
||||
|
||||
DEVICE_CALIBRATION_SCHEMA_VERSION = "missioncore.device-calibration/v1alpha2"
|
||||
CALIBRATION_SNAPSHOT_MANIFEST_VERSION = "missioncore.k1-calibration-snapshot/v1"
|
||||
CALIBRATION_CAPTURE_SUFFIX = "k1_factory_calibration"
|
||||
|
||||
CalibrationTransportFactory = Callable[[str, bool], ReviewedCalibrationMqttReader]
|
||||
|
||||
|
||||
class DeviceCalibrationSnapshotError(RuntimeError):
|
||||
"""A live factory-calibration snapshot could not be sealed safely."""
|
||||
|
||||
|
||||
class DeviceCalibrationSnapshotReader:
|
||||
"""Read two exact K1 files and seal an append-only private snapshot."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
authority_loader: ApplicationAuthorityLoader,
|
||||
*,
|
||||
compatibility_profile_id: str,
|
||||
transport_factory: CalibrationTransportFactory | None = None,
|
||||
) -> None:
|
||||
self._authority_loader = authority_loader
|
||||
self._compatibility_profile_id = compatibility_profile_id
|
||||
self._transport_factory = transport_factory or _default_transport_factory
|
||||
|
||||
def capture(
|
||||
self,
|
||||
*,
|
||||
host: str,
|
||||
evidence_root: Path,
|
||||
allow_device_ap: bool,
|
||||
) -> dict[str, object]:
|
||||
authority = self._authority_loader.load()
|
||||
transport = self._transport_factory(host, allow_device_ap)
|
||||
result = transport.read_factory_calibration(authority)
|
||||
return seal_factory_calibration_snapshot(
|
||||
result,
|
||||
evidence_root=evidence_root,
|
||||
compatibility_profile_id=self._compatibility_profile_id,
|
||||
)
|
||||
|
||||
|
||||
def unavailable_device_calibration_snapshot(
|
||||
compatibility_profile_id: str | None,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": DEVICE_CALIBRATION_SCHEMA_VERSION,
|
||||
"status": "unavailable",
|
||||
"compatibility_profile_id": compatibility_profile_id,
|
||||
"device_internal_calibration": None,
|
||||
"vehicle_extrinsics": "host-domain-not-owned-by-plugin",
|
||||
"reason_code": "device-calibration-not-observed",
|
||||
}
|
||||
|
||||
|
||||
def seal_factory_calibration_snapshot(
|
||||
result: FactoryCalibrationReadResult,
|
||||
*,
|
||||
evidence_root: Path,
|
||||
compatibility_profile_id: str,
|
||||
captured_at: datetime | None = None,
|
||||
) -> dict[str, object]:
|
||||
files_by_path = {item.path: item for item in result.files}
|
||||
if set(files_by_path) != {
|
||||
FACTORY_CAMERA_CALIBRATION_PATH,
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
||||
}:
|
||||
raise DeviceCalibrationSnapshotError(
|
||||
"factory calibration result does not contain the exact two-file set"
|
||||
)
|
||||
normalized_calibration = parse_k1_factory_calibration(
|
||||
files_by_path[FACTORY_CAMERA_CALIBRATION_PATH].content,
|
||||
files_by_path[FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH].content,
|
||||
).normalized_profile()
|
||||
|
||||
observed_at = (captured_at or datetime.now(UTC)).astimezone(UTC)
|
||||
captured_at_utc = observed_at.isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
snapshot_id = uuid4().hex
|
||||
content_identity = hashlib.sha256()
|
||||
content_identity.update(result.binding.vendor_device_id.encode("ascii"))
|
||||
content_identity.update(b"\x00")
|
||||
content_identity.update(result.binding.device_serial.encode("ascii"))
|
||||
for path in sorted(files_by_path):
|
||||
content_identity.update(b"\x00")
|
||||
content_identity.update(path.encode("utf-8"))
|
||||
content_identity.update(bytes.fromhex(files_by_path[path].content_sha256))
|
||||
|
||||
artifact_specs = (
|
||||
(
|
||||
FACTORY_CAMERA_CALIBRATION_PATH,
|
||||
"camera.yaml",
|
||||
files_by_path[FACTORY_CAMERA_CALIBRATION_PATH],
|
||||
),
|
||||
(
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
||||
"extrinsic_camera_lidar.yaml",
|
||||
files_by_path[FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH],
|
||||
),
|
||||
)
|
||||
artifact_documents = [
|
||||
_artifact_document(source_path, artifact_name, content)
|
||||
for source_path, artifact_name, content in artifact_specs
|
||||
]
|
||||
private_root = evidence_root.expanduser().resolve() / "private" / "device-calibration"
|
||||
_ensure_private_directory(private_root)
|
||||
stamp = observed_at.strftime("%Y%m%dT%H%M%SZ")
|
||||
final_name = f"{stamp}_{CALIBRATION_CAPTURE_SUFFIX}_{snapshot_id[:12]}"
|
||||
final_dir = private_root / final_name
|
||||
staging_dir = private_root / f".{final_name}.incomplete"
|
||||
try:
|
||||
staging_dir.mkdir(mode=0o700, exist_ok=False)
|
||||
for _source_path, artifact_name, content in artifact_specs:
|
||||
_write_exclusive(staging_dir / artifact_name, content.content)
|
||||
private_reference = str(final_dir)
|
||||
manifest = {
|
||||
"schema_version": CALIBRATION_SNAPSHOT_MANIFEST_VERSION,
|
||||
"snapshot_id": snapshot_id,
|
||||
"captured_at_utc": captured_at_utc,
|
||||
"compatibility_profile_id": compatibility_profile_id,
|
||||
"content_identity_sha256": content_identity.hexdigest(),
|
||||
"device": {
|
||||
"vendor_device_id": result.binding.vendor_device_id,
|
||||
"device_serial": result.binding.device_serial,
|
||||
"device_model": result.binding.device_model,
|
||||
"platform_type": result.binding.device_type,
|
||||
"software_version": result.binding.software_version,
|
||||
"system_version": result.binding.system_version,
|
||||
"is_activated": result.binding.is_activated,
|
||||
},
|
||||
"source": {
|
||||
"transport": "mqtt-protobuf-read-only",
|
||||
"request_command": 5,
|
||||
"write_command_available": False,
|
||||
"automatic_retry": False,
|
||||
"path_allowlist": [
|
||||
FACTORY_CAMERA_CALIBRATION_PATH,
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
||||
],
|
||||
"transport_snapshot": result.transport,
|
||||
},
|
||||
"artifacts": artifact_documents,
|
||||
"normalized_calibration": normalized_calibration,
|
||||
"storage": {
|
||||
"classification": "private-device-calibration",
|
||||
"append_only": True,
|
||||
"snapshot_path": private_reference,
|
||||
},
|
||||
}
|
||||
manifest_bytes = (
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True) + "\n"
|
||||
).encode("utf-8")
|
||||
_write_exclusive(staging_dir / "manifest.json", manifest_bytes)
|
||||
os.rename(staging_dir, final_dir)
|
||||
_fsync_directory(private_root)
|
||||
except Exception:
|
||||
with suppress(OSError):
|
||||
shutil.rmtree(staging_dir)
|
||||
raise
|
||||
|
||||
return {
|
||||
"schema_version": DEVICE_CALIBRATION_SCHEMA_VERSION,
|
||||
"status": "available",
|
||||
"compatibility_profile_id": compatibility_profile_id,
|
||||
"snapshot_id": snapshot_id,
|
||||
"captured_at_utc": captured_at_utc,
|
||||
"device_internal_calibration": {
|
||||
"source": "xgrids-factory-data-live-read",
|
||||
"device_serial": result.binding.device_serial,
|
||||
"firmware_version": result.binding.software_version,
|
||||
"content_identity_sha256": content_identity.hexdigest(),
|
||||
"documents": artifact_documents,
|
||||
"private_snapshot_path": str(final_dir),
|
||||
"normalized_calibration": normalized_calibration,
|
||||
"camera_stream_mapping": normalized_calibration["mapping_proof"],
|
||||
},
|
||||
"vehicle_extrinsics": "host-domain-not-owned-by-plugin",
|
||||
"reason_code": None,
|
||||
}
|
||||
|
||||
|
||||
def _default_transport_factory(host: str, allow_device_ap: bool) -> ReviewedCalibrationMqttReader:
|
||||
return ReviewedCalibrationMqttReader(host, allow_device_ap=allow_device_ap)
|
||||
|
||||
|
||||
def _artifact_document(
|
||||
source_path: str,
|
||||
artifact_name: str,
|
||||
content: CalibrationFileContent,
|
||||
) -> dict[str, object]:
|
||||
return {
|
||||
"source_path": source_path,
|
||||
"artifact_name": artifact_name,
|
||||
"sha256": content.content_sha256,
|
||||
"bytes": content.content_bytes,
|
||||
"encoding": "utf-8",
|
||||
}
|
||||
|
||||
|
||||
def _ensure_private_directory(path: Path) -> None:
|
||||
path.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
current = path
|
||||
while current.name in {"private", "device-calibration"}:
|
||||
with suppress(OSError):
|
||||
current.chmod(0o700)
|
||||
current = current.parent
|
||||
|
||||
|
||||
def _write_exclusive(path: Path, payload: bytes) -> None:
|
||||
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
||||
if hasattr(os, "O_NOFOLLOW"):
|
||||
flags |= os.O_NOFOLLOW
|
||||
descriptor = os.open(path, flags, 0o600)
|
||||
try:
|
||||
with os.fdopen(descriptor, "wb", closefd=False) as stream:
|
||||
stream.write(payload)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
flags = os.O_RDONLY
|
||||
if hasattr(os, "O_DIRECTORY"):
|
||||
flags |= os.O_DIRECTORY
|
||||
descriptor = os.open(path, flags)
|
||||
try:
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
@@ -7,6 +7,7 @@ import signal
|
||||
import subprocess
|
||||
import threading
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
@@ -52,6 +53,22 @@ class CameraProcessLease:
|
||||
failure_code: str | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CommittedCameraSegment:
|
||||
"""A camera fragment observed only after its raw archive commit."""
|
||||
|
||||
source_id: CameraSourceId
|
||||
generation: int
|
||||
kind: CameraArchiveKind
|
||||
sequence: int
|
||||
host_epoch_ns: int
|
||||
host_monotonic_ns: int
|
||||
payload: bytes
|
||||
|
||||
|
||||
CommittedCameraSegmentObserver = Callable[[CommittedCameraSegment], None]
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CameraProducer:
|
||||
generation: int
|
||||
@@ -78,7 +95,13 @@ class XgridsK1CameraGateway:
|
||||
Outside an acquisition the legacy lazy-preview lifecycle remains available.
|
||||
"""
|
||||
|
||||
def __init__(self, repository_root: Path, plugin_id: str) -> None:
|
||||
def __init__(
|
||||
self,
|
||||
repository_root: Path,
|
||||
plugin_id: str,
|
||||
*,
|
||||
committed_segment_observer: CommittedCameraSegmentObserver | None = None,
|
||||
) -> None:
|
||||
self._repository_root = repository_root.resolve()
|
||||
self._plugin_id = plugin_id
|
||||
self._lock = threading.RLock()
|
||||
@@ -94,6 +117,8 @@ class XgridsK1CameraGateway:
|
||||
self._archive_summaries: list[dict[str, Any]] = []
|
||||
self._error: dict[str, str] | None = None
|
||||
self._closed = False
|
||||
self._committed_segment_observer = committed_segment_observer
|
||||
self._committed_segment_observer_errors = 0
|
||||
self._ffmpeg_path, self._ffmpeg_source = _resolve_ffmpeg(self._repository_root)
|
||||
|
||||
def snapshot(self) -> dict[str, Any]:
|
||||
@@ -148,6 +173,7 @@ class XgridsK1CameraGateway:
|
||||
"source": self._ffmpeg_source,
|
||||
},
|
||||
"error": dict(self._error) if self._error is not None else None,
|
||||
"derived_observer_errors": self._committed_segment_observer_errors,
|
||||
}
|
||||
|
||||
def select(self, source_id: CameraSourceId, target_host: str) -> dict[str, Any]:
|
||||
@@ -531,7 +557,7 @@ class XgridsK1CameraGateway:
|
||||
if archive is not None:
|
||||
try:
|
||||
# Source of record first; preview is always expendable.
|
||||
archive.append(kind, payload)
|
||||
committed = archive.append(kind, payload)
|
||||
except (CameraArchiveError, OSError, ValueError):
|
||||
self._mark_producer_failure(
|
||||
producer,
|
||||
@@ -539,6 +565,26 @@ class XgridsK1CameraGateway:
|
||||
"Долговременная запись camera stream завершилась ошибкой.",
|
||||
)
|
||||
return False
|
||||
observer = self._committed_segment_observer
|
||||
if observer is not None:
|
||||
try:
|
||||
observer(
|
||||
CommittedCameraSegment(
|
||||
source_id=producer.source_id,
|
||||
generation=producer.generation,
|
||||
kind=kind,
|
||||
sequence=int(committed["sequence"]),
|
||||
host_epoch_ns=int(committed["host_epoch_ns"]),
|
||||
host_monotonic_ns=int(committed["host_monotonic_ns"]),
|
||||
payload=payload,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
# Derived diagnostics can be dropped, but can never fail or
|
||||
# back-pressure the authoritative camera recording.
|
||||
with self._lock:
|
||||
self._committed_segment_observer_errors += 1
|
||||
self._revision += 1
|
||||
|
||||
with self._lock:
|
||||
producer_owned = self._producer is producer or (
|
||||
|
||||
@@ -21,6 +21,8 @@ from k1link.compute import prepare_camera_compute_job
|
||||
from k1link.device_plugins.xgrids_k1.analyze import (
|
||||
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
||||
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
||||
CalibratedOverlayExperimentError,
|
||||
run_calibrated_overlay_experiment,
|
||||
summarize_mqtt_streams,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.archive import discover_legacy_viewer_sessions
|
||||
@@ -31,6 +33,10 @@ from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
WriteMode,
|
||||
provision_wifi_once,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.firmware_credential import (
|
||||
FirmwareCredentialError,
|
||||
import_k1_fw302_ap_credential,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import (
|
||||
DEFAULT_MAX_MESSAGE_BYTES,
|
||||
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||
@@ -151,6 +157,53 @@ def authority_provision(
|
||||
)
|
||||
|
||||
|
||||
@authority_app.command("import-k1-fw302-ap")
|
||||
def import_k1_fw302_ap(
|
||||
firmware: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
exists=True,
|
||||
dir_okay=False,
|
||||
readable=True,
|
||||
resolve_path=True,
|
||||
help="Official XGRIDS K1 3.0.2 full firmware archive.",
|
||||
),
|
||||
],
|
||||
confirm_reviewed_firmware: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--confirm-reviewed-firmware",
|
||||
help="Confirm offline import from the exact reviewed official artifact.",
|
||||
),
|
||||
] = False,
|
||||
) -> None:
|
||||
"""Install the exact K1 AP material in the OS credential store."""
|
||||
|
||||
if not confirm_reviewed_firmware:
|
||||
console.print(
|
||||
"[red]Firmware credential import not confirmed.[/red] "
|
||||
"Add --confirm-reviewed-firmware for the reviewed official 3.0.2 image."
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
helper = (
|
||||
Path(__file__).resolve().parents[4]
|
||||
/ "plugins"
|
||||
/ "xgrids-k1"
|
||||
/ "macos"
|
||||
/ "associate_wifi.swift"
|
||||
)
|
||||
try:
|
||||
result = import_k1_fw302_ap_credential(firmware, helper)
|
||||
except (FirmwareCredentialError, OSError, ValueError) as exc:
|
||||
console.print(f"[red]Firmware credential import failed:[/red] {exc}")
|
||||
raise typer.Exit(code=2) from exc
|
||||
console.print(
|
||||
"[green]Exact firmware credential provider installed.[/green] "
|
||||
f"provider={result.provider_id!r}; adapter={result.host_adapter!r}; "
|
||||
"secret_exposed=false; no K1 command was sent."
|
||||
)
|
||||
|
||||
|
||||
def _default_route_interface() -> str | None:
|
||||
output = _command_output(["route", "-n", "get", "default"])
|
||||
if output is None:
|
||||
@@ -463,7 +516,7 @@ def net_mqtt_capture(
|
||||
] = 1883,
|
||||
duration: Annotated[
|
||||
float,
|
||||
typer.Option(min=1.0, max=3600.0, help="Capture duration after SUBACK, in seconds."),
|
||||
typer.Option(min=1.0, help="Capture duration after SUBACK, in seconds."),
|
||||
] = 60.0,
|
||||
max_message_bytes: Annotated[
|
||||
int,
|
||||
@@ -587,6 +640,90 @@ def analyze_mqtt_streams(
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
@analyze_app.command("calibrated-overlay")
|
||||
def analyze_calibrated_overlay(
|
||||
session_root: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--session-root",
|
||||
exists=True,
|
||||
file_okay=False,
|
||||
dir_okay=True,
|
||||
readable=True,
|
||||
resolve_path=True,
|
||||
help="Sealed observation session containing MQTT and camera evidence.",
|
||||
),
|
||||
],
|
||||
calibration_snapshot: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--calibration-snapshot",
|
||||
exists=True,
|
||||
file_okay=False,
|
||||
dir_okay=True,
|
||||
readable=True,
|
||||
resolve_path=True,
|
||||
help="Private physical K1 factory-calibration snapshot directory.",
|
||||
),
|
||||
],
|
||||
out_root: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--out-root",
|
||||
help="Mission Core evidence root; the result is sealed below private/.",
|
||||
),
|
||||
],
|
||||
source_id: Annotated[
|
||||
str,
|
||||
typer.Option("--source", help="Canonical archived K1 main-camera source id."),
|
||||
] = "sensor.camera.right",
|
||||
video_offsets: Annotated[
|
||||
list[float] | None,
|
||||
typer.Option(
|
||||
"--video-offset",
|
||||
help="Repeat for each diagnostic second inside the camera epoch.",
|
||||
),
|
||||
] = None,
|
||||
temporal_offset_seconds: Annotated[
|
||||
float,
|
||||
typer.Option(
|
||||
"--temporal-offset",
|
||||
min=-5.0,
|
||||
max=5.0,
|
||||
help="Explicit LiDAR minus camera host-arrival offset for experiments.",
|
||||
),
|
||||
] = 0.0,
|
||||
) -> None:
|
||||
"""Build a read-only LiDAR→KB4→camera diagnostic from sealed evidence."""
|
||||
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg is None:
|
||||
console.print("[red]Calibrated overlay failed:[/red] ffmpeg is unavailable")
|
||||
raise typer.Exit(code=2)
|
||||
offsets = tuple(video_offsets or (60.0, 180.0, 300.0, 420.0))
|
||||
try:
|
||||
result = run_calibrated_overlay_experiment(
|
||||
session_root=session_root,
|
||||
calibration_snapshot_root=calibration_snapshot,
|
||||
source_id=source_id,
|
||||
video_offsets_seconds=offsets,
|
||||
output_root=out_root,
|
||||
ffmpeg_path=Path(ffmpeg),
|
||||
temporal_offset_seconds=temporal_offset_seconds,
|
||||
)
|
||||
except (CalibratedOverlayExperimentError, OSError, RuntimeError, ValueError) as exc:
|
||||
console.print(f"[red]Calibrated overlay failed:[/red] {type(exc).__name__}: {exc}")
|
||||
raise typer.Exit(code=2) from exc
|
||||
console.print(
|
||||
f"[green]Calibrated diagnostic ready:[/green] {result.experiment_id}; "
|
||||
f"frames: {result.frame_count}; source: {result.source_id}"
|
||||
)
|
||||
console.print(f"Calibration: {result.calibration_content_identity}")
|
||||
console.print(f"Rerun: {result.rerun_path}")
|
||||
console.print(f"Mosaic: {result.mosaic_path}")
|
||||
console.print("P0 remains open until static-landmark reprojection is measured.")
|
||||
|
||||
|
||||
@compute_app.command("prepare-camera-job")
|
||||
def prepare_camera_job(
|
||||
session_root: Annotated[
|
||||
|
||||
@@ -32,20 +32,36 @@ from pydantic import (
|
||||
)
|
||||
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.compute.live_perception import LivePerceptionIngress
|
||||
from k1link.device_plugins.xgrids_k1.ble.ap_activation import (
|
||||
device_ap_activation_session,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.ble.scanner import scan
|
||||
from k1link.device_plugins.xgrids_k1.ble.wifi_provisioning import (
|
||||
AP_FALLBACK_IPV4,
|
||||
provision_wifi_once,
|
||||
read_wifi_status_once,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.calibration_snapshot import (
|
||||
DeviceCalibrationSnapshotReader,
|
||||
unavailable_device_calibration_snapshot,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.camera import (
|
||||
CAMERA_EXCLUSIVE_GROUP,
|
||||
CAMERA_SOURCE_LABELS,
|
||||
CAMERA_SOURCE_PATHS,
|
||||
CameraSourceId,
|
||||
CommittedCameraSegment,
|
||||
XgridsK1CameraGateway,
|
||||
build_xgrids_k1_camera_router,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.macos_wifi import associate_with_wifi_once
|
||||
from k1link.device_plugins.xgrids_k1.firmware_credential import (
|
||||
K1_FW302_CREDENTIAL_PROVIDER_ID,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.live_perception_shadow import (
|
||||
build_live_perception_shadow_router,
|
||||
ensure_live_shadow_token,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||
from k1link.device_plugins.xgrids_k1.mqtt.capture import seal_capture_clock
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_authority import (
|
||||
@@ -71,11 +87,19 @@ from k1link.device_plugins.xgrids_k1.protocol.modeling_safety import (
|
||||
LiveModelingControlSafety,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.normalizer import normalize_k1_message
|
||||
from k1link.device_plugins.xgrids_k1.quick_connect_profile import (
|
||||
quick_connect_host_profile_id,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.viewer.messages import StreamMessage
|
||||
from k1link.device_plugins.xgrids_k1.viewer.runtime import (
|
||||
VisualizationRuntime,
|
||||
new_live_session_dir,
|
||||
)
|
||||
from k1link.host_network import (
|
||||
HostWifiProfileError,
|
||||
associate_with_wifi_profile_once,
|
||||
ensure_wifi_profile_from_credential_source,
|
||||
)
|
||||
from k1link.sessions import ActiveSessionLease, resolve_missioncore_evidence_dir
|
||||
from k1link.viewer.metrics import BridgeMetrics
|
||||
from k1link.viewer.rerun_bridge import RerunSceneSettings
|
||||
@@ -203,8 +227,8 @@ class CompatibilityAttestationRequest(StrictRequest):
|
||||
|
||||
class ConnectRequest(StrictRequest):
|
||||
device_id: str = Field(min_length=1, max_length=128)
|
||||
ssid: str = Field(min_length=1, max_length=128)
|
||||
password: SecretStr = Field(min_length=1, max_length=256)
|
||||
ssid: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
password: SecretStr | None = Field(default=None, min_length=1, max_length=256)
|
||||
connection_mode: ConnectionMode = "bridge"
|
||||
compatibility_attestation: CompatibilityAttestationRequest
|
||||
operation_id: str | None = Field(default=None, min_length=1, max_length=128)
|
||||
@@ -212,24 +236,35 @@ class ConnectRequest(StrictRequest):
|
||||
|
||||
@model_validator(mode="after")
|
||||
def validate_connection_topology(self) -> Self:
|
||||
expected = CONNECTION_TOPOLOGY_BY_MODE[self.connection_mode]
|
||||
if self.compatibility_attestation.topology != expected:
|
||||
raise ValueError(
|
||||
f"connection_mode={self.connection_mode} requires topology={expected}"
|
||||
)
|
||||
if self.connection_mode == "quick-connect":
|
||||
if self.ssid is not None or self.password is not None:
|
||||
raise ValueError(
|
||||
"Quick Connect resolves its credential from the host Wi-Fi profile"
|
||||
)
|
||||
return self
|
||||
if self.ssid is None or self.password is None:
|
||||
raise ValueError("SSID and Wi-Fi password are required for this connection mode")
|
||||
if not 1 <= len(self.ssid.encode("utf-8")) <= 32:
|
||||
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
if not 1 <= len(self.password.get_secret_value().encode("utf-8")) <= 64:
|
||||
raise ValueError(
|
||||
"Wi-Fi password must contain between 1 and 64 UTF-8 bytes"
|
||||
)
|
||||
expected = CONNECTION_TOPOLOGY_BY_MODE[self.connection_mode]
|
||||
if self.compatibility_attestation.topology != expected:
|
||||
raise ValueError(
|
||||
f"connection_mode={self.connection_mode} requires topology={expected}"
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class LiveRequest(StrictRequest):
|
||||
project_name: str = Field(min_length=1, max_length=96)
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||
# Omitted means run until the operator explicitly stops the acquisition.
|
||||
# A positive value remains available to compatibility clients that need a
|
||||
# bounded capture, but there is no application-level maximum.
|
||||
duration_seconds: float | None = Field(default=None, ge=1.0, allow_inf_nan=False)
|
||||
compatibility_attestation: CompatibilityAttestationRequest
|
||||
|
||||
@field_validator("project_name")
|
||||
@@ -269,7 +304,7 @@ class PrepareAcquisitionRequest(OperationContextRequest):
|
||||
mount_type: Literal["handheld"] = "handheld"
|
||||
gnss_mode: Literal["none"] = "none"
|
||||
host: str | None = Field(default=None, max_length=15)
|
||||
duration_seconds: float = Field(default=3600.0, ge=1.0, le=86_400.0)
|
||||
duration_seconds: float | None = Field(default=None, ge=1.0, allow_inf_nan=False)
|
||||
requested_streams: tuple[RequestedStreamId, ...] = DEFAULT_LIVE_STREAMS
|
||||
evidence_policy: Literal["required", "best-effort", "disabled"] = "required"
|
||||
compatibility_attestation: CompatibilityAttestationRequest
|
||||
@@ -322,6 +357,9 @@ class ViewerSettingsRequest(StrictRequest):
|
||||
show_points: bool = True
|
||||
show_trajectory: bool = True
|
||||
show_grid: bool = True
|
||||
show_detections_2d: bool = False
|
||||
show_segmentation: bool = False
|
||||
show_cuboids_3d: bool = False
|
||||
|
||||
|
||||
class ShadowApplicationControlArmRequest(StrictRequest):
|
||||
@@ -354,12 +392,14 @@ class XgridsK1CompatibilityService:
|
||||
repository_root: Path,
|
||||
*,
|
||||
application_authority_loader: ApplicationAuthorityLoader | None = None,
|
||||
calibration_snapshot_reader: DeviceCalibrationSnapshotReader | None = None,
|
||||
) -> None:
|
||||
self.repository_root = repository_root.resolve()
|
||||
self.evidence_root = resolve_missioncore_evidence_dir(self.repository_root)
|
||||
self._lock = threading.Lock()
|
||||
self._acquisition_lifecycle_gate = threading.RLock()
|
||||
self._provisioning_gate = threading.Lock()
|
||||
self._calibration_gate = threading.Lock()
|
||||
self._provisioning_active = False
|
||||
self._fingerprint_key = secrets.token_bytes(32)
|
||||
self._devices: list[dict[str, Any]] = []
|
||||
@@ -391,9 +431,22 @@ class XgridsK1CompatibilityService:
|
||||
# The host-owned visual runtime receives the vendor normalizer
|
||||
# explicitly. There is no implicit K1 decoder in the visual layer.
|
||||
self._modeling_control_safety = LiveModelingControlSafety()
|
||||
self.live_perception_ingress = LivePerceptionIngress()
|
||||
(
|
||||
self.live_perception_token_path,
|
||||
self._live_perception_token,
|
||||
) = ensure_live_shadow_token(self.repository_root)
|
||||
authority_loader = (
|
||||
application_authority_loader or MacOSKeychainApplicationAuthorityLoader()
|
||||
)
|
||||
self._calibration_snapshot_reader = (
|
||||
calibration_snapshot_reader
|
||||
or DeviceCalibrationSnapshotReader(
|
||||
authority_loader,
|
||||
compatibility_profile_id=XGRIDS_K1_COMPATIBILITY_PROFILE_ID,
|
||||
)
|
||||
)
|
||||
self._device_calibration = unavailable_device_calibration_snapshot(None)
|
||||
self._application_control = DormantApplicationControlCoordinator(
|
||||
authority_loader,
|
||||
WriteDisabledOneShotPublisher(UninstalledApplicationPublishSink()),
|
||||
@@ -409,6 +462,7 @@ class XgridsK1CompatibilityService:
|
||||
self.camera_preview = XgridsK1CameraGateway(
|
||||
self.repository_root,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
committed_segment_observer=self._observe_committed_camera_segment,
|
||||
)
|
||||
|
||||
def _application_control_transport(self, host: str) -> ReviewedApplicationMqttTransport:
|
||||
@@ -445,6 +499,7 @@ class XgridsK1CompatibilityService:
|
||||
if self._compatibility_attestation is not None
|
||||
else None
|
||||
)
|
||||
device_calibration = dict(self._device_calibration)
|
||||
acquisition = self._acquisition.as_dict() if self._acquisition is not None else None
|
||||
if acquisition is not None:
|
||||
acquisition["project_name"] = self._acquisition_project_name
|
||||
@@ -487,6 +542,8 @@ class XgridsK1CompatibilityService:
|
||||
active_profile_id = (
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID if compatibility_attestation is not None else None
|
||||
)
|
||||
if device_calibration.get("status") != "available":
|
||||
device_calibration = unavailable_device_calibration_snapshot(active_profile_id)
|
||||
active_control = application_control_session["state"] not in {
|
||||
"idle",
|
||||
"completed",
|
||||
@@ -559,8 +616,9 @@ class XgridsK1CompatibilityService:
|
||||
device_session_id,
|
||||
camera_preview,
|
||||
),
|
||||
"device_calibration": _device_calibration_snapshot(active_profile_id),
|
||||
"device_calibration": device_calibration,
|
||||
"camera_preview": camera_preview,
|
||||
"live_perception_shadow": self.live_perception_ingress.snapshot(),
|
||||
"acquisition": acquisition,
|
||||
"operations": operation_documents,
|
||||
"last_operation": operation_documents[-1] if operation_documents else None,
|
||||
@@ -577,6 +635,11 @@ class XgridsK1CompatibilityService:
|
||||
"frame_rate_hz": metrics["pcl_fps"],
|
||||
"point_count": metrics["last_point_count"],
|
||||
"dropped_preview_frames": metrics["preview_dropped"],
|
||||
"ai_end_to_end_ms": metrics.get("perception_end_to_end_ms"),
|
||||
"ai_end_to_end_p95_ms": metrics.get("perception_end_to_end_p95_ms"),
|
||||
"ai_frame_rate_hz": metrics.get("perception_fps"),
|
||||
"ai_dropped_frames": metrics.get("perception_dropped"),
|
||||
"ai_stale_ms": metrics.get("perception_stale_ms"),
|
||||
**metrics,
|
||||
},
|
||||
}
|
||||
@@ -639,17 +702,45 @@ class XgridsK1CompatibilityService:
|
||||
known_ids = {str(item["device_id"]) for item in self.state()["devices"]}
|
||||
if request.device_id not in known_ids:
|
||||
raise ValueError("сначала найдите и выберите устройство через Bluetooth")
|
||||
# Unwrap once at the network service boundary. The plain value is kept
|
||||
# only in this stack frame, included in a keyed request digest, and
|
||||
# passed either to the reviewed BLE write or to the short-lived macOS
|
||||
# CoreWLAN helper over stdin; it is never journaled.
|
||||
password = request.password.get_secret_value()
|
||||
quick_connect = request.connection_mode == "quick-connect"
|
||||
selected_device = next(
|
||||
item for item in self.state()["devices"] if item["device_id"] == request.device_id
|
||||
)
|
||||
selected_device_name = str(selected_device.get("name") or "").strip()
|
||||
if quick_connect and not selected_device_name:
|
||||
raise ValueError(
|
||||
"выбранный BLE-кандидат не сообщил имя точки доступа; "
|
||||
"Quick Connect остановлен без команды устройству"
|
||||
)
|
||||
quick_connect_profile_id = (
|
||||
quick_connect_host_profile_id(selected_device_name) if quick_connect else None
|
||||
)
|
||||
host_wifi_helper_path = (
|
||||
self.repository_root
|
||||
/ "plugins"
|
||||
/ "xgrids-k1"
|
||||
/ "macos"
|
||||
/ "associate_wifi.swift"
|
||||
)
|
||||
# Bridge and Direct Connect unwrap once at the BLE service boundary.
|
||||
# Quick Connect carries no browser/API credential. It first sends the
|
||||
# reviewed fixed AP-enable command, then the host-network adapter uses
|
||||
# the selected device's advertised name as its exact SSID and resolves
|
||||
# a device-scoped profile inside the OS credential store.
|
||||
password = (
|
||||
""
|
||||
if request.password is None
|
||||
else request.password.get_secret_value()
|
||||
)
|
||||
request_fingerprint = self._request_fingerprint(
|
||||
ACTION_NETWORK_PROVISION,
|
||||
{
|
||||
"device_id": request.device_id,
|
||||
"ssid": request.ssid,
|
||||
"password": password,
|
||||
"password": password if not quick_connect else None,
|
||||
"host_wifi_profile": (
|
||||
quick_connect_profile_id if quick_connect else None
|
||||
),
|
||||
"connection_mode": request.connection_mode,
|
||||
"compatibility_attestation": request.compatibility_attestation.model_dump(
|
||||
mode="json"
|
||||
@@ -662,7 +753,7 @@ class XgridsK1CompatibilityService:
|
||||
idempotency_key=request.idempotency_key,
|
||||
device_id=self._device_id,
|
||||
device_session_id=self._device_session_id,
|
||||
deadline_seconds=60.0,
|
||||
deadline_seconds=240.0,
|
||||
request_fingerprint=request_fingerprint,
|
||||
)
|
||||
if not created:
|
||||
@@ -688,8 +779,34 @@ class XgridsK1CompatibilityService:
|
||||
|
||||
session_dir: Path | None = None
|
||||
network_change_attempted = False
|
||||
quick_connect = request.connection_mode == "quick-connect"
|
||||
operation_stage = (
|
||||
"device-ap-activation" if quick_connect else "ble-provisioning-write"
|
||||
)
|
||||
try:
|
||||
if quick_connect:
|
||||
assert quick_connect_profile_id is not None
|
||||
operation_stage = "host-wifi-profile-preflight"
|
||||
self._set_operation(
|
||||
"credential_preflight",
|
||||
"Проверяем локальный профиль выбранного K1 до команды устройству.",
|
||||
)
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"running",
|
||||
stage_code=operation_stage,
|
||||
message_code="network.provision.host_wifi_profile_preflight",
|
||||
)
|
||||
profile_preflight = await asyncio.to_thread(
|
||||
ensure_wifi_profile_from_credential_source,
|
||||
host_wifi_helper_path,
|
||||
quick_connect_profile_id,
|
||||
selected_device_name,
|
||||
K1_FW302_CREDENTIAL_PROVIDER_ID,
|
||||
timeout_seconds=30.0,
|
||||
)
|
||||
if not profile_preflight["available"]:
|
||||
raise HostWifiProfileError("credential-source-unavailable")
|
||||
|
||||
with self._lock:
|
||||
active_acquisition = self._acquisition
|
||||
if (
|
||||
@@ -708,6 +825,7 @@ class XgridsK1CompatibilityService:
|
||||
self._k1_ip = None
|
||||
self._connection_mode = None
|
||||
self._compatibility_attestation = None
|
||||
self._device_calibration = unavailable_device_calibration_snapshot(None)
|
||||
self._device_session_id = None
|
||||
self._device_session_opened_at = None
|
||||
self._connection_verification = {
|
||||
@@ -725,7 +843,7 @@ class XgridsK1CompatibilityService:
|
||||
self.camera_preview.stop_current()
|
||||
|
||||
operation_message = (
|
||||
"Подключаем этот Mac к точке доступа выбранного K1 одним запросом."
|
||||
"Включаем точку доступа выбранного K1 и подключаем к ней управляющее устройство."
|
||||
if quick_connect
|
||||
else "Передаём устройству настройки Wi-Fi одним подтверждённым запросом."
|
||||
)
|
||||
@@ -740,38 +858,102 @@ class XgridsK1CompatibilityService:
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"running",
|
||||
stage_code=(
|
||||
"host-wifi-association" if quick_connect else "ble-provisioning-write"
|
||||
),
|
||||
stage_code=operation_stage,
|
||||
message_code="network.provision.running",
|
||||
)
|
||||
network_change_attempted = True
|
||||
if quick_connect:
|
||||
started_at = _utc_now_iso()
|
||||
association = await asyncio.to_thread(
|
||||
associate_with_wifi_once,
|
||||
self.repository_root
|
||||
/ "plugins"
|
||||
/ "xgrids-k1"
|
||||
/ "macos"
|
||||
/ "associate_wifi.swift",
|
||||
request.ssid,
|
||||
password,
|
||||
timeout_seconds=45.0,
|
||||
)
|
||||
assert quick_connect_profile_id is not None
|
||||
operation_stage = "device-ap-activation"
|
||||
async with device_ap_activation_session(
|
||||
request.device_id,
|
||||
timeout_seconds=15.0,
|
||||
write_mode="auto",
|
||||
) as activation:
|
||||
write_json_atomic(
|
||||
session_dir / "ap-activation.redacted.json",
|
||||
activation,
|
||||
)
|
||||
if not activation["ready_observed"]:
|
||||
raise RuntimeError(
|
||||
"K1 не подтвердил готовность точки доступа; "
|
||||
"системное подключение Wi-Fi не запускалось"
|
||||
)
|
||||
operation_stage = "host-wifi-association"
|
||||
self._operations.transition(
|
||||
operation.operation_id,
|
||||
"running",
|
||||
stage_code=operation_stage,
|
||||
message_code="network.provision.host_wifi_association",
|
||||
)
|
||||
# Keep the same BLE connection alive through the native
|
||||
# CoreWLAN discovery/association handoff, as the reviewed
|
||||
# LixelGO Quick Connect flow does.
|
||||
try:
|
||||
association = await asyncio.to_thread(
|
||||
associate_with_wifi_profile_once,
|
||||
host_wifi_helper_path,
|
||||
quick_connect_profile_id,
|
||||
selected_device_name,
|
||||
scan_timeout_seconds=15.0,
|
||||
timeout_seconds=180.0,
|
||||
)
|
||||
except HostWifiProfileError as exc:
|
||||
write_json_atomic(
|
||||
session_dir / "host-wifi-association.redacted.json",
|
||||
{
|
||||
"schema_version": 1,
|
||||
"completed_at_utc": _utc_now_iso(),
|
||||
"adapter": "CoreWLAN",
|
||||
"outcome": "failed",
|
||||
"reason_code": exc.reason_code,
|
||||
"scan_attempt_count": exc.scan_attempt_count,
|
||||
"scan_elapsed_ms": exc.scan_elapsed_ms,
|
||||
},
|
||||
)
|
||||
raise
|
||||
completed_at = _utc_now_iso()
|
||||
ipv4: str | None = AP_FALLBACK_IPV4
|
||||
connection_manifest: dict[str, Any] = {
|
||||
"schema_version": 1,
|
||||
"started_at_utc": started_at,
|
||||
"started_at_utc": activation["started_at_utc"],
|
||||
"completed_at_utc": completed_at,
|
||||
"operation": "single_corewlan_k1_ap_association",
|
||||
"operation": "single_k1_ap_activation_and_host_profile_association",
|
||||
"connection_mode": request.connection_mode,
|
||||
"topology": request.compatibility_attestation.topology,
|
||||
"outcome": association["outcome"],
|
||||
"credentials_persisted_by_connector": False,
|
||||
"device_ap_activation_profile_id": activation["profile_id"],
|
||||
"device_ap_activation_outcome": activation["outcome"],
|
||||
"device_ap_ready_observed": activation["ready_observed"],
|
||||
"device_ap_activation_write_performed": activation["write_performed"],
|
||||
"device_ap_activation_write_mode": activation["write_mode"],
|
||||
"host_wifi_adapter": association["adapter"],
|
||||
"host_wifi_profile_id": quick_connect_profile_id,
|
||||
"host_wifi_profile_ready_before_device_write": profile_preflight[
|
||||
"available"
|
||||
],
|
||||
"host_wifi_profile_preflight_adapter": profile_preflight["adapter"],
|
||||
"host_wifi_profile_materialized_before_device_write": profile_preflight[
|
||||
"profile_enrolled"
|
||||
],
|
||||
"credential_provider_id": K1_FW302_CREDENTIAL_PROVIDER_ID,
|
||||
"credential_provider_source": profile_preflight[
|
||||
"credential_source"
|
||||
],
|
||||
"host_wifi_profile_enrolled_now": association["profile_enrolled"],
|
||||
"host_wifi_scan_attempt_count": association["scan_attempt_count"],
|
||||
"host_wifi_scan_elapsed_ms": association["scan_elapsed_ms"],
|
||||
"host_wifi_credential_source": association["credential_source"],
|
||||
"device_ap_ssid_source": "selected-ble-advertised-name",
|
||||
"credentials_resolved_by_plugin": (
|
||||
profile_preflight["credential_source"]
|
||||
== "exact-firmware-profile"
|
||||
),
|
||||
"credentials_persisted_by_host_adapter": True,
|
||||
}
|
||||
else:
|
||||
if request.ssid is None:
|
||||
raise RuntimeError("SSID отсутствует после проверки запроса")
|
||||
result = await provision_wifi_once(
|
||||
request.device_id,
|
||||
request.ssid,
|
||||
@@ -825,6 +1007,9 @@ class XgridsK1CompatibilityService:
|
||||
self._compatibility_attestation = _attestation_snapshot(
|
||||
request.compatibility_attestation
|
||||
)
|
||||
self._device_calibration = unavailable_device_calibration_snapshot(
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
)
|
||||
self._connection_verification = {
|
||||
"status": "not-probed",
|
||||
"endpoint_validation": "not-performed",
|
||||
@@ -863,7 +1048,7 @@ class XgridsK1CompatibilityService:
|
||||
self._operations.transition_if_pending(
|
||||
operation.operation_id,
|
||||
"failed",
|
||||
stage_code="failed",
|
||||
stage_code=f"{operation_stage}-failed",
|
||||
message_code="network.provision.failed",
|
||||
error=_operation_error(
|
||||
exc,
|
||||
@@ -896,26 +1081,143 @@ class XgridsK1CompatibilityService:
|
||||
message: StreamMessage,
|
||||
metrics: BridgeMetrics,
|
||||
) -> bool:
|
||||
self._observe_live_perception_mqtt(message)
|
||||
if self._modeling_control_safety.observe(message, metrics):
|
||||
return True
|
||||
return observe_modeling_report(message, metrics)
|
||||
|
||||
def _observe_live_perception_mqtt(self, message: StreamMessage) -> None:
|
||||
if message.source != "live_mqtt":
|
||||
return
|
||||
if message.topic.endswith("/lio_pcl") or message.topic == "RealtimePointcloud":
|
||||
modality: Literal["lidar", "pose"] = "lidar"
|
||||
elif message.topic.endswith("/lio_pose") or message.topic == "RealtimePath":
|
||||
modality = "pose"
|
||||
else:
|
||||
return
|
||||
self.live_perception_ingress.publish(
|
||||
modality=modality,
|
||||
source_id=message.topic,
|
||||
source_sequence=message.sequence,
|
||||
captured_at_epoch_ns=message.received_at_epoch_ns,
|
||||
received_monotonic_ns=(
|
||||
message.received_monotonic_ns
|
||||
if message.received_monotonic_ns is not None
|
||||
else time.monotonic_ns()
|
||||
),
|
||||
payload=message.payload,
|
||||
)
|
||||
|
||||
def _observe_committed_camera_segment(
|
||||
self,
|
||||
segment: CommittedCameraSegment,
|
||||
) -> None:
|
||||
self.live_perception_ingress.publish(
|
||||
modality="camera-init" if segment.kind == "init" else "camera-frame",
|
||||
source_id=segment.source_id,
|
||||
source_sequence=segment.sequence,
|
||||
captured_at_epoch_ns=segment.host_epoch_ns,
|
||||
received_monotonic_ns=segment.host_monotonic_ns,
|
||||
payload=segment.payload,
|
||||
)
|
||||
|
||||
def verify_connection(self) -> dict[str, Any]:
|
||||
"""Validate the recorded endpoint only; no network packet is emitted."""
|
||||
"""Refresh the session-scoped DHCP address from the read-only BLE status."""
|
||||
|
||||
self._refresh_live_lan_address()
|
||||
return self.state()
|
||||
|
||||
def _refresh_live_lan_address(self) -> str:
|
||||
with self._lock:
|
||||
selected_device_id = self._selected_device_id
|
||||
connection_mode = self._connection_mode
|
||||
acquisition = self._acquisition
|
||||
current_target = self._k1_ip
|
||||
if selected_device_id is None or connection_mode is None:
|
||||
raise ValueError("сначала выберите и подключите K1 через BLE/Wi-Fi")
|
||||
if acquisition is not None and acquisition.state not in TERMINAL_ACQUISITION_STATES:
|
||||
raise RuntimeError(
|
||||
"нельзя менять DHCP-привязку во время активной acquisition-сессии"
|
||||
)
|
||||
control_state = str(self._application_control_session.snapshot()["state"])
|
||||
if control_state not in {"idle", "completed", "closed", "failed"}:
|
||||
raise RuntimeError("нельзя менять DHCP-привязку при открытой control-сессии")
|
||||
if connection_mode == "quick-connect":
|
||||
if current_target != AP_FALLBACK_IPV4:
|
||||
raise RuntimeError("Quick Connect потерял фиксированный адрес точки доступа K1")
|
||||
return AP_FALLBACK_IPV4
|
||||
|
||||
status_read = asyncio.run(
|
||||
read_wifi_status_once(selected_device_id, timeout_seconds=20.0)
|
||||
)
|
||||
observed_target = status_read["status"]["ipv4"]
|
||||
if observed_target is None or observed_target == AP_FALLBACK_IPV4:
|
||||
raise RuntimeError("K1 не сообщил актуальный DHCP-адрес через BLE status")
|
||||
target = validate_private_ipv4(observed_target)
|
||||
if _target_is_local_ipv4(target):
|
||||
raise RuntimeError("BLE status сообщил адрес, принадлежащий этому компьютеру")
|
||||
|
||||
with self._lock:
|
||||
target = self._k1_ip
|
||||
if target is None:
|
||||
raise ValueError("у плагина нет подтверждённого локального адреса устройства")
|
||||
validate_private_ipv4(target)
|
||||
with self._lock:
|
||||
if (
|
||||
self._selected_device_id != selected_device_id
|
||||
or self._connection_mode != connection_mode
|
||||
):
|
||||
raise RuntimeError("выбранное подключение K1 изменилось во время DHCP refresh")
|
||||
address_changed = self._k1_ip != target
|
||||
self._k1_ip = target
|
||||
if address_changed:
|
||||
self._device_session_id = new_device_session_id()
|
||||
self._device_session_opened_at = _utc_now_iso()
|
||||
self._device_calibration = unavailable_device_calibration_snapshot(
|
||||
XGRIDS_K1_COMPATIBILITY_PROFILE_ID
|
||||
)
|
||||
self._connection_verification = {
|
||||
"status": "endpoint-valid",
|
||||
"endpoint_validation": "private-ip-syntax-only",
|
||||
"network_reachability": "unknown",
|
||||
"observed_at": _utc_now_iso(),
|
||||
"status": "live-address-observed",
|
||||
"endpoint_validation": "ble-wifi-status-read",
|
||||
"network_reachability": "not-probed",
|
||||
"address_changed": address_changed,
|
||||
"previous_address_present": current_target is not None,
|
||||
"write_performed": False,
|
||||
"observed_at": status_read["observed_at_utc"],
|
||||
}
|
||||
return self.state()
|
||||
return target
|
||||
|
||||
def read_device_calibration_snapshot(self) -> dict[str, Any]:
|
||||
"""Read and seal the two reviewed factory YAML files without device mutation."""
|
||||
|
||||
if not self._calibration_gate.acquire(blocking=False):
|
||||
raise RuntimeError("чтение заводской калибровки уже выполняется")
|
||||
try:
|
||||
self._refresh_live_lan_address()
|
||||
with self._lock:
|
||||
target = self._k1_ip
|
||||
connection_mode = self._connection_mode
|
||||
attestation = self._compatibility_attestation
|
||||
if target is None or connection_mode is None:
|
||||
raise ValueError("у плагина нет подтверждённого локального адреса K1")
|
||||
if attestation is None:
|
||||
raise ValueError(
|
||||
"для чтения калибровки сначала должен быть выбран точный профиль K1 FW 3.0.2"
|
||||
)
|
||||
snapshot = self._calibration_snapshot_reader.capture(
|
||||
host=target,
|
||||
evidence_root=self.evidence_root,
|
||||
allow_device_ap=connection_mode == "quick-connect",
|
||||
)
|
||||
with self._lock:
|
||||
if (
|
||||
self._k1_ip != target
|
||||
or self._connection_mode != connection_mode
|
||||
or self._compatibility_attestation != attestation
|
||||
):
|
||||
raise RuntimeError(
|
||||
"подключение K1 изменилось во время чтения; "
|
||||
"снимок сохранён, но не активирован"
|
||||
)
|
||||
self._device_calibration = dict(snapshot)
|
||||
return dict(snapshot)
|
||||
finally:
|
||||
self._calibration_gate.release()
|
||||
|
||||
@_serialized_acquisition_access
|
||||
def open_application_control_session(
|
||||
@@ -927,6 +1229,8 @@ class XgridsK1CompatibilityService:
|
||||
with self._lock:
|
||||
if self._provisioning_active:
|
||||
raise RuntimeError("нельзя открывать control-сессию во время настройки Wi-Fi")
|
||||
self._refresh_live_lan_address()
|
||||
with self._lock:
|
||||
target = self._k1_ip
|
||||
attestation = self._compatibility_attestation
|
||||
acquisition = self._acquisition
|
||||
@@ -1018,6 +1322,8 @@ class XgridsK1CompatibilityService:
|
||||
"plugin-commanded" if control_state == "workspace-ready" else "operator-manual"
|
||||
)
|
||||
requested_streams = _validated_requested_streams(request)
|
||||
if request.host is None and control_mode == "operator-manual":
|
||||
self._refresh_live_lan_address()
|
||||
target = request.host or self.state()["k1_ip"]
|
||||
if not isinstance(target, str) or not target:
|
||||
raise ValueError(
|
||||
@@ -1235,6 +1541,7 @@ class XgridsK1CompatibilityService:
|
||||
lease.release()
|
||||
raise RuntimeError("evidence-сессия уже удерживается активным acquisition")
|
||||
self._acquisition_session_lease = lease
|
||||
self.live_perception_ingress.begin_session(out_dir.name)
|
||||
self._modeling_control_safety.reset()
|
||||
self.runtime.start_live(
|
||||
acquisition.target_host,
|
||||
@@ -1621,7 +1928,7 @@ class XgridsK1CompatibilityService:
|
||||
self,
|
||||
project_name: str,
|
||||
host: str | None,
|
||||
duration_seconds: float,
|
||||
duration_seconds: float | None,
|
||||
compatibility_attestation: CompatibilityAttestationRequest,
|
||||
) -> dict[str, Any]:
|
||||
"""Deprecated compatibility shim over prepare + operator-manual start."""
|
||||
@@ -1770,6 +2077,11 @@ class XgridsK1CompatibilityService:
|
||||
)
|
||||
self._terminalize_acquisition_operations_on_shutdown(terminal_error)
|
||||
finally:
|
||||
with self._lock:
|
||||
out_dir = self._acquisition_out_dir
|
||||
if out_dir is not None:
|
||||
self.live_perception_ingress.end_session(out_dir.name)
|
||||
self.live_perception_ingress.close()
|
||||
if terminal_error is None:
|
||||
self._release_acquisition_session_lease()
|
||||
if terminal_error is not None:
|
||||
@@ -1786,6 +2098,9 @@ class XgridsK1CompatibilityService:
|
||||
show_points=request.show_points,
|
||||
show_trajectory=request.show_trajectory,
|
||||
show_grid=request.show_grid,
|
||||
show_detections_2d=request.show_detections_2d,
|
||||
show_segmentation=request.show_segmentation,
|
||||
show_cuboids_3d=request.show_cuboids_3d,
|
||||
)
|
||||
)
|
||||
return self.state()
|
||||
@@ -1826,6 +2141,8 @@ class XgridsK1CompatibilityService:
|
||||
camera_status: Literal["complete", "interrupted", "failed"],
|
||||
camera_failure_code: str | None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
out_dir = self._acquisition_out_dir
|
||||
camera_error: Exception | None = None
|
||||
runtime_error: Exception | None = None
|
||||
try:
|
||||
@@ -1853,6 +2170,8 @@ class XgridsK1CompatibilityService:
|
||||
self._seal_acquisition_capture_clock()
|
||||
cleanup_complete = True
|
||||
finally:
|
||||
if out_dir is not None:
|
||||
self.live_perception_ingress.end_session(out_dir.name)
|
||||
if cleanup_complete:
|
||||
self._release_acquisition_session_lease()
|
||||
|
||||
@@ -2333,6 +2652,8 @@ class XgridsK1ServicePort(Protocol):
|
||||
|
||||
def verify_connection(self) -> dict[str, Any]: ...
|
||||
|
||||
def read_device_calibration_snapshot(self) -> dict[str, Any]: ...
|
||||
|
||||
def open_application_control_session(
|
||||
self,
|
||||
request: OpenApplicationControlSessionRequest,
|
||||
@@ -2364,7 +2685,7 @@ class XgridsK1ServicePort(Protocol):
|
||||
self,
|
||||
project_name: str,
|
||||
host: str | None,
|
||||
duration_seconds: float,
|
||||
duration_seconds: float | None,
|
||||
compatibility_attestation: CompatibilityAttestationRequest,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
@@ -2442,13 +2763,17 @@ class XgridsK1PluginFacade:
|
||||
if action_id in {
|
||||
ACTION_DEVICE_INSPECT,
|
||||
ACTION_SENSOR_CATALOG_READ,
|
||||
ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ,
|
||||
ACTION_ACQUISITION_STATE_READ,
|
||||
}:
|
||||
EmptyRequest.model_validate(payload)
|
||||
if action_id == ACTION_DEVICE_INSPECT:
|
||||
return await asyncio.to_thread(self.service.inspect_device)
|
||||
return await asyncio.to_thread(self.service.state)
|
||||
if action_id == ACTION_DEVICE_CALIBRATION_SNAPSHOT_READ:
|
||||
EmptyRequest.model_validate(payload)
|
||||
return await asyncio.to_thread(
|
||||
self.service.read_device_calibration_snapshot
|
||||
)
|
||||
if action_id == ACTION_NETWORK_PROVISION:
|
||||
connect_request = ConnectRequest.model_validate(payload)
|
||||
return await self.service.connect(connect_request)
|
||||
@@ -2567,9 +2892,10 @@ def _operation_error(
|
||||
side_effect_status: Literal["none", "possible", "confirmed", "unknown"],
|
||||
safe_to_retry: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
reason_code = getattr(exc, "reason_code", None)
|
||||
return {
|
||||
"category": category,
|
||||
"code": type(exc).__name__,
|
||||
"code": reason_code if isinstance(reason_code, str) and reason_code else type(exc).__name__,
|
||||
"retryable": False,
|
||||
"safe_to_retry": safe_to_retry,
|
||||
"side_effect_status": side_effect_status,
|
||||
@@ -2716,17 +3042,6 @@ def _sensor_catalog(
|
||||
}
|
||||
|
||||
|
||||
def _device_calibration_snapshot(active_profile_id: str | None) -> dict[str, Any]:
|
||||
return {
|
||||
"schema_version": "missioncore.device-calibration/v1alpha2",
|
||||
"status": "unavailable",
|
||||
"compatibility_profile_id": active_profile_id,
|
||||
"device_internal_calibration": None,
|
||||
"vehicle_extrinsics": "host-domain-not-owned-by-plugin",
|
||||
"reason_code": "device-calibration-not-observed",
|
||||
}
|
||||
|
||||
|
||||
def _new_operation_session_dir(sessions_root: Path, suffix: str) -> Path:
|
||||
stamp = datetime.now(UTC).strftime("%Y%m%dT%H%M%SZ")
|
||||
base = sessions_root / f"{stamp}_{suffix}"
|
||||
@@ -2827,6 +3142,12 @@ def build_xgrids_k1_plugin(repository_root: Path) -> DevicePluginRuntimeContribu
|
||||
service.camera_preview,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
),
|
||||
build_live_perception_shadow_router(
|
||||
service.live_perception_ingress,
|
||||
XGRIDS_K1_PLUGIN_ID,
|
||||
bearer_token=service._live_perception_token,
|
||||
result_receiver=service.runtime.publish_perception_result,
|
||||
),
|
||||
),
|
||||
observation=build_xgrids_k1_observation(repository_root),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import tarfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import IO
|
||||
|
||||
from k1link.host_network.wifi import (
|
||||
HostWifiCredentialMaterialStoreResult,
|
||||
store_wifi_credential_material,
|
||||
)
|
||||
|
||||
K1_FW302_CREDENTIAL_PROVIDER_ID = (
|
||||
"xgrids.lixelkity-k1.quick-connect.fw-3.0.2.official-firmware.v1"
|
||||
)
|
||||
K1_FW302_OFFICIAL_ARCHIVE_SHA256 = (
|
||||
"e5830feae54d586cdeda2824495d08598920dc9cf4541059d01f0efeb858a750"
|
||||
)
|
||||
K1_FW302_OUTER_MEMBER = "upgrade.tar.gz"
|
||||
K1_FW302_RK_IMAGE_MEMBER = (
|
||||
"upgrade/rk/rk_normal/"
|
||||
"OTA-BOOT-ROOTFS-APP-V3.0.2-20250624.153447.img"
|
||||
)
|
||||
K1_FW302_APPS_OFFSET = 4_338_829_862
|
||||
K1_FW302_APPS_SIZE = 230_801_408
|
||||
|
||||
_AP_PSK_DECLARATION = b"nmcli conn modify WIFI_AP 802-11-wireless-security.psk "
|
||||
_READ_CHUNK_SIZE = 4 * 1024 * 1024
|
||||
_SCAN_OVERLAP = len(_AP_PSK_DECLARATION) + 128
|
||||
|
||||
|
||||
class FirmwareCredentialError(RuntimeError):
|
||||
"""An exact firmware artifact cannot provide the reviewed credential."""
|
||||
|
||||
|
||||
class SecretBuffer:
|
||||
"""Short-lived credential bytes whose repr and str are always redacted."""
|
||||
|
||||
__slots__ = ("_value",)
|
||||
|
||||
def __init__(self, value: bytearray) -> None:
|
||||
self._value = value
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return "SecretBuffer(<redacted>)"
|
||||
|
||||
__str__ = __repr__
|
||||
|
||||
def reveal_ascii(self) -> str:
|
||||
return self._value.decode("ascii")
|
||||
|
||||
def zeroize(self) -> None:
|
||||
self._value[:] = b"\x00" * len(self._value)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FirmwareCredentialImportResult:
|
||||
provider_id: str
|
||||
firmware_sha256: str
|
||||
host_adapter: str
|
||||
outcome: str
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
while chunk := stream.read(_READ_CHUNK_SIZE):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _discard_exact(stream: IO[bytes], size: int) -> None:
|
||||
remaining = size
|
||||
while remaining:
|
||||
chunk = stream.read(min(_READ_CHUNK_SIZE, remaining))
|
||||
if not chunk:
|
||||
raise FirmwareCredentialError("firmware image ended before the apps partition")
|
||||
remaining -= len(chunk)
|
||||
|
||||
|
||||
def _bounded_chunks(stream: IO[bytes], size: int) -> Iterator[bytes]:
|
||||
remaining = size
|
||||
while remaining:
|
||||
chunk = stream.read(min(_READ_CHUNK_SIZE, remaining))
|
||||
if not chunk:
|
||||
raise FirmwareCredentialError("firmware apps partition is truncated")
|
||||
remaining -= len(chunk)
|
||||
yield chunk
|
||||
|
||||
|
||||
def _credential_from_chunks(chunks: Iterator[bytes]) -> SecretBuffer:
|
||||
overlap = b""
|
||||
processed = 0
|
||||
matches: dict[int, bytearray] = {}
|
||||
for chunk in chunks:
|
||||
combined = overlap + chunk
|
||||
combined_offset = processed - len(overlap)
|
||||
start = 0
|
||||
while True:
|
||||
index = combined.find(_AP_PSK_DECLARATION, start)
|
||||
if index < 0:
|
||||
break
|
||||
value_start = index + len(_AP_PSK_DECLARATION)
|
||||
value_end = value_start
|
||||
while value_end < len(combined) and combined[value_end] not in b"\x00\r\n":
|
||||
value_end += 1
|
||||
if value_end < len(combined):
|
||||
absolute_offset = combined_offset + index
|
||||
matches[absolute_offset] = bytearray(combined[value_start:value_end].strip())
|
||||
start = index + 1
|
||||
processed += len(chunk)
|
||||
overlap = combined[-_SCAN_OVERLAP:]
|
||||
|
||||
if len(matches) != 1:
|
||||
for value in matches.values():
|
||||
value[:] = b"\x00" * len(value)
|
||||
raise FirmwareCredentialError(
|
||||
"reviewed AP credential declaration was not unique in the apps partition"
|
||||
)
|
||||
value = next(iter(matches.values()))
|
||||
if not 8 <= len(value) <= 63 or any(byte <= 0x20 or byte >= 0x7F for byte in value):
|
||||
value[:] = b"\x00" * len(value)
|
||||
raise FirmwareCredentialError("reviewed AP credential has an invalid WPA-PSK shape")
|
||||
return SecretBuffer(value)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _official_rk_image(archive_path: Path) -> Iterator[IO[bytes]]:
|
||||
try:
|
||||
with tarfile.open(archive_path, mode="r:*") as outer:
|
||||
outer_member = outer.getmember(K1_FW302_OUTER_MEMBER)
|
||||
upgrade_stream = outer.extractfile(outer_member)
|
||||
if upgrade_stream is None:
|
||||
raise FirmwareCredentialError("official upgrade member has no readable payload")
|
||||
with upgrade_stream, tarfile.open(
|
||||
fileobj=upgrade_stream, mode="r|gz"
|
||||
) as upgrade:
|
||||
for member in upgrade:
|
||||
if member.name != K1_FW302_RK_IMAGE_MEMBER:
|
||||
continue
|
||||
image_stream = upgrade.extractfile(member)
|
||||
if image_stream is None:
|
||||
raise FirmwareCredentialError(
|
||||
"official Rockchip image has no readable payload"
|
||||
)
|
||||
with image_stream:
|
||||
yield image_stream
|
||||
return
|
||||
except (KeyError, OSError, tarfile.TarError) as exc:
|
||||
raise FirmwareCredentialError("official firmware archive is unreadable") from exc
|
||||
raise FirmwareCredentialError("reviewed Rockchip image is absent from the archive")
|
||||
|
||||
|
||||
def extract_k1_fw302_ap_credential(archive_path: Path) -> tuple[SecretBuffer, str]:
|
||||
"""Resolve the FW 3.0.2 AP material without printing or persisting it.
|
||||
|
||||
The exact official archive is authenticated first. Only then is the reviewed
|
||||
apps-partition range scanned for one bounded NetworkManager declaration.
|
||||
"""
|
||||
|
||||
resolved_path = archive_path.resolve(strict=True)
|
||||
firmware_sha256 = _sha256(resolved_path)
|
||||
if not hmac.compare_digest(firmware_sha256, K1_FW302_OFFICIAL_ARCHIVE_SHA256):
|
||||
raise FirmwareCredentialError("firmware SHA-256 does not match the reviewed 3.0.2 image")
|
||||
with _official_rk_image(resolved_path) as image_stream:
|
||||
_discard_exact(image_stream, K1_FW302_APPS_OFFSET)
|
||||
secret = _credential_from_chunks(
|
||||
_bounded_chunks(image_stream, K1_FW302_APPS_SIZE)
|
||||
)
|
||||
return secret, firmware_sha256
|
||||
|
||||
|
||||
def import_k1_fw302_ap_credential(
|
||||
archive_path: Path,
|
||||
helper_path: Path,
|
||||
) -> FirmwareCredentialImportResult:
|
||||
"""Install one firmware-scoped material in the host's secure store."""
|
||||
|
||||
secret, firmware_sha256 = extract_k1_fw302_ap_credential(archive_path)
|
||||
try:
|
||||
stored: HostWifiCredentialMaterialStoreResult = store_wifi_credential_material(
|
||||
helper_path,
|
||||
K1_FW302_CREDENTIAL_PROVIDER_ID,
|
||||
secret.reveal_ascii(),
|
||||
)
|
||||
finally:
|
||||
secret.zeroize()
|
||||
return FirmwareCredentialImportResult(
|
||||
provider_id=K1_FW302_CREDENTIAL_PROVIDER_ID,
|
||||
firmware_sha256=firmware_sha256,
|
||||
host_adapter=stored["adapter"],
|
||||
outcome=stored["outcome"],
|
||||
)
|
||||
@@ -0,0 +1,144 @@
|
||||
"""Authenticated localhost transport for the K1 shadow perception worker."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import os
|
||||
import secrets
|
||||
from collections.abc import Callable
|
||||
from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from uuid import uuid4
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from k1link.compute.live_perception import LivePerceptionIngress
|
||||
|
||||
TOKEN_BYTES = 32
|
||||
TOKEN_FILE_NAME = "shadow-worker.token"
|
||||
|
||||
|
||||
def ensure_live_shadow_token(repository_root: Path) -> tuple[Path, str]:
|
||||
"""Load or create the private bearer used only through the SSH tunnel."""
|
||||
|
||||
token_root = repository_root.resolve() / ".runtime" / "live-perception"
|
||||
token_root.mkdir(mode=0o700, parents=True, exist_ok=True)
|
||||
with suppress(OSError):
|
||||
token_root.chmod(0o700)
|
||||
token_path = token_root / TOKEN_FILE_NAME
|
||||
try:
|
||||
token = token_path.read_text(encoding="ascii").strip()
|
||||
except FileNotFoundError:
|
||||
token = secrets.token_urlsafe(TOKEN_BYTES)
|
||||
descriptor = os.open(
|
||||
token_path,
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
os.write(descriptor, f"{token}\n".encode("ascii"))
|
||||
os.fsync(descriptor)
|
||||
finally:
|
||||
os.close(descriptor)
|
||||
if len(token) < 40 or len(token) > 128 or not token.isascii():
|
||||
raise RuntimeError("live perception shadow token is invalid")
|
||||
with suppress(OSError):
|
||||
token_path.chmod(0o600)
|
||||
return token_path, token
|
||||
|
||||
|
||||
def build_live_perception_shadow_router(
|
||||
ingress: LivePerceptionIngress,
|
||||
plugin_id: str,
|
||||
*,
|
||||
bearer_token: str,
|
||||
result_receiver: Callable[[bytes], bool] | None = None,
|
||||
) -> APIRouter:
|
||||
"""Expose one exclusive sensor stream with bounded diagnostic results back."""
|
||||
|
||||
router = APIRouter(include_in_schema=False)
|
||||
|
||||
@router.websocket(
|
||||
f"/api/v1/device-plugins/{plugin_id}/live-perception-shadow"
|
||||
)
|
||||
async def live_perception_shadow(websocket: WebSocket) -> None:
|
||||
authorization = websocket.headers.get("authorization", "")
|
||||
supplied = authorization.removeprefix("Bearer ")
|
||||
if not supplied or not hmac.compare_digest(supplied, bearer_token):
|
||||
await websocket.close(code=1008, reason="Shadow worker authentication failed")
|
||||
return
|
||||
|
||||
consumer_id = f"shadow-worker-{uuid4().hex}"
|
||||
try:
|
||||
ingress.open_consumer(consumer_id)
|
||||
except RuntimeError:
|
||||
await websocket.close(code=1008, reason="Shadow worker lease is unavailable")
|
||||
return
|
||||
|
||||
await websocket.accept()
|
||||
client_event = asyncio.create_task(websocket.receive())
|
||||
try:
|
||||
while True:
|
||||
ingress_event = asyncio.create_task(
|
||||
asyncio.to_thread(
|
||||
ingress.take_next,
|
||||
consumer_id,
|
||||
timeout=0.5,
|
||||
)
|
||||
)
|
||||
completed, _ = await asyncio.wait(
|
||||
{client_event, ingress_event},
|
||||
return_when=asyncio.FIRST_COMPLETED,
|
||||
)
|
||||
if client_event in completed:
|
||||
message = client_event.result()
|
||||
if message.get("type") == "websocket.disconnect":
|
||||
ingress_event.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ingress_event
|
||||
break
|
||||
result = message.get("bytes")
|
||||
if not isinstance(result, bytes) or result_receiver is None:
|
||||
ingress_event.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ingress_event
|
||||
await websocket.close(
|
||||
code=1008,
|
||||
reason="Shadow result direction is unavailable",
|
||||
)
|
||||
return
|
||||
try:
|
||||
await asyncio.to_thread(result_receiver, result)
|
||||
except (RuntimeError, ValueError):
|
||||
ingress_event.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ingress_event
|
||||
await websocket.close(
|
||||
code=1008,
|
||||
reason="Shadow result contract is invalid",
|
||||
)
|
||||
return
|
||||
client_event = asyncio.create_task(websocket.receive())
|
||||
if ingress_event not in completed:
|
||||
ingress_event.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await ingress_event
|
||||
continue
|
||||
event = await ingress_event
|
||||
if event is None:
|
||||
if ingress.snapshot()["closed"]:
|
||||
break
|
||||
continue
|
||||
await websocket.send_bytes(event.wire_bytes())
|
||||
except (WebSocketDisconnect, RuntimeError):
|
||||
return
|
||||
finally:
|
||||
client_event.cancel()
|
||||
with suppress(asyncio.CancelledError):
|
||||
await client_event
|
||||
ingress.close_consumer(consumer_id)
|
||||
with suppress(RuntimeError):
|
||||
await websocket.close()
|
||||
|
||||
return router
|
||||
@@ -1,99 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
from pathlib import Path
|
||||
from typing import Any, TypedDict
|
||||
|
||||
|
||||
class HostWifiAssociationResult(TypedDict):
|
||||
schema_version: int
|
||||
adapter: str
|
||||
outcome: str
|
||||
already_associated: bool
|
||||
|
||||
|
||||
class HostWifiAssociationError(RuntimeError):
|
||||
"""One bounded host-side Wi-Fi association attempt failed."""
|
||||
|
||||
def __init__(self, reason_code: str) -> None:
|
||||
self.reason_code = reason_code
|
||||
super().__init__(f"macOS Wi-Fi association failed: {reason_code}")
|
||||
|
||||
|
||||
RunProcess = Callable[..., subprocess.CompletedProcess[bytes]]
|
||||
|
||||
|
||||
def associate_with_wifi_once(
|
||||
helper_path: Path,
|
||||
ssid: str,
|
||||
password: str,
|
||||
*,
|
||||
timeout_seconds: float = 45.0,
|
||||
runner: RunProcess = subprocess.run,
|
||||
) -> HostWifiAssociationResult:
|
||||
"""Associate the Mac with one operator-selected Wi-Fi network exactly once.
|
||||
|
||||
The credential is sent to the short-lived CoreWLAN helper through stdin. It
|
||||
never appears in argv, the environment, stdout, stderr, or a persisted
|
||||
artifact. The helper performs at most one scan and one association call.
|
||||
"""
|
||||
|
||||
if sys.platform != "darwin":
|
||||
raise HostWifiAssociationError("unsupported-platform")
|
||||
if not helper_path.is_file():
|
||||
raise HostWifiAssociationError("corewlan-helper-missing")
|
||||
if not 1 <= len(ssid.encode("utf-8")) <= 32:
|
||||
raise ValueError("SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
if not 1 <= len(password.encode("utf-8")) <= 64:
|
||||
raise ValueError("Wi-Fi password must contain between 1 and 64 UTF-8 bytes")
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
|
||||
request_bytes = bytearray(
|
||||
json.dumps(
|
||||
{"ssid": ssid, "password": password},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
).encode("utf-8")
|
||||
)
|
||||
try:
|
||||
completed = runner(
|
||||
["/usr/bin/xcrun", "swift", str(helper_path.resolve())],
|
||||
input=request_bytes,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
timeout=timeout_seconds,
|
||||
check=False,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
raise HostWifiAssociationError("corewlan-helper-unavailable") from exc
|
||||
finally:
|
||||
request_bytes[:] = b"\x00" * len(request_bytes)
|
||||
|
||||
if len(completed.stdout) > 4096:
|
||||
raise HostWifiAssociationError("corewlan-response-too-large")
|
||||
try:
|
||||
response: Any = json.loads(completed.stdout.decode("utf-8"))
|
||||
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
||||
raise HostWifiAssociationError("corewlan-response-invalid") from exc
|
||||
if not isinstance(response, dict):
|
||||
raise HostWifiAssociationError("corewlan-response-invalid")
|
||||
|
||||
reason_code = response.get("reason_code")
|
||||
if completed.returncode != 0 or response.get("ok") is not True:
|
||||
if not isinstance(reason_code, str) or not reason_code:
|
||||
reason_code = "corewlan-association-failed"
|
||||
raise HostWifiAssociationError(reason_code)
|
||||
|
||||
already_associated = response.get("already_associated")
|
||||
if not isinstance(already_associated, bool):
|
||||
raise HostWifiAssociationError("corewlan-response-invalid")
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"adapter": "CoreWLAN",
|
||||
"outcome": "already-associated" if already_associated else "associated",
|
||||
"already_associated": already_associated,
|
||||
}
|
||||
@@ -101,7 +101,7 @@ class CaptureSummary(TypedDict):
|
||||
reconnect_enabled: bool
|
||||
publishing_enabled: bool
|
||||
subscriptions: list[str]
|
||||
requested_duration_seconds: float
|
||||
requested_duration_seconds: float | None
|
||||
capture_elapsed_seconds: float
|
||||
session_elapsed_seconds: float
|
||||
operation_elapsed_seconds: float
|
||||
@@ -753,7 +753,7 @@ def capture_mqtt(
|
||||
out_dir: Path,
|
||||
*,
|
||||
port: int = 1883,
|
||||
duration_seconds: float = 60.0,
|
||||
duration_seconds: float | None = 60.0,
|
||||
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
|
||||
on_clock_established: Callable[[], None] | None = None,
|
||||
on_ready: Callable[[], None] | None = None,
|
||||
@@ -765,7 +765,9 @@ def capture_mqtt(
|
||||
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:
|
||||
if duration_seconds is not None and (
|
||||
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(
|
||||
@@ -898,7 +900,11 @@ def capture_mqtt(
|
||||
capture_started = now
|
||||
if on_ready is not None:
|
||||
on_ready()
|
||||
if capture_started is not None and now - capture_started >= duration_seconds:
|
||||
if (
|
||||
capture_started is not None
|
||||
and duration_seconds 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:
|
||||
@@ -970,7 +976,7 @@ def _build_summary(
|
||||
writer: _CaptureWriter,
|
||||
target_ipv4: str,
|
||||
port: int,
|
||||
duration_seconds: float,
|
||||
duration_seconds: float | None,
|
||||
capture_elapsed: float,
|
||||
operation_elapsed: float,
|
||||
max_message_bytes: int,
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
ApplicationControlAuthority,
|
||||
ApplicationRequestHeader,
|
||||
LiveDeviceControlBinding,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.modeling_control import OPENAPI_SUCCESS
|
||||
from k1link.device_plugins.xgrids_k1.protocol.protobuf_wire import (
|
||||
ProtobufWireError,
|
||||
ProtoField,
|
||||
iter_fields,
|
||||
)
|
||||
|
||||
CALIBRATION_FILE_REQUEST_TOPIC = "lixel/calibration/request/file"
|
||||
CALIBRATION_FILE_RESPONSE_TOPIC = "lixel/calibration/response/file"
|
||||
CALIBRATION_FILE_MESSAGE_TYPE = "CalibFileRequest"
|
||||
CALIBRATION_FILE_READ_COMMAND = 5
|
||||
|
||||
FACTORY_CAMERA_CALIBRATION_PATH = "/mnt/system/factory-data/config/camera.yaml"
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH = (
|
||||
"/mnt/system/factory-data/config/extrinsic_camera_lidar.yaml"
|
||||
)
|
||||
FACTORY_CALIBRATION_PATHS = (
|
||||
FACTORY_CAMERA_CALIBRATION_PATH,
|
||||
FACTORY_CAMERA_LIDAR_EXTRINSIC_PATH,
|
||||
)
|
||||
|
||||
MAX_CALIBRATION_FILE_BYTES = 1024 * 1024
|
||||
MAX_CALIBRATION_RESPONSE_BYTES = MAX_CALIBRATION_FILE_BYTES + 16 * 1024
|
||||
MAX_CALIBRATION_PATH_BYTES = 256
|
||||
MAX_CALIBRATION_HEADER_BYTES = 4 * 1024
|
||||
|
||||
|
||||
class CalibrationFileProtocolError(ValueError):
|
||||
"""A K1 calibration-file message violated the read-only contract."""
|
||||
|
||||
|
||||
class CalibrationFileRejected(CalibrationFileProtocolError):
|
||||
"""The K1 rejected a correlated, exact-path calibration-file read."""
|
||||
|
||||
def __init__(self, result_code: int) -> None:
|
||||
self.result_code = result_code
|
||||
super().__init__(f"K1 calibration-file read rejected with result code {result_code}")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class EncodedCalibrationFileRead:
|
||||
path: str
|
||||
session_id: str = field(repr=False)
|
||||
vendor_device_id: str = field(repr=False)
|
||||
payload: bytes = field(repr=False)
|
||||
payload_sha256: str
|
||||
payload_bytes: int
|
||||
topic: str = CALIBRATION_FILE_REQUEST_TOPIC
|
||||
response_topic: str = CALIBRATION_FILE_RESPONSE_TOPIC
|
||||
qos: int = 2
|
||||
retain: bool = False
|
||||
mutates_device: bool = False
|
||||
automatic_retry: bool = False
|
||||
|
||||
def envelope(self, *, ordinal: int) -> OneShotPublishEnvelope:
|
||||
if ordinal not in (1, 2):
|
||||
raise CalibrationFileProtocolError(
|
||||
"factory calibration read ordinal must be one or two"
|
||||
)
|
||||
return OneShotPublishEnvelope(
|
||||
operation_key=f"calibration:read:{ordinal}",
|
||||
topic=self.topic,
|
||||
payload=self.payload,
|
||||
payload_sha256=self.payload_sha256,
|
||||
payload_bytes=self.payload_bytes,
|
||||
qos=self.qos,
|
||||
retain=self.retain,
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalibrationFileContent:
|
||||
path: str
|
||||
content: bytes = field(repr=False)
|
||||
content_sha256: str
|
||||
content_bytes: int
|
||||
result_code: int
|
||||
|
||||
|
||||
def build_factory_calibration_file_read(
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
path: str,
|
||||
) -> EncodedCalibrationFileRead:
|
||||
"""Build command 5 for one of the two reviewed factory YAML paths.
|
||||
|
||||
The command is deliberately not a parameter. This module has no API that
|
||||
can encode command 6 (file write), and an arbitrary path cannot cross this
|
||||
boundary.
|
||||
"""
|
||||
|
||||
_require_reviewed_binding(binding)
|
||||
_require_exact_factory_path(path)
|
||||
header = ApplicationRequestHeader(
|
||||
message_type=CALIBRATION_FILE_MESSAGE_TYPE,
|
||||
authority=authority,
|
||||
binding=binding,
|
||||
)
|
||||
encoded_header = b"".join(
|
||||
(
|
||||
_text_field(4, binding.vendor_device_id),
|
||||
_text_field(5, header.session_id),
|
||||
_text_field(6, authority.openapi_key),
|
||||
)
|
||||
)
|
||||
if len(encoded_header) > MAX_CALIBRATION_HEADER_BYTES:
|
||||
raise CalibrationFileProtocolError("calibration request header exceeds bound")
|
||||
payload = b"".join(
|
||||
(
|
||||
_bytes_field(1, encoded_header),
|
||||
_varint_field(2, CALIBRATION_FILE_READ_COMMAND),
|
||||
_text_field(3, path),
|
||||
)
|
||||
)
|
||||
if len(payload) > MAX_CALIBRATION_RESPONSE_BYTES:
|
||||
raise CalibrationFileProtocolError("calibration request exceeds bound")
|
||||
return EncodedCalibrationFileRead(
|
||||
path=path,
|
||||
session_id=header.session_id,
|
||||
vendor_device_id=binding.vendor_device_id,
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
)
|
||||
|
||||
|
||||
def decode_factory_calibration_file_response(
|
||||
payload: bytes,
|
||||
request: EncodedCalibrationFileRead,
|
||||
authority: ApplicationControlAuthority,
|
||||
binding: LiveDeviceControlBinding,
|
||||
) -> CalibrationFileContent:
|
||||
"""Decode and correlate one exact-path command-5 response."""
|
||||
|
||||
_require_reviewed_binding(binding)
|
||||
_require_exact_factory_path(request.path)
|
||||
if request.vendor_device_id != binding.vendor_device_id:
|
||||
raise CalibrationFileProtocolError("calibration request binding changed")
|
||||
if len(payload) > MAX_CALIBRATION_RESPONSE_BYTES:
|
||||
raise CalibrationFileProtocolError("calibration response exceeds bound")
|
||||
|
||||
top = _selected_unique_fields(
|
||||
payload,
|
||||
"calibration response",
|
||||
selected={1, 2, 3, 4, 15},
|
||||
max_fields=32,
|
||||
)
|
||||
header = _selected_unique_fields(
|
||||
_required_bytes(top, 1, "response.header"),
|
||||
"calibration response header",
|
||||
selected={4, 5, 6},
|
||||
max_fields=16,
|
||||
)
|
||||
device_id = _required_ascii(header, 4, "response.header.device_id")
|
||||
session_id = _required_ascii(header, 5, "response.header.session_id")
|
||||
openapi_key = _required_ascii(header, 6, "response.header.openapi_key")
|
||||
if not hmac.compare_digest(device_id, binding.vendor_device_id):
|
||||
raise CalibrationFileProtocolError("calibration response device identity mismatch")
|
||||
if not hmac.compare_digest(session_id, request.session_id):
|
||||
raise CalibrationFileProtocolError("calibration response session mismatch")
|
||||
if not hmac.compare_digest(openapi_key, authority.openapi_key):
|
||||
raise CalibrationFileProtocolError("calibration response authority mismatch")
|
||||
|
||||
command = _required_uint(top, 2, "response.cmd")
|
||||
if command != CALIBRATION_FILE_READ_COMMAND:
|
||||
raise CalibrationFileProtocolError("calibration response is not a file-read result")
|
||||
observed_path = _required_utf8(top, 3, "response.file_path", MAX_CALIBRATION_PATH_BYTES)
|
||||
if not hmac.compare_digest(observed_path, request.path):
|
||||
raise CalibrationFileProtocolError("calibration response path mismatch")
|
||||
|
||||
error = _selected_unique_fields(
|
||||
_required_bytes(top, 15, "response.error"),
|
||||
"calibration response error",
|
||||
selected={1, 2},
|
||||
max_fields=8,
|
||||
)
|
||||
result_code = _required_uint(error, 1, "response.error.code")
|
||||
if result_code != OPENAPI_SUCCESS:
|
||||
raise CalibrationFileRejected(result_code)
|
||||
|
||||
content = _required_bytes(top, 4, "response.file_content")
|
||||
if not content:
|
||||
raise CalibrationFileProtocolError("calibration response file content is empty")
|
||||
if len(content) > MAX_CALIBRATION_FILE_BYTES:
|
||||
raise CalibrationFileProtocolError("calibration response file exceeds bound")
|
||||
try:
|
||||
content.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise CalibrationFileProtocolError(
|
||||
"calibration response file is not valid UTF-8"
|
||||
) from exc
|
||||
if b"\x00" in content:
|
||||
raise CalibrationFileProtocolError("calibration response file contains NUL")
|
||||
return CalibrationFileContent(
|
||||
path=observed_path,
|
||||
content=content,
|
||||
content_sha256=hashlib.sha256(content).hexdigest(),
|
||||
content_bytes=len(content),
|
||||
result_code=result_code,
|
||||
)
|
||||
|
||||
|
||||
def _require_reviewed_binding(binding: LiveDeviceControlBinding) -> None:
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise CalibrationFileProtocolError(
|
||||
"live DeviceInfo does not match the reviewed activated K1 FW 3.0.2 profile"
|
||||
)
|
||||
|
||||
|
||||
def _require_exact_factory_path(path: object) -> None:
|
||||
if not isinstance(path, str) or path not in FACTORY_CALIBRATION_PATHS:
|
||||
raise CalibrationFileProtocolError(
|
||||
"calibration file path is outside the exact two-file allowlist"
|
||||
)
|
||||
|
||||
|
||||
def _selected_unique_fields(
|
||||
payload: bytes,
|
||||
name: str,
|
||||
*,
|
||||
selected: set[int],
|
||||
max_fields: int,
|
||||
) -> dict[int, ProtoField]:
|
||||
result: dict[int, ProtoField] = {}
|
||||
try:
|
||||
for item in iter_fields(payload, max_fields=max_fields):
|
||||
if item.number not in selected:
|
||||
continue
|
||||
if item.number in result:
|
||||
raise CalibrationFileProtocolError(
|
||||
f"{name} field {item.number} is duplicated"
|
||||
)
|
||||
result[item.number] = item
|
||||
except ProtobufWireError as exc:
|
||||
raise CalibrationFileProtocolError(f"invalid {name}: {exc}") from exc
|
||||
return result
|
||||
|
||||
|
||||
def _required_bytes(fields: dict[int, ProtoField], number: int, name: str) -> bytes:
|
||||
item = fields.get(number)
|
||||
if item is None or item.wire_type != 2 or not isinstance(item.value, bytes):
|
||||
raise CalibrationFileProtocolError(f"{name} is missing or has wrong wire type")
|
||||
return item.value
|
||||
|
||||
|
||||
def _required_uint(fields: dict[int, ProtoField], number: int, name: str) -> int:
|
||||
item = fields.get(number)
|
||||
if item is None or item.wire_type != 0 or not isinstance(item.value, int):
|
||||
raise CalibrationFileProtocolError(f"{name} is missing or has wrong wire type")
|
||||
return item.value
|
||||
|
||||
|
||||
def _required_utf8(
|
||||
fields: dict[int, ProtoField],
|
||||
number: int,
|
||||
name: str,
|
||||
maximum_bytes: int,
|
||||
) -> str:
|
||||
raw = _required_bytes(fields, number, name)
|
||||
if not raw or len(raw) > maximum_bytes:
|
||||
raise CalibrationFileProtocolError(f"{name} is empty or exceeds bound")
|
||||
try:
|
||||
return raw.decode("utf-8", errors="strict")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise CalibrationFileProtocolError(f"{name} is not valid UTF-8") from exc
|
||||
|
||||
|
||||
def _required_ascii(fields: dict[int, ProtoField], number: int, name: str) -> str:
|
||||
value = _required_utf8(fields, number, name, MAX_CALIBRATION_HEADER_BYTES)
|
||||
try:
|
||||
encoded = value.encode("ascii", errors="strict")
|
||||
except UnicodeEncodeError as exc:
|
||||
raise CalibrationFileProtocolError(f"{name} is not printable ASCII") from exc
|
||||
if any(byte <= 0x20 or byte > 0x7E for byte in encoded):
|
||||
raise CalibrationFileProtocolError(f"{name} is not printable ASCII")
|
||||
return value
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
if value < 0:
|
||||
raise CalibrationFileProtocolError("negative protobuf varint is unsupported")
|
||||
encoded = bytearray()
|
||||
while value > 0x7F:
|
||||
encoded.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
encoded.append(value)
|
||||
return bytes(encoded)
|
||||
|
||||
|
||||
def _key(number: int, wire_type: int) -> bytes:
|
||||
if number < 1:
|
||||
raise CalibrationFileProtocolError("protobuf field number must be positive")
|
||||
return _varint((number << 3) | wire_type)
|
||||
|
||||
|
||||
def _varint_field(number: int, value: int) -> bytes:
|
||||
return _key(number, 0) + _varint(value)
|
||||
|
||||
|
||||
def _bytes_field(number: int, value: bytes) -> bytes:
|
||||
return _key(number, 2) + _varint(len(value)) + value
|
||||
|
||||
|
||||
def _text_field(number: int, value: str) -> bytes:
|
||||
return _bytes_field(number, value.encode("utf-8", errors="strict"))
|
||||
@@ -0,0 +1,534 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import secrets
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
|
||||
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.device_plugins.xgrids_k1.ble.wifi_provisioning import AP_FALLBACK_IPV4
|
||||
from k1link.device_plugins.xgrids_k1.mqtt import validate_private_ipv4
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_bootstrap import (
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
ApplicationControlAuthority,
|
||||
ApplicationRequestHeader,
|
||||
EncodedApplicationRequest,
|
||||
LiveDeviceControlBinding,
|
||||
decode_and_bind_device_info_response,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.application_publish import (
|
||||
OneShotPublishEnvelope,
|
||||
)
|
||||
from k1link.device_plugins.xgrids_k1.protocol.calibration_file import (
|
||||
CALIBRATION_FILE_RESPONSE_TOPIC,
|
||||
FACTORY_CALIBRATION_PATHS,
|
||||
MAX_CALIBRATION_RESPONSE_BYTES,
|
||||
CalibrationFileContent,
|
||||
build_factory_calibration_file_read,
|
||||
decode_factory_calibration_file_response,
|
||||
)
|
||||
|
||||
CALIBRATION_CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
CALIBRATION_EXCHANGE_TIMEOUT_SECONDS = 5.0
|
||||
CALIBRATION_KEEPALIVE_SECONDS = 30
|
||||
CALIBRATION_LOOP_INTERVAL_SECONDS = 0.05
|
||||
MAX_DEVICE_INFO_RESPONSE_BYTES = 64 * 1024
|
||||
|
||||
CALIBRATION_READ_SUBSCRIPTIONS: tuple[tuple[str, int], ...] = (
|
||||
(DEVICE_INFO_RESPONSE_TOPIC, 0),
|
||||
(CALIBRATION_FILE_RESPONSE_TOPIC, 2),
|
||||
)
|
||||
|
||||
|
||||
class CalibrationMqttTransportError(RuntimeError):
|
||||
"""The read-only calibration transport failed before a publish."""
|
||||
|
||||
def __init__(self, message: str, *, reason_code: str = "transport_failure") -> None:
|
||||
self.reason_code = reason_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
class CalibrationReadOutcomeUnknown(RuntimeError):
|
||||
"""A read was published, but its exact response could not be established."""
|
||||
|
||||
def __init__(self, message: str, *, reason_code: str = "read_outcome_unknown") -> None:
|
||||
self.reason_code = reason_code
|
||||
super().__init__(message)
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FactoryCalibrationReadResult:
|
||||
binding: LiveDeviceControlBinding
|
||||
files: tuple[CalibrationFileContent, CalibrationFileContent]
|
||||
transport: dict[str, object]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CalibrationMqttSnapshot:
|
||||
state: str
|
||||
connect_attempts: int
|
||||
subscribe_attempts: int
|
||||
publish_attempts: int
|
||||
qos2_completions: int
|
||||
correlated_responses: int
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"mode": "read-only-factory-calibration",
|
||||
"state": self.state,
|
||||
"connect_attempts": self.connect_attempts,
|
||||
"subscribe_attempts": self.subscribe_attempts,
|
||||
"publish_attempts": self.publish_attempts,
|
||||
"qos2_completions": self.qos2_completions,
|
||||
"correlated_responses": self.correlated_responses,
|
||||
"clean_session": True,
|
||||
"automatic_reconnect": False,
|
||||
"automatic_retry": False,
|
||||
"request_command": 5,
|
||||
"write_command_available": False,
|
||||
}
|
||||
|
||||
|
||||
class ReviewedCalibrationMqttReader:
|
||||
"""One-connection, no-retry reader for two exact factory calibration files."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str,
|
||||
*,
|
||||
port: int = 1883,
|
||||
connect_timeout_seconds: float = CALIBRATION_CONNECT_TIMEOUT_SECONDS,
|
||||
exchange_timeout_seconds: float = CALIBRATION_EXCHANGE_TIMEOUT_SECONDS,
|
||||
allow_device_ap: bool = False,
|
||||
client_factory: Callable[[], mqtt.Client] | None = None,
|
||||
monotonic: Callable[[], float] = time.monotonic,
|
||||
) -> None:
|
||||
self._target_ipv4 = validate_private_ipv4(host)
|
||||
if self._target_ipv4 == AP_FALLBACK_IPV4 and not allow_device_ap:
|
||||
raise ValueError("K1 access-point address is not allowed for this connection mode")
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("port must be between 1 and 65535")
|
||||
for name, value in (
|
||||
("connect_timeout_seconds", connect_timeout_seconds),
|
||||
("exchange_timeout_seconds", exchange_timeout_seconds),
|
||||
):
|
||||
if not math.isfinite(value) or value <= 0:
|
||||
raise ValueError(f"{name} must be finite and greater than zero")
|
||||
self._port = port
|
||||
self._connect_timeout_seconds = connect_timeout_seconds
|
||||
self._exchange_timeout_seconds = exchange_timeout_seconds
|
||||
self._client_factory = client_factory
|
||||
self._monotonic = monotonic
|
||||
self._lock = threading.Lock()
|
||||
self._client: mqtt.Client | None = None
|
||||
self._state = "new"
|
||||
self._connected = False
|
||||
self._subscribed = False
|
||||
self._closing = False
|
||||
self._subscription_mid: int | None = None
|
||||
self._completed_publish_mids: set[int] = set()
|
||||
self._messages: deque[tuple[str, bytes]] = deque()
|
||||
self._callback_error: str | None = None
|
||||
self._connect_attempts = 0
|
||||
self._subscribe_attempts = 0
|
||||
self._publish_attempts = 0
|
||||
self._qos2_completions = 0
|
||||
self._correlated_responses = 0
|
||||
|
||||
def read_factory_calibration(
|
||||
self,
|
||||
authority: ApplicationControlAuthority,
|
||||
) -> FactoryCalibrationReadResult:
|
||||
self.open()
|
||||
try:
|
||||
discovery = _build_device_info_discovery(authority)
|
||||
device_info_payload = self._exchange_once(
|
||||
OneShotPublishEnvelope.from_bootstrap_request(discovery),
|
||||
expected_response_topic=DEVICE_INFO_RESPONSE_TOPIC,
|
||||
)
|
||||
binding = decode_and_bind_device_info_response(device_info_payload, authority).binding
|
||||
if not binding.ready_for_reviewed_profile:
|
||||
raise CalibrationMqttTransportError(
|
||||
"live DeviceInfo does not attest the reviewed activated K1 FW 3.0.2 profile",
|
||||
reason_code="compatibility_profile_mismatch",
|
||||
)
|
||||
|
||||
files: list[CalibrationFileContent] = []
|
||||
for ordinal, path in enumerate(FACTORY_CALIBRATION_PATHS, start=1):
|
||||
request = build_factory_calibration_file_read(authority, binding, path)
|
||||
response_payload = self._exchange_once(
|
||||
request.envelope(ordinal=ordinal),
|
||||
expected_response_topic=CALIBRATION_FILE_RESPONSE_TOPIC,
|
||||
)
|
||||
files.append(
|
||||
decode_factory_calibration_file_response(
|
||||
response_payload,
|
||||
request,
|
||||
authority,
|
||||
binding,
|
||||
)
|
||||
)
|
||||
if len(files) != 2:
|
||||
raise CalibrationMqttTransportError(
|
||||
"factory calibration read did not return exactly two files"
|
||||
)
|
||||
snapshot = self.snapshot().as_dict()
|
||||
return FactoryCalibrationReadResult(
|
||||
binding=binding,
|
||||
files=(files[0], files[1]),
|
||||
transport=snapshot,
|
||||
)
|
||||
finally:
|
||||
self.close()
|
||||
|
||||
def open(self) -> CalibrationMqttSnapshot:
|
||||
with self._lock:
|
||||
if self._state != "new":
|
||||
raise CalibrationMqttTransportError(
|
||||
"calibration transport can be opened only once",
|
||||
reason_code="transport_already_opened",
|
||||
)
|
||||
self._state = "connecting"
|
||||
self._connect_attempts = 1
|
||||
client = self._new_client()
|
||||
client.connect_timeout = self._connect_timeout_seconds
|
||||
self._install_callbacks(client)
|
||||
self._client = client
|
||||
try:
|
||||
result = client.connect(
|
||||
self._target_ipv4,
|
||||
port=self._port,
|
||||
keepalive=CALIBRATION_KEEPALIVE_SECONDS,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
self._fail_before_publish("calibration MQTT connect call failed", exc)
|
||||
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||
self._fail_before_publish("calibration MQTT connect call was rejected")
|
||||
deadline = self._monotonic() + self._connect_timeout_seconds
|
||||
self._drive_until(lambda: self._subscribed, deadline, post_publish=False)
|
||||
with self._lock:
|
||||
self._state = "ready"
|
||||
return self.snapshot()
|
||||
|
||||
def close(self) -> None:
|
||||
with self._lock:
|
||||
if self._state == "closed":
|
||||
return
|
||||
self._closing = True
|
||||
client = self._client
|
||||
if client is not None:
|
||||
try:
|
||||
if self._subscribed:
|
||||
client.unsubscribe([topic for topic, _qos in CALIBRATION_READ_SUBSCRIPTIONS])
|
||||
client.disconnect()
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
pass
|
||||
with self._lock:
|
||||
self._connected = False
|
||||
self._subscribed = False
|
||||
self._messages.clear()
|
||||
if self._state not in {"failed", "poisoned"}:
|
||||
self._state = "closed"
|
||||
|
||||
def snapshot(self) -> CalibrationMqttSnapshot:
|
||||
with self._lock:
|
||||
return CalibrationMqttSnapshot(
|
||||
state=self._state,
|
||||
connect_attempts=self._connect_attempts,
|
||||
subscribe_attempts=self._subscribe_attempts,
|
||||
publish_attempts=self._publish_attempts,
|
||||
qos2_completions=self._qos2_completions,
|
||||
correlated_responses=self._correlated_responses,
|
||||
)
|
||||
|
||||
def _exchange_once(
|
||||
self,
|
||||
envelope: OneShotPublishEnvelope,
|
||||
*,
|
||||
expected_response_topic: str,
|
||||
) -> bytes:
|
||||
if envelope.topic not in {
|
||||
DEVICE_INFO_REQUEST_TOPIC,
|
||||
"lixel/calibration/request/file",
|
||||
}:
|
||||
raise ValueError("read-only calibration request topic is not allowlisted")
|
||||
if expected_response_topic not in {
|
||||
DEVICE_INFO_RESPONSE_TOPIC,
|
||||
CALIBRATION_FILE_RESPONSE_TOPIC,
|
||||
}:
|
||||
raise ValueError("read-only calibration response topic is not allowlisted")
|
||||
with self._lock:
|
||||
if self._state != "ready" or not self._connected or not self._subscribed:
|
||||
raise CalibrationMqttTransportError("calibration transport is not ready")
|
||||
if self._messages:
|
||||
self._state = "poisoned"
|
||||
raise CalibrationReadOutcomeUnknown(
|
||||
"an uncorrelated response preceded the next calibration read",
|
||||
reason_code="unexpected_response_before_publish",
|
||||
)
|
||||
self._publish_attempts += 1
|
||||
|
||||
client = self._require_client()
|
||||
try:
|
||||
info = client.publish(
|
||||
envelope.topic,
|
||||
payload=envelope.payload,
|
||||
qos=envelope.qos,
|
||||
retain=envelope.retain,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
self._fail_after_publish("calibration MQTT publish call failed", exc)
|
||||
if info.rc != mqtt.MQTT_ERR_SUCCESS or info.mid is None:
|
||||
self._fail_after_publish("calibration MQTT publish returned an unsafe result")
|
||||
publish_mid = int(info.mid)
|
||||
deadline = self._monotonic() + self._exchange_timeout_seconds
|
||||
|
||||
def complete() -> bool:
|
||||
with self._lock:
|
||||
return publish_mid in self._completed_publish_mids and bool(self._messages)
|
||||
|
||||
self._drive_until(complete, deadline, post_publish=True)
|
||||
self._service_once(post_publish=True)
|
||||
with self._lock:
|
||||
if len(self._messages) != 1:
|
||||
self._state = "poisoned"
|
||||
raise CalibrationReadOutcomeUnknown(
|
||||
"duplicate calibration response made read correlation ambiguous",
|
||||
reason_code="duplicate_response",
|
||||
)
|
||||
topic, payload = self._messages.popleft()
|
||||
if topic != expected_response_topic:
|
||||
self._state = "poisoned"
|
||||
raise CalibrationReadOutcomeUnknown(
|
||||
"calibration response topic did not match the issued read",
|
||||
reason_code="response_topic_mismatch",
|
||||
)
|
||||
self._correlated_responses += 1
|
||||
return payload
|
||||
|
||||
def _new_client(self) -> mqtt.Client:
|
||||
if self._client_factory is not None:
|
||||
return self._client_factory()
|
||||
return mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
client_id=f"mck1-cal-{secrets.token_hex(7)}",
|
||||
clean_session=True,
|
||||
protocol=mqtt.MQTTv311,
|
||||
reconnect_on_failure=False,
|
||||
)
|
||||
|
||||
def _install_callbacks(self, client: mqtt.Client) -> None:
|
||||
def on_connect(
|
||||
callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
_flags: mqtt.ConnectFlags,
|
||||
reason_code: ReasonCode,
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
if reason_code.is_failure:
|
||||
self._set_callback_error("calibration MQTT broker rejected connection")
|
||||
return
|
||||
with self._lock:
|
||||
self._connected = True
|
||||
self._subscribe_attempts = 1
|
||||
try:
|
||||
result, mid = callback_client.subscribe(list(CALIBRATION_READ_SUBSCRIPTIONS))
|
||||
except (OSError, RuntimeError, ValueError):
|
||||
self._set_callback_error("calibration MQTT response subscription failed")
|
||||
return
|
||||
if result != mqtt.MQTT_ERR_SUCCESS or mid is None:
|
||||
self._set_callback_error("calibration MQTT response subscription was rejected")
|
||||
return
|
||||
with self._lock:
|
||||
self._subscription_mid = mid
|
||||
|
||||
def on_subscribe(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
mid: int,
|
||||
reason_codes: list[ReasonCode],
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
expected_mid = self._subscription_mid
|
||||
if mid != expected_mid or len(reason_codes) != len(CALIBRATION_READ_SUBSCRIPTIONS):
|
||||
self._set_callback_error("calibration MQTT received an unexpected SUBACK")
|
||||
return
|
||||
if any(reason_code.is_failure for reason_code in reason_codes):
|
||||
self._set_callback_error("calibration MQTT broker rejected a subscription")
|
||||
return
|
||||
with self._lock:
|
||||
self._subscribed = True
|
||||
|
||||
def on_publish(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
mid: int,
|
||||
reason_code: ReasonCode,
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
if reason_code.is_failure:
|
||||
self._set_callback_error("calibration MQTT QoS2 transaction failed")
|
||||
return
|
||||
with self._lock:
|
||||
self._completed_publish_mids.add(mid)
|
||||
self._qos2_completions += 1
|
||||
|
||||
def on_message(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
message: mqtt.MQTTMessage,
|
||||
) -> None:
|
||||
allowed = {topic for topic, _qos in CALIBRATION_READ_SUBSCRIPTIONS}
|
||||
if message.topic not in allowed:
|
||||
self._set_callback_error("calibration MQTT received an unreviewed topic")
|
||||
return
|
||||
payload = bytes(message.payload)
|
||||
maximum = (
|
||||
MAX_DEVICE_INFO_RESPONSE_BYTES
|
||||
if message.topic == DEVICE_INFO_RESPONSE_TOPIC
|
||||
else MAX_CALIBRATION_RESPONSE_BYTES
|
||||
)
|
||||
if len(payload) > maximum:
|
||||
self._set_callback_error("calibration MQTT response exceeds bound")
|
||||
return
|
||||
with self._lock:
|
||||
self._messages.append((message.topic, payload))
|
||||
|
||||
def on_disconnect(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
_flags: mqtt.DisconnectFlags,
|
||||
reason_code: ReasonCode,
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
with self._lock:
|
||||
expected = self._closing
|
||||
self._connected = False
|
||||
if not expected or reason_code.is_failure:
|
||||
self._set_callback_error("calibration MQTT connection ended unexpectedly")
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_subscribe = on_subscribe
|
||||
client.on_publish = on_publish
|
||||
client.on_message = on_message
|
||||
client.on_disconnect = on_disconnect
|
||||
|
||||
def _drive_until(
|
||||
self,
|
||||
predicate: Callable[[], bool],
|
||||
deadline: float,
|
||||
*,
|
||||
post_publish: bool,
|
||||
) -> None:
|
||||
while not predicate():
|
||||
with self._lock:
|
||||
callback_error = self._callback_error
|
||||
if callback_error is not None:
|
||||
if post_publish:
|
||||
self._fail_after_publish(callback_error)
|
||||
self._fail_before_publish(callback_error)
|
||||
if self._monotonic() >= deadline:
|
||||
if post_publish:
|
||||
self._fail_after_publish("calibration MQTT response barrier timed out")
|
||||
self._fail_before_publish("calibration MQTT connection/subscription timed out")
|
||||
self._service_once(post_publish=post_publish)
|
||||
|
||||
def _service_once(self, *, post_publish: bool) -> None:
|
||||
client = self._require_client()
|
||||
try:
|
||||
result = client.loop(timeout=CALIBRATION_LOOP_INTERVAL_SECONDS)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
if post_publish:
|
||||
self._fail_after_publish("calibration MQTT network loop failed", exc)
|
||||
self._fail_before_publish("calibration MQTT network loop failed", exc)
|
||||
if result != mqtt.MQTT_ERR_SUCCESS:
|
||||
if post_publish:
|
||||
self._fail_after_publish("calibration MQTT network loop returned an error")
|
||||
self._fail_before_publish("calibration MQTT network loop returned an error")
|
||||
|
||||
def _require_client(self) -> mqtt.Client:
|
||||
if self._client is None:
|
||||
raise CalibrationMqttTransportError("calibration MQTT client is unavailable")
|
||||
return self._client
|
||||
|
||||
def _set_callback_error(self, message: str) -> None:
|
||||
with self._lock:
|
||||
if self._callback_error is None:
|
||||
self._callback_error = message
|
||||
|
||||
def _fail_before_publish(self, message: str, cause: BaseException | None = None) -> None:
|
||||
with self._lock:
|
||||
self._state = "failed"
|
||||
self.close()
|
||||
error = CalibrationMqttTransportError(message)
|
||||
if cause is not None:
|
||||
raise error from cause
|
||||
raise error
|
||||
|
||||
def _fail_after_publish(self, message: str, cause: BaseException | None = None) -> None:
|
||||
with self._lock:
|
||||
self._state = "poisoned"
|
||||
self.close()
|
||||
error = CalibrationReadOutcomeUnknown(message)
|
||||
if cause is not None:
|
||||
raise error from cause
|
||||
raise error
|
||||
|
||||
|
||||
def _build_device_info_discovery(
|
||||
authority: ApplicationControlAuthority,
|
||||
) -> EncodedApplicationRequest:
|
||||
header = ApplicationRequestHeader(
|
||||
message_type="DeviceInfoRequest",
|
||||
authority=authority,
|
||||
)
|
||||
encoded_header = b"".join(
|
||||
(
|
||||
_text_field(5, header.session_id),
|
||||
_text_field(6, authority.openapi_key),
|
||||
)
|
||||
)
|
||||
payload = _bytes_field(1, encoded_header)
|
||||
return EncodedApplicationRequest(
|
||||
ordinal=1,
|
||||
phase="identity-discovery",
|
||||
message_type="DeviceInfoRequest",
|
||||
topic=DEVICE_INFO_REQUEST_TOPIC,
|
||||
response_topic=DEVICE_INFO_RESPONSE_TOPIC,
|
||||
payload=payload,
|
||||
payload_sha256=hashlib.sha256(payload).hexdigest(),
|
||||
payload_bytes=len(payload),
|
||||
mutates_device=False,
|
||||
requires_live_binding=False,
|
||||
response_required=True,
|
||||
session_id=header.session_id,
|
||||
vendor_device_id=None,
|
||||
)
|
||||
|
||||
|
||||
def _varint(value: int) -> bytes:
|
||||
encoded = bytearray()
|
||||
while value > 0x7F:
|
||||
encoded.append((value & 0x7F) | 0x80)
|
||||
value >>= 7
|
||||
encoded.append(value)
|
||||
return bytes(encoded)
|
||||
|
||||
|
||||
def _bytes_field(number: int, value: bytes) -> bytes:
|
||||
key = _varint((number << 3) | 2)
|
||||
return key + _varint(len(value)) + value
|
||||
|
||||
|
||||
def _text_field(number: int, value: str) -> bytes:
|
||||
return _bytes_field(number, value.encode("utf-8", errors="strict"))
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import unicodedata
|
||||
|
||||
QUICK_CONNECT_HOST_PROFILE_PREFIX = "xgrids.lixelkity-k1.quick-connect.fw-3.v2"
|
||||
|
||||
|
||||
def quick_connect_host_profile_id(device_ap_ssid: str) -> str:
|
||||
"""Return an opaque, device-scoped host credential profile identifier.
|
||||
|
||||
LixelGO's reviewed DeviceData model carries WiFiAP_SSID and
|
||||
WiFiAP_Password per device. The SSID is already operator-visible, but the
|
||||
credential-store account stays opaque so neither value is mistaken for a
|
||||
universal K1 factory profile.
|
||||
"""
|
||||
|
||||
normalized = unicodedata.normalize("NFC", device_ap_ssid).strip()
|
||||
encoded = normalized.encode("utf-8")
|
||||
if not 1 <= len(encoded) <= 32:
|
||||
raise ValueError("K1 AP SSID must contain between 1 and 32 UTF-8 bytes")
|
||||
suffix = hashlib.sha256(encoded).hexdigest()[:24]
|
||||
return f"{QUICK_CONNECT_HOST_PROFILE_PREFIX}.{suffix}"
|
||||
@@ -52,6 +52,9 @@ APPLICATION_ID = "nodedc_mission_core_recorded"
|
||||
SESSION_TIMELINE = "session_time"
|
||||
CAPTURE_TIMELINE = "capture_time"
|
||||
JS_MAX_SAFE_INTEGER = (1 << 53) - 1
|
||||
RECORDED_VIEW_POINT_DECIMATION_THRESHOLD = 100_000
|
||||
RECORDED_VIEW_POINT_STRIDE = 4
|
||||
RECORDED_VIEW_POINT_FRAME_STRIDE = 5
|
||||
|
||||
# Rerun keys viewer state by these IDs. Reusing them for every settings-only
|
||||
# blueprint update makes the update overwrite the existing scene instead of
|
||||
@@ -61,8 +64,9 @@ 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")
|
||||
RECORDED_CAMERA_VIEW_ID = UUID("5c1db75b-07cd-479a-903d-f4f2ed554513")
|
||||
RECORDED_PERCEPTION_3D_VIEW_ID = UUID("0496bd2e-2b4d-4a4f-87b8-3ce4f9f7e114")
|
||||
RECORDED_METRICS_VIEW_ID = UUID("f973fc11-0867-4732-ad3c-97008621fab7")
|
||||
RecordedView = Literal["spatial", "perception", "metrics"]
|
||||
RecordedView = Literal["spatial", "perception", "perception3d", "metrics"]
|
||||
|
||||
|
||||
class RrdExportSummary(TypedDict):
|
||||
@@ -191,13 +195,15 @@ def export_k1mqtt_to_rrd(
|
||||
cancel_event: threading.Event | None = None,
|
||||
activity_callback: Callable[[], None] | None = None,
|
||||
) -> RrdExportSummary:
|
||||
"""Losslessly project every decodable K1 data-plane frame into one RRD.
|
||||
"""Project a bounded-rate view of K1 data into one operator RRD.
|
||||
|
||||
The raw capture remains the source of record. The derived RRD uses a
|
||||
recording-local duration timeline whose zero is the durable capture-clock
|
||||
origin for v2 recordings (or the first raw message for legacy captures).
|
||||
It never traverses the bounded live-preview queue, so export throughput
|
||||
cannot drop point or pose frames.
|
||||
It never traverses the bounded live-preview queue. Point-cloud frames and
|
||||
very dense point batches are deterministically sampled for interactive
|
||||
rendering while counters, poses, capture boundaries and the native capture
|
||||
remain complete. AI jobs always read the complete native capture.
|
||||
|
||||
The destination is replaced only after the temporary RRD has been closed,
|
||||
flushed and fsynced. Any decode, timing, sink or rename failure therefore
|
||||
@@ -328,9 +334,10 @@ def export_k1mqtt_to_rrd(
|
||||
)
|
||||
counters.observe_decoded(session_time_ns)
|
||||
if isinstance(decoded, DecodedPointCloudView):
|
||||
_log_points(recording, decoded, settings)
|
||||
counters.point_frames += 1
|
||||
counters.points += decoded.point_count
|
||||
if _should_publish_recorded_point_frame(counters.point_frames):
|
||||
_log_points(recording, decoded, settings)
|
||||
elif isinstance(decoded, DecodedPoseView):
|
||||
position = (
|
||||
float(decoded.position_xyz[0]),
|
||||
@@ -548,6 +555,10 @@ def _recorded_blueprint(
|
||||
"/world/trajectory": rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
# Dynamic perception must not inherit the mapping view's
|
||||
# historical accumulation window. It is rendered latest-at in
|
||||
# the dedicated perception view below.
|
||||
"/world/perception": rrb.EntityBehavior(visible=False),
|
||||
},
|
||||
# A positive window accumulates historical frames. With no
|
||||
# window, latest-at deliberately keeps one current LiDAR frame.
|
||||
@@ -560,15 +571,41 @@ def _recorded_blueprint(
|
||||
background=[7, 8, 10, 255],
|
||||
)
|
||||
camera_view.id = RECORDED_CAMERA_VIEW_ID
|
||||
perception_3d_view = rrb.Spatial3DView(
|
||||
# Present derived cuboids in the calibrated world scene instead of an
|
||||
# isolated /world/perception subtree. No visible time range is set on
|
||||
# this view: points and cuboids therefore remain latest-at and do not
|
||||
# accumulate into the overlapping-box failure mode.
|
||||
origin="/world",
|
||||
name="Сегментация и объекты · 3D",
|
||||
background=[7, 8, 10, 255],
|
||||
line_grid=rrb.LineGrid3D(
|
||||
visible=settings.show_grid,
|
||||
color=[86, 91, 99, 110],
|
||||
stroke_width=0.75,
|
||||
),
|
||||
overrides={
|
||||
"/world/points": rrb.EntityBehavior(visible=settings.show_points),
|
||||
"/world/trajectory": rrb.EntityBehavior(
|
||||
visible=settings.show_trajectory,
|
||||
),
|
||||
"/world/perception": rrb.EntityBehavior(visible=True),
|
||||
"/world/perception/lidar": rrb.EntityBehavior(visible=False),
|
||||
},
|
||||
)
|
||||
perception_3d_view.id = RECORDED_PERCEPTION_3D_VIEW_ID
|
||||
metrics_view = rrb.TimeSeriesView(
|
||||
origin="/metrics/device",
|
||||
name="Маршрут и время",
|
||||
)
|
||||
metrics_view.id = RECORDED_METRICS_VIEW_ID
|
||||
active_tab = {"spatial": 0, "perception": 1, "metrics": 2}[active_view]
|
||||
active_tab = {"spatial": 0, "perception": 1, "perception3d": 2, "metrics": 3}[
|
||||
active_view
|
||||
]
|
||||
root_container = rrb.Tabs(
|
||||
spatial_view,
|
||||
camera_view,
|
||||
perception_3d_view,
|
||||
metrics_view,
|
||||
active_tab=active_tab,
|
||||
)
|
||||
@@ -747,6 +784,7 @@ def _log_points(
|
||||
if frame.colors_rgb is None
|
||||
else np.frombuffer(frame.colors_rgb, dtype=np.uint8).reshape((-1, 3))
|
||||
)
|
||||
positions, intensities, rgb = _recorded_view_points(positions, intensities, rgb)
|
||||
recording.log(
|
||||
"/world/points",
|
||||
rr.Points3D(
|
||||
@@ -757,6 +795,35 @@ def _log_points(
|
||||
)
|
||||
|
||||
|
||||
def _recorded_view_points(
|
||||
positions: np.ndarray,
|
||||
intensities: np.ndarray,
|
||||
rgb: np.ndarray | None,
|
||||
) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
|
||||
# The native K1 capture remains the complete source of record and all AI
|
||||
# jobs read that source directly. Rerun is the interactive operator
|
||||
# projection: bound the temporal frame rate, but preserve complete normal
|
||||
# K1 scans. The AI composition intentionally uses one latest point frame
|
||||
# so dynamic cuboids do not stack; thinning a normal ~2.4k-point scan here
|
||||
# made that view visibly bald. Keep spatial decimation only as an emergency
|
||||
# guard for unusually large (>100k point) frames from future hardware.
|
||||
if len(positions) <= RECORDED_VIEW_POINT_DECIMATION_THRESHOLD:
|
||||
return positions, intensities, rgb
|
||||
return (
|
||||
positions[::RECORDED_VIEW_POINT_STRIDE],
|
||||
intensities[::RECORDED_VIEW_POINT_STRIDE],
|
||||
None if rgb is None else rgb[::RECORDED_VIEW_POINT_STRIDE],
|
||||
)
|
||||
|
||||
|
||||
def _should_publish_recorded_point_frame(frame_number: int) -> bool:
|
||||
"""Keep the first point frame and then a stable 2 Hz operator cadence."""
|
||||
|
||||
if frame_number < 1:
|
||||
raise ValueError("point frame number must be positive")
|
||||
return frame_number == 1 or (frame_number - 1) % RECORDED_VIEW_POINT_FRAME_STRIDE == 0
|
||||
|
||||
|
||||
def _log_pose(
|
||||
recording: rr.RecordingStream,
|
||||
frame: DecodedPoseView,
|
||||
|
||||
@@ -11,6 +11,10 @@ from pathlib import Path
|
||||
from typing import Literal, Protocol, TypedDict
|
||||
|
||||
from k1link.artifacts import utc_now_iso, write_json_atomic
|
||||
from k1link.compute.live_perception import (
|
||||
LivePerceptionResultFrame,
|
||||
decode_live_perception_result,
|
||||
)
|
||||
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
|
||||
@@ -34,6 +38,7 @@ BridgeFactory = Callable[..., RerunBridge]
|
||||
# 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
|
||||
PERCEPTION_PREVIEW_QUEUE_SIZE = 2
|
||||
|
||||
|
||||
class CanonicalNormalizer(Protocol):
|
||||
@@ -92,6 +97,9 @@ class VisualizationRuntime:
|
||||
self._closed = False
|
||||
self._scene_settings = RerunSceneSettings()
|
||||
self._metrics = BridgeMetrics()
|
||||
self._perception_messages: queue.Queue[LivePerceptionResultFrame] = queue.Queue(
|
||||
maxsize=PERCEPTION_PREVIEW_QUEUE_SIZE
|
||||
)
|
||||
|
||||
def snapshot(self) -> RuntimeSnapshot:
|
||||
with self._lock:
|
||||
@@ -113,6 +121,29 @@ class VisualizationRuntime:
|
||||
self._notify()
|
||||
return self.snapshot()
|
||||
|
||||
def publish_perception_result(self, encoded: bytes) -> bool:
|
||||
"""Admit one validated latest-wins AI result without blocking acquisition."""
|
||||
|
||||
frame = decode_live_perception_result(encoded)
|
||||
with self._lock:
|
||||
active = self._source_mode in {"live", "replay"} and not self._closed
|
||||
if not active:
|
||||
return False
|
||||
try:
|
||||
self._perception_messages.put_nowait(frame)
|
||||
return True
|
||||
except queue.Full:
|
||||
pass
|
||||
with suppress(queue.Empty):
|
||||
self._perception_messages.get_nowait()
|
||||
self._perception_messages.task_done()
|
||||
self._metrics.perception_dropped()
|
||||
try:
|
||||
self._perception_messages.put_nowait(frame)
|
||||
return True
|
||||
except queue.Full:
|
||||
return False
|
||||
|
||||
def start_replay(self, path: Path, *, speed: float = 1.0, loop: bool = False) -> None:
|
||||
resolved = path.expanduser().resolve()
|
||||
if not resolved.is_file():
|
||||
@@ -140,10 +171,12 @@ class VisualizationRuntime:
|
||||
host: str,
|
||||
out_dir: Path,
|
||||
*,
|
||||
duration_seconds: float = 3600.0,
|
||||
duration_seconds: float | None = None,
|
||||
project_name: str,
|
||||
) -> None:
|
||||
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
|
||||
if duration_seconds is not None and (
|
||||
not math.isfinite(duration_seconds) or duration_seconds <= 0
|
||||
):
|
||||
raise ValueError("длительность приёма должна быть больше нуля")
|
||||
clock_established = threading.Event()
|
||||
self._start(
|
||||
@@ -311,7 +344,7 @@ class VisualizationRuntime:
|
||||
host: str,
|
||||
out_dir: Path,
|
||||
*,
|
||||
duration_seconds: float,
|
||||
duration_seconds: float | None,
|
||||
project_name: str,
|
||||
clock_established: threading.Event,
|
||||
) -> None:
|
||||
@@ -360,6 +393,12 @@ class VisualizationRuntime:
|
||||
publisher_ready = threading.Event()
|
||||
publisher_aborted = threading.Event()
|
||||
publisher_error: list[BaseException] = []
|
||||
while True:
|
||||
try:
|
||||
self._perception_messages.get_nowait()
|
||||
self._perception_messages.task_done()
|
||||
except queue.Empty:
|
||||
break
|
||||
|
||||
def enqueue(message: StreamMessage) -> None:
|
||||
# A plugin may consume non-visual status before the bounded preview
|
||||
@@ -426,11 +465,30 @@ class VisualizationRuntime:
|
||||
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():
|
||||
while (
|
||||
not source_done.is_set()
|
||||
or not messages.empty()
|
||||
or not self._perception_messages.empty()
|
||||
):
|
||||
if (
|
||||
self._stop_event.is_set()
|
||||
and source_done.is_set()
|
||||
and messages.empty()
|
||||
and self._perception_messages.empty()
|
||||
):
|
||||
break
|
||||
try:
|
||||
message = messages.get(timeout=0.1)
|
||||
perception = self._perception_messages.get_nowait()
|
||||
except queue.Empty:
|
||||
perception = None
|
||||
if perception is not None:
|
||||
try:
|
||||
bridge.process_perception(perception)
|
||||
finally:
|
||||
self._perception_messages.task_done()
|
||||
continue
|
||||
try:
|
||||
message = messages.get(timeout=0.05)
|
||||
except queue.Empty:
|
||||
continue
|
||||
try:
|
||||
@@ -573,7 +631,7 @@ def new_live_session_dir(sessions_root: Path) -> Path:
|
||||
def _write_live_session_preamble(
|
||||
out_dir: Path,
|
||||
host: str,
|
||||
duration_seconds: float,
|
||||
duration_seconds: float | None,
|
||||
project_name: str,
|
||||
) -> None:
|
||||
out_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
Reference in New Issue
Block a user