feat: prove and decode K1 realtime MQTT streams
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
"""Bounded, offline analysis of sensitive K1 evidence artifacts."""
|
||||
|
||||
from k1link.analyze.stream_summary import (
|
||||
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
||||
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
||||
StreamSummary,
|
||||
summarize_mqtt_streams,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES",
|
||||
"MAX_STREAM_SUMMARY_PAYLOAD_BYTES",
|
||||
"StreamSummary",
|
||||
"summarize_mqtt_streams",
|
||||
]
|
||||
@@ -0,0 +1,354 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TypedDict
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
from k1link.mqtt import DEFAULT_MAX_MESSAGE_BYTES, iter_capture_frames
|
||||
from k1link.protocol import (
|
||||
DecodeLimits,
|
||||
StreamDecodeError,
|
||||
decode_lio_pcl,
|
||||
decode_lio_pose,
|
||||
)
|
||||
|
||||
LIO_PCL_TOPIC = "lixel/application/report/lio_pcl"
|
||||
LIO_POSE_TOPIC = "lixel/application/report/lio_pose"
|
||||
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES = DecodeLimits().max_mqtt_payload_bytes
|
||||
MAX_STREAM_SUMMARY_PAYLOAD_BYTES = DEFAULT_MAX_MESSAGE_BYTES
|
||||
_HASH_CHUNK_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
class IntRangeSummary(TypedDict):
|
||||
min: int | None
|
||||
max: int | None
|
||||
|
||||
|
||||
class ScalerSummary(TypedDict):
|
||||
min: int | None
|
||||
max: int | None
|
||||
constant: bool | None
|
||||
|
||||
|
||||
class ByteSummary(TypedDict):
|
||||
total: int
|
||||
per_frame: IntRangeSummary
|
||||
|
||||
|
||||
class PointSummary(TypedDict):
|
||||
total: int
|
||||
per_frame: IntRangeSummary
|
||||
|
||||
|
||||
class SourceSummary(TypedDict):
|
||||
bytes: int
|
||||
sha256: str
|
||||
|
||||
|
||||
class LimitSummary(TypedDict):
|
||||
max_payload_bytes: int
|
||||
max_compressed_bytes: int
|
||||
max_decompressed_bytes: int
|
||||
max_compression_ratio: int
|
||||
max_points_per_frame: int
|
||||
|
||||
|
||||
class FrameSummary(TypedDict):
|
||||
count: int
|
||||
payload_bytes: int
|
||||
encoded_frame_bytes: int
|
||||
other_count: int
|
||||
other_payload_bytes: int
|
||||
|
||||
|
||||
class DecodeSummary(TypedDict):
|
||||
attempted: int
|
||||
successes: int
|
||||
errors: int
|
||||
|
||||
|
||||
class PointCloudSummary(TypedDict):
|
||||
frame_count: int
|
||||
payload_bytes: int
|
||||
decode_successes: int
|
||||
decode_errors: int
|
||||
points: PointSummary
|
||||
scalers: ScalerSummary
|
||||
compressed_bytes: ByteSummary
|
||||
decompressed_bytes: ByteSummary
|
||||
|
||||
|
||||
class PoseSummary(TypedDict):
|
||||
frame_count: int
|
||||
payload_bytes: int
|
||||
decode_successes: int
|
||||
decode_errors: int
|
||||
first_to_last_displacement_meters: float | None
|
||||
|
||||
|
||||
class StreamSummary(TypedDict):
|
||||
schema_version: int
|
||||
created_at_utc: str
|
||||
sensitivity: str
|
||||
source: SourceSummary
|
||||
limits: LimitSummary
|
||||
frames: FrameSummary
|
||||
decoding: DecodeSummary
|
||||
point_cloud: PointCloudSummary
|
||||
pose: PoseSummary
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _IntRange:
|
||||
minimum: int | None = None
|
||||
maximum: int | None = None
|
||||
|
||||
def add(self, value: int) -> None:
|
||||
if self.minimum is None or value < self.minimum:
|
||||
self.minimum = value
|
||||
if self.maximum is None or value > self.maximum:
|
||||
self.maximum = value
|
||||
|
||||
def summary(self) -> IntRangeSummary:
|
||||
return {"min": self.minimum, "max": self.maximum}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PointCloudAccumulator:
|
||||
frame_count: int = 0
|
||||
payload_bytes: int = 0
|
||||
decode_successes: int = 0
|
||||
decode_errors: int = 0
|
||||
point_total: int = 0
|
||||
compressed_total: int = 0
|
||||
decompressed_total: int = 0
|
||||
points_per_frame: _IntRange = field(default_factory=_IntRange)
|
||||
scalers: _IntRange = field(default_factory=_IntRange)
|
||||
compressed_per_frame: _IntRange = field(default_factory=_IntRange)
|
||||
decompressed_per_frame: _IntRange = field(default_factory=_IntRange)
|
||||
first_scaler: int | None = None
|
||||
scaler_constant: bool = True
|
||||
|
||||
def record_payload(self, payload_bytes: int) -> None:
|
||||
self.frame_count += 1
|
||||
self.payload_bytes += payload_bytes
|
||||
|
||||
def record_error(self) -> None:
|
||||
self.decode_errors += 1
|
||||
|
||||
def record_decoded(
|
||||
self,
|
||||
*,
|
||||
point_count: int,
|
||||
scaler: int,
|
||||
compressed_bytes: int,
|
||||
decompressed_bytes: int,
|
||||
) -> None:
|
||||
self.decode_successes += 1
|
||||
self.point_total += point_count
|
||||
self.compressed_total += compressed_bytes
|
||||
self.decompressed_total += decompressed_bytes
|
||||
self.points_per_frame.add(point_count)
|
||||
self.scalers.add(scaler)
|
||||
self.compressed_per_frame.add(compressed_bytes)
|
||||
self.decompressed_per_frame.add(decompressed_bytes)
|
||||
if self.first_scaler is None:
|
||||
self.first_scaler = scaler
|
||||
elif scaler != self.first_scaler:
|
||||
self.scaler_constant = False
|
||||
|
||||
def summary(self) -> PointCloudSummary:
|
||||
scaler_summary: ScalerSummary = {
|
||||
"min": self.scalers.minimum,
|
||||
"max": self.scalers.maximum,
|
||||
"constant": self.scaler_constant if self.decode_successes else None,
|
||||
}
|
||||
return {
|
||||
"frame_count": self.frame_count,
|
||||
"payload_bytes": self.payload_bytes,
|
||||
"decode_successes": self.decode_successes,
|
||||
"decode_errors": self.decode_errors,
|
||||
"points": {
|
||||
"total": self.point_total,
|
||||
"per_frame": self.points_per_frame.summary(),
|
||||
},
|
||||
"scalers": scaler_summary,
|
||||
"compressed_bytes": {
|
||||
"total": self.compressed_total,
|
||||
"per_frame": self.compressed_per_frame.summary(),
|
||||
},
|
||||
"decompressed_bytes": {
|
||||
"total": self.decompressed_total,
|
||||
"per_frame": self.decompressed_per_frame.summary(),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class _PoseAccumulator:
|
||||
frame_count: int = 0
|
||||
payload_bytes: int = 0
|
||||
decode_successes: int = 0
|
||||
decode_errors: int = 0
|
||||
first_position: tuple[float, float, float] | None = None
|
||||
last_position: tuple[float, float, float] | None = None
|
||||
|
||||
def record_payload(self, payload_bytes: int) -> None:
|
||||
self.frame_count += 1
|
||||
self.payload_bytes += payload_bytes
|
||||
|
||||
def record_error(self) -> None:
|
||||
self.decode_errors += 1
|
||||
|
||||
def record_decoded(self, position: tuple[float, float, float]) -> None:
|
||||
self.decode_successes += 1
|
||||
if self.first_position is None:
|
||||
self.first_position = position
|
||||
self.last_position = position
|
||||
|
||||
def summary(self) -> PoseSummary:
|
||||
displacement: float | None = None
|
||||
if self.first_position is not None and self.last_position is not None:
|
||||
candidate = math.dist(self.first_position, self.last_position)
|
||||
if math.isfinite(candidate):
|
||||
displacement = candidate
|
||||
return {
|
||||
"frame_count": self.frame_count,
|
||||
"payload_bytes": self.payload_bytes,
|
||||
"decode_successes": self.decode_successes,
|
||||
"decode_errors": self.decode_errors,
|
||||
"first_to_last_displacement_meters": displacement,
|
||||
}
|
||||
|
||||
|
||||
def summarize_mqtt_streams(
|
||||
capture: Path,
|
||||
*,
|
||||
max_payload_bytes: int = DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
||||
) -> StreamSummary:
|
||||
"""Stream a raw MQTT capture into an aggregate-only, coordinate-free summary."""
|
||||
if not 1 <= max_payload_bytes <= MAX_STREAM_SUMMARY_PAYLOAD_BYTES:
|
||||
raise ValueError(
|
||||
"max_payload_bytes must be between 1 and "
|
||||
f"{MAX_STREAM_SUMMARY_PAYLOAD_BYTES}"
|
||||
)
|
||||
|
||||
capture_path = capture.expanduser()
|
||||
before = capture_path.stat()
|
||||
if not stat.S_ISREG(before.st_mode):
|
||||
raise ValueError("capture must be a regular file")
|
||||
|
||||
capture_sha256 = _sha256_file(capture_path)
|
||||
hashed = capture_path.stat()
|
||||
if _file_identity(before) != _file_identity(hashed):
|
||||
raise RuntimeError("capture changed while it was being hashed")
|
||||
|
||||
limits = DecodeLimits(max_mqtt_payload_bytes=max_payload_bytes)
|
||||
point_cloud = _PointCloudAccumulator()
|
||||
pose = _PoseAccumulator()
|
||||
frame_count = 0
|
||||
payload_bytes = 0
|
||||
encoded_frame_bytes = 0
|
||||
other_count = 0
|
||||
other_payload_bytes = 0
|
||||
|
||||
for capture_frame in iter_capture_frames(
|
||||
capture_path,
|
||||
max_payload_bytes=max_payload_bytes,
|
||||
):
|
||||
frame_count += 1
|
||||
frame_payload_bytes = len(capture_frame.payload)
|
||||
payload_bytes += frame_payload_bytes
|
||||
encoded_frame_bytes += capture_frame.raw_frame_bytes
|
||||
|
||||
if capture_frame.topic == LIO_PCL_TOPIC:
|
||||
point_cloud.record_payload(frame_payload_bytes)
|
||||
try:
|
||||
decoded = decode_lio_pcl(capture_frame.payload, limits)
|
||||
except StreamDecodeError:
|
||||
point_cloud.record_error()
|
||||
continue
|
||||
point_cloud.record_decoded(
|
||||
point_count=len(decoded.points),
|
||||
scaler=decoded.header.scaler,
|
||||
compressed_bytes=decoded.compressed_bytes,
|
||||
decompressed_bytes=decoded.decompressed_bytes,
|
||||
)
|
||||
continue
|
||||
|
||||
if capture_frame.topic == LIO_POSE_TOPIC:
|
||||
pose.record_payload(frame_payload_bytes)
|
||||
try:
|
||||
decoded_pose = decode_lio_pose(capture_frame.payload, limits)
|
||||
except StreamDecodeError:
|
||||
pose.record_error()
|
||||
continue
|
||||
pose.record_decoded(decoded_pose.position_xyz)
|
||||
continue
|
||||
|
||||
# Unknown topic text is intentionally neither retained nor emitted: a malformed
|
||||
# capture could place an identifier in that field.
|
||||
other_count += 1
|
||||
other_payload_bytes += frame_payload_bytes
|
||||
|
||||
after = capture_path.stat()
|
||||
if _file_identity(hashed) != _file_identity(after):
|
||||
raise RuntimeError("capture changed while it was being analyzed")
|
||||
|
||||
decode_successes = point_cloud.decode_successes + pose.decode_successes
|
||||
decode_errors = point_cloud.decode_errors + pose.decode_errors
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"created_at_utc": utc_now_iso(),
|
||||
"sensitivity": (
|
||||
"sensitive derived K1 stream statistics; keep in ignored storage; "
|
||||
"identifiers, keys, error text and coordinates are omitted"
|
||||
),
|
||||
"source": {
|
||||
"bytes": hashed.st_size,
|
||||
"sha256": capture_sha256,
|
||||
},
|
||||
"limits": {
|
||||
"max_payload_bytes": limits.max_mqtt_payload_bytes,
|
||||
"max_compressed_bytes": limits.max_compressed_bytes,
|
||||
"max_decompressed_bytes": limits.max_decompressed_bytes,
|
||||
"max_compression_ratio": limits.max_compression_ratio,
|
||||
"max_points_per_frame": limits.max_points_per_frame,
|
||||
},
|
||||
"frames": {
|
||||
"count": frame_count,
|
||||
"payload_bytes": payload_bytes,
|
||||
"encoded_frame_bytes": encoded_frame_bytes,
|
||||
"other_count": other_count,
|
||||
"other_payload_bytes": other_payload_bytes,
|
||||
},
|
||||
"decoding": {
|
||||
"attempted": point_cloud.frame_count + pose.frame_count,
|
||||
"successes": decode_successes,
|
||||
"errors": decode_errors,
|
||||
},
|
||||
"point_cloud": point_cloud.summary(),
|
||||
"pose": pose.summary(),
|
||||
}
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(_HASH_CHUNK_BYTES), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _file_identity(file_stat: os.stat_result) -> tuple[int, int, int, int]:
|
||||
return (
|
||||
file_stat.st_dev,
|
||||
file_stat.st_ino,
|
||||
file_stat.st_size,
|
||||
file_stat.st_mtime_ns,
|
||||
)
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from importlib.metadata import version
|
||||
from typing import TypedDict
|
||||
|
||||
from bleak import BleakClient, BleakScanner
|
||||
from bleak.exc import BleakDeviceNotFoundError
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
|
||||
class CharacteristicReadResult(TypedDict):
|
||||
schema_version: int
|
||||
started_at_utc: str
|
||||
completed_at_utc: str
|
||||
adapter: str
|
||||
bleak_version: str
|
||||
device_macos_uuid: str
|
||||
device_name: str
|
||||
characteristic_uuid: str
|
||||
operation: str
|
||||
value_length: int
|
||||
value_hex: str
|
||||
|
||||
|
||||
async def read_characteristic_once(
|
||||
device_macos_uuid: str,
|
||||
characteristic_uuid: str,
|
||||
timeout_seconds: float,
|
||||
) -> CharacteristicReadResult:
|
||||
"""Read one explicitly selected characteristic once without pairing or writes."""
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
|
||||
started_at = utc_now_iso()
|
||||
async with asyncio.timeout(timeout_seconds):
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
|
||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||
characteristic = client.services.get_characteristic(characteristic_uuid)
|
||||
if characteristic is None:
|
||||
raise ValueError(f"Characteristic not found: {characteristic_uuid}")
|
||||
if "read" not in characteristic.properties:
|
||||
raise ValueError(f"Characteristic is not readable: {characteristic_uuid}")
|
||||
value = bytes(await client.read_gatt_char(characteristic))
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_macos_uuid": device_macos_uuid,
|
||||
"device_name": client.name,
|
||||
"characteristic_uuid": characteristic.uuid,
|
||||
"operation": "single_gatt_read_no_pair_no_write",
|
||||
"value_length": len(value),
|
||||
"value_hex": value.hex(),
|
||||
}
|
||||
@@ -0,0 +1,289 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import ipaddress
|
||||
from importlib.metadata import version
|
||||
from time import monotonic
|
||||
from typing import Literal, TypedDict
|
||||
|
||||
from bleak import BleakClient, BleakScanner
|
||||
from bleak.exc import BleakDeviceNotFoundError, BleakError
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
PROFILE_ID = "xgrids-k1-fw3-wifi-v1"
|
||||
SERVICE_UUID = "00007f00-0000-1000-8000-00805f9b34fb"
|
||||
WRITE_CHARACTERISTIC_UUID = "00007f01-0000-1000-8000-00805f9b34fb"
|
||||
STATUS_CHARACTERISTIC_UUID = "00007f02-0000-1000-8000-00805f9b34fb"
|
||||
FRAME_LENGTH = 99
|
||||
SSID_SLOT_LENGTH = 32
|
||||
PASSWORD_SLOT_LENGTH = 64
|
||||
AP_FALLBACK_IPV4 = "192.168.56.1"
|
||||
ProvisioningOutcome = Literal[
|
||||
"lan_address_observed",
|
||||
"status_changed",
|
||||
"no_status_change_before_timeout",
|
||||
"ble_disconnected_after_write",
|
||||
]
|
||||
WriteMode = Literal["auto", "with_response", "without_response"]
|
||||
ResolvedWriteMode = Literal["with_response", "without_response"]
|
||||
|
||||
|
||||
class WifiStatus(TypedDict):
|
||||
value_length: int
|
||||
mode: str | None
|
||||
ipv4: str | None
|
||||
status_code: int
|
||||
reserved: int | None
|
||||
trailer_hex: str
|
||||
|
||||
|
||||
class StatusObservation(TypedDict):
|
||||
observed_at_utc: str
|
||||
seconds_after_write: float
|
||||
status: WifiStatus
|
||||
|
||||
|
||||
class WifiProvisioningResult(TypedDict):
|
||||
schema_version: int
|
||||
profile_id: str
|
||||
started_at_utc: str
|
||||
completed_at_utc: str
|
||||
adapter: str
|
||||
bleak_version: str
|
||||
device_macos_uuid: str
|
||||
device_name: str
|
||||
service_uuid: str
|
||||
write_characteristic_uuid: str
|
||||
status_characteristic_uuid: str
|
||||
operation: str
|
||||
write_mode: ResolvedWriteMode
|
||||
write_without_response_advertised: bool
|
||||
max_write_without_response_size: int
|
||||
frame_length: int
|
||||
baseline_status: WifiStatus
|
||||
observations: list[StatusObservation]
|
||||
outcome: ProvisioningOutcome
|
||||
|
||||
|
||||
def build_wifi_provisioning_frame(ssid: str, password: str) -> bytearray:
|
||||
"""Build the deterministic 99-byte frame used by LixelGO for K1 Wi-Fi setup."""
|
||||
ssid_bytes = ssid.encode("utf-8")
|
||||
password_bytes = password.encode("utf-8")
|
||||
|
||||
if not ssid_bytes:
|
||||
raise ValueError("SSID must not be empty")
|
||||
if not password_bytes:
|
||||
raise ValueError("Wi-Fi password must not be empty")
|
||||
if len(ssid_bytes) > SSID_SLOT_LENGTH:
|
||||
raise ValueError("SSID must be at most 32 UTF-8 bytes")
|
||||
if len(password_bytes) > PASSWORD_SLOT_LENGTH:
|
||||
raise ValueError("Wi-Fi password must be at most 64 UTF-8 bytes")
|
||||
|
||||
frame = bytearray(FRAME_LENGTH)
|
||||
frame[0] = len(ssid_bytes)
|
||||
frame[1 : 1 + len(ssid_bytes)] = ssid_bytes
|
||||
frame[33] = len(password_bytes)
|
||||
frame[34 : 34 + len(password_bytes)] = password_bytes
|
||||
frame[98] = 0
|
||||
return frame
|
||||
|
||||
|
||||
def parse_wifi_status(value: bytes) -> WifiStatus:
|
||||
"""Parse the non-secret status frame returned by the K1 read characteristic."""
|
||||
if len(value) < 51:
|
||||
raise ValueError("K1 Wi-Fi status must contain at least 51 bytes")
|
||||
|
||||
mode_length = value[0]
|
||||
if mode_length > SSID_SLOT_LENGTH:
|
||||
raise ValueError("K1 Wi-Fi status mode length is invalid")
|
||||
try:
|
||||
mode = value[1 : 1 + mode_length].decode("utf-8") if mode_length else None
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError("K1 Wi-Fi status mode is not valid UTF-8") from exc
|
||||
|
||||
address_length = value[33]
|
||||
address_start = 34
|
||||
address_end = address_start + address_length
|
||||
if address_end > len(value):
|
||||
raise ValueError("K1 Wi-Fi status address length exceeds the frame")
|
||||
|
||||
ipv4: str | None = None
|
||||
if address_length:
|
||||
try:
|
||||
address = ipaddress.ip_address(value[address_start:address_end])
|
||||
except ValueError:
|
||||
address = None
|
||||
if isinstance(address, ipaddress.IPv4Address):
|
||||
ipv4 = str(address)
|
||||
|
||||
return {
|
||||
"value_length": len(value),
|
||||
"mode": mode,
|
||||
"ipv4": ipv4,
|
||||
"status_code": value[50],
|
||||
"reserved": value[51] if len(value) > 51 else None,
|
||||
"trailer_hex": value[52:].hex() if len(value) > 52 else "",
|
||||
}
|
||||
|
||||
|
||||
def _outcome(
|
||||
baseline: WifiStatus,
|
||||
observations: list[StatusObservation],
|
||||
disconnected: bool,
|
||||
) -> ProvisioningOutcome:
|
||||
if observations:
|
||||
final = observations[-1]["status"]
|
||||
if final["ipv4"] not in (None, AP_FALLBACK_IPV4):
|
||||
return "lan_address_observed"
|
||||
if final != baseline:
|
||||
return "status_changed"
|
||||
if disconnected:
|
||||
return "ble_disconnected_after_write"
|
||||
return "no_status_change_before_timeout"
|
||||
|
||||
|
||||
async def provision_wifi_once(
|
||||
device_macos_uuid: str,
|
||||
ssid: str,
|
||||
password: str,
|
||||
timeout_seconds: float = 45.0,
|
||||
poll_interval_seconds: float = 1.0,
|
||||
write_mode: WriteMode = "auto",
|
||||
) -> WifiProvisioningResult:
|
||||
"""Perform one reviewed provisioning write and poll the K1 status characteristic."""
|
||||
if timeout_seconds <= 0:
|
||||
raise ValueError("timeout_seconds must be positive")
|
||||
if poll_interval_seconds <= 0:
|
||||
raise ValueError("poll_interval_seconds must be positive")
|
||||
if write_mode not in ("auto", "with_response", "without_response"):
|
||||
raise ValueError(f"Unsupported write mode: {write_mode}")
|
||||
|
||||
frame = build_wifi_provisioning_frame(ssid, password)
|
||||
started_at = utc_now_iso()
|
||||
observations: list[StatusObservation] = []
|
||||
disconnected = False
|
||||
|
||||
try:
|
||||
async with asyncio.timeout(timeout_seconds + 25.0):
|
||||
device = await BleakScanner.find_device_by_address(
|
||||
device_macos_uuid,
|
||||
timeout=min(20.0, timeout_seconds),
|
||||
)
|
||||
if device is None:
|
||||
raise BleakDeviceNotFoundError(
|
||||
device_macos_uuid,
|
||||
"Device was not rediscovered; keep the K1 powered and nearby.",
|
||||
)
|
||||
|
||||
async with BleakClient(device, timeout=timeout_seconds, pair=False) as client:
|
||||
device_name = client.name
|
||||
service = client.services.get_service(SERVICE_UUID)
|
||||
write_characteristic = client.services.get_characteristic(
|
||||
WRITE_CHARACTERISTIC_UUID
|
||||
)
|
||||
status_characteristic = client.services.get_characteristic(
|
||||
STATUS_CHARACTERISTIC_UUID
|
||||
)
|
||||
if service is None:
|
||||
raise ValueError(f"Reviewed K1 service not found: {SERVICE_UUID}")
|
||||
if write_characteristic is None:
|
||||
raise ValueError(
|
||||
"Reviewed K1 write characteristic not found: "
|
||||
f"{WRITE_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if status_characteristic is None:
|
||||
raise ValueError(
|
||||
"Reviewed K1 status characteristic not found: "
|
||||
f"{STATUS_CHARACTERISTIC_UUID}"
|
||||
)
|
||||
if write_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError("K1 write characteristic is attached to an unexpected service")
|
||||
if status_characteristic.service_uuid != service.uuid:
|
||||
raise ValueError(
|
||||
"K1 status characteristic is attached to an unexpected service"
|
||||
)
|
||||
if "read" not in status_characteristic.properties:
|
||||
raise ValueError("Reviewed K1 status characteristic is not readable")
|
||||
|
||||
properties = set(write_characteristic.properties)
|
||||
max_without_response = (
|
||||
write_characteristic.max_write_without_response_size
|
||||
)
|
||||
resolved_write_mode: ResolvedWriteMode
|
||||
if write_mode == "auto":
|
||||
if "write-without-response" in properties:
|
||||
resolved_write_mode = "without_response"
|
||||
elif "write" in properties:
|
||||
resolved_write_mode = "with_response"
|
||||
else:
|
||||
raise ValueError("Reviewed K1 characteristic is not writable")
|
||||
elif write_mode == "with_response":
|
||||
if "write" not in properties:
|
||||
raise ValueError(
|
||||
"Reviewed K1 characteristic does not advertise writes with response"
|
||||
)
|
||||
resolved_write_mode = "with_response"
|
||||
else:
|
||||
if len(frame) > max_without_response:
|
||||
raise ValueError(
|
||||
"Provisioning frame exceeds the negotiated write-without-response size"
|
||||
)
|
||||
resolved_write_mode = "without_response"
|
||||
|
||||
baseline_value = bytes(await client.read_gatt_char(status_characteristic))
|
||||
baseline = parse_wifi_status(baseline_value)
|
||||
|
||||
await client.write_gatt_char(
|
||||
write_characteristic,
|
||||
frame,
|
||||
response=resolved_write_mode == "with_response",
|
||||
)
|
||||
write_completed = monotonic()
|
||||
deadline = write_completed + timeout_seconds
|
||||
|
||||
while monotonic() < deadline:
|
||||
try:
|
||||
value = bytes(await client.read_gatt_char(status_characteristic))
|
||||
except BleakError:
|
||||
if not client.is_connected:
|
||||
disconnected = True
|
||||
break
|
||||
raise
|
||||
status = parse_wifi_status(value)
|
||||
observation: StatusObservation = {
|
||||
"observed_at_utc": utc_now_iso(),
|
||||
"seconds_after_write": round(monotonic() - write_completed, 3),
|
||||
"status": status,
|
||||
}
|
||||
if not observations or status != observations[-1]["status"]:
|
||||
observations.append(observation)
|
||||
if status["ipv4"] not in (None, AP_FALLBACK_IPV4):
|
||||
break
|
||||
await asyncio.sleep(poll_interval_seconds)
|
||||
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"profile_id": PROFILE_ID,
|
||||
"started_at_utc": started_at,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"adapter": "CoreBluetooth",
|
||||
"bleak_version": version("bleak"),
|
||||
"device_macos_uuid": device_macos_uuid,
|
||||
"device_name": device_name,
|
||||
"service_uuid": service.uuid,
|
||||
"write_characteristic_uuid": write_characteristic.uuid,
|
||||
"status_characteristic_uuid": status_characteristic.uuid,
|
||||
"operation": "single_reviewed_wifi_provisioning_write",
|
||||
"write_mode": resolved_write_mode,
|
||||
"write_without_response_advertised": (
|
||||
"write-without-response" in properties
|
||||
),
|
||||
"max_write_without_response_size": max_without_response,
|
||||
"frame_length": len(frame),
|
||||
"baseline_status": baseline,
|
||||
"observations": observations,
|
||||
"outcome": _outcome(baseline, observations, disconnected),
|
||||
}
|
||||
finally:
|
||||
frame[:] = b"\x00" * len(frame)
|
||||
+253
-1
@@ -15,10 +15,28 @@ from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from k1link import __version__
|
||||
from k1link.analyze import (
|
||||
DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
||||
MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
||||
summarize_mqtt_streams,
|
||||
)
|
||||
from k1link.artifacts import write_json_atomic
|
||||
from k1link.ble.gatt import dump_metadata
|
||||
from k1link.ble.scanner import scan
|
||||
from k1link.ble.wifi_provisioning import (
|
||||
PROFILE_ID,
|
||||
WriteMode,
|
||||
provision_wifi_once,
|
||||
)
|
||||
from k1link.macos_credentials import CredentialDialogError, prompt_wifi_credentials
|
||||
from k1link.mqtt import (
|
||||
DEFAULT_MAX_MESSAGE_BYTES,
|
||||
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||
CaptureError,
|
||||
capture_mqtt,
|
||||
)
|
||||
from k1link.net.snapshot import snapshot
|
||||
from k1link.usb.snapshot import snapshot as usb_snapshot
|
||||
|
||||
app = typer.Typer(
|
||||
name="k1link",
|
||||
@@ -27,9 +45,13 @@ app = typer.Typer(
|
||||
)
|
||||
console = Console()
|
||||
ble_app = typer.Typer(help="Bluetooth LE discovery and metadata commands.", no_args_is_help=True)
|
||||
net_app = typer.Typer(help="Passive local network observation commands.", no_args_is_help=True)
|
||||
net_app = typer.Typer(help="Read-only local network observation commands.", no_args_is_help=True)
|
||||
usb_app = typer.Typer(help="Read-only macOS USB metadata commands.", no_args_is_help=True)
|
||||
analyze_app = typer.Typer(help="Bounded offline evidence analysis commands.", no_args_is_help=True)
|
||||
app.add_typer(ble_app, name="ble")
|
||||
app.add_typer(net_app, name="net")
|
||||
app.add_typer(usb_app, name="usb")
|
||||
app.add_typer(analyze_app, name="analyze")
|
||||
|
||||
|
||||
class ToolStatus(TypedDict):
|
||||
@@ -245,6 +267,86 @@ def ble_gatt_dump(
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
@ble_app.command("wifi-configure")
|
||||
def ble_wifi_configure(
|
||||
device: Annotated[str, typer.Option(help="CoreBluetooth/macOS UUID from ble scan.")],
|
||||
out: Annotated[
|
||||
Path,
|
||||
typer.Option(help="Ignored sensitive session JSON path; parent directories are created."),
|
||||
],
|
||||
profile: Annotated[
|
||||
str,
|
||||
typer.Option(help="Exact reviewed provisioning profile ID."),
|
||||
],
|
||||
confirm_write: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--confirm-write",
|
||||
help="Confirm one state-changing BLE Wi-Fi provisioning write.",
|
||||
),
|
||||
] = False,
|
||||
write_mode: Annotated[
|
||||
WriteMode,
|
||||
typer.Option(help="ATT write mode; auto follows the live characteristic properties."),
|
||||
] = "auto",
|
||||
timeout: Annotated[
|
||||
float,
|
||||
typer.Option(min=10.0, max=120.0, help="Status polling timeout after the write."),
|
||||
] = 45.0,
|
||||
) -> None:
|
||||
"""Send router credentials once using the reviewed K1 firmware-3 profile."""
|
||||
if profile != PROFILE_ID:
|
||||
console.print(f"[red]Unknown or unreviewed profile:[/red] {profile}")
|
||||
raise typer.Exit(code=2)
|
||||
if not confirm_write:
|
||||
console.print(
|
||||
"[red]Write not confirmed.[/red] "
|
||||
"Add --confirm-write after reviewing the profile."
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
|
||||
console.print(
|
||||
"Two local macOS dialogs will request the Wi-Fi name and hidden password. "
|
||||
"The password is never printed, logged, or written to the result file; "
|
||||
"the K1 may echo the SSID in the ignored sensitive status result."
|
||||
)
|
||||
try:
|
||||
ssid, password = prompt_wifi_credentials()
|
||||
except CredentialDialogError as exc:
|
||||
console.print(f"[red]Credential entry failed:[/red] {exc}")
|
||||
raise typer.Exit(code=2) from exc
|
||||
|
||||
console.print("Credentials accepted locally. Starting the single reviewed BLE write.")
|
||||
try:
|
||||
result = asyncio.run(
|
||||
provision_wifi_once(
|
||||
device,
|
||||
ssid,
|
||||
password,
|
||||
timeout_seconds=timeout,
|
||||
write_mode=write_mode,
|
||||
)
|
||||
)
|
||||
except (BleakError, OSError, TimeoutError, ValueError) as exc:
|
||||
console.print(f"[red]Wi-Fi provisioning failed:[/red] {type(exc).__name__}: {exc}")
|
||||
console.print("No automatic retry was attempted.")
|
||||
raise typer.Exit(code=2) from exc
|
||||
finally:
|
||||
password = ""
|
||||
ssid = ""
|
||||
|
||||
write_json_atomic(out, result)
|
||||
observations = result["observations"]
|
||||
final_status = observations[-1]["status"] if observations else result["baseline_status"]
|
||||
console.print(f"Outcome: {result['outcome']}; ATT mode: {result['write_mode']}")
|
||||
console.print(
|
||||
f"Final status code: {final_status['status_code']}; "
|
||||
f"reported IPv4: {final_status['ipv4'] or '-'}"
|
||||
)
|
||||
console.print("Saved sensitive device/network metadata; do not commit the output.")
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
@net_app.command("snapshot")
|
||||
def net_snapshot(
|
||||
out: Annotated[
|
||||
@@ -259,5 +361,155 @@ def net_snapshot(
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
@net_app.command("mqtt-capture")
|
||||
def net_mqtt_capture(
|
||||
host: Annotated[
|
||||
str,
|
||||
typer.Option("--host", help="Confirmed K1 RFC1918 IPv4 address; hostnames are rejected."),
|
||||
],
|
||||
out: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--out",
|
||||
help="Sensitive output directory; use captures/... so Git ignores it.",
|
||||
),
|
||||
],
|
||||
confirm_owned_device: Annotated[
|
||||
bool,
|
||||
typer.Option(
|
||||
"--confirm-owned-device",
|
||||
help="Confirm the target is an owner-controlled K1 before connecting.",
|
||||
),
|
||||
] = False,
|
||||
port: Annotated[
|
||||
int,
|
||||
typer.Option(min=1, max=65535, help="MQTT broker TCP port."),
|
||||
] = 1883,
|
||||
duration: Annotated[
|
||||
float,
|
||||
typer.Option(min=1.0, max=3600.0, help="Capture duration after SUBACK, in seconds."),
|
||||
] = 60.0,
|
||||
max_message_bytes: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
min=1,
|
||||
max=MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||
help="Abort before storing a payload larger than this byte limit.",
|
||||
),
|
||||
] = DEFAULT_MAX_MESSAGE_BYTES,
|
||||
) -> None:
|
||||
"""Capture fixed K1 MQTT report topics once; never publish or reconnect."""
|
||||
if not confirm_owned_device:
|
||||
console.print(
|
||||
"[red]Target ownership not confirmed.[/red] "
|
||||
"Add --confirm-owned-device for the confirmed K1 IPv4 address."
|
||||
)
|
||||
raise typer.Exit(code=2)
|
||||
|
||||
console.print(
|
||||
"Starting one read-only MQTT subscription session. "
|
||||
"No application messages will be published and no reconnect will be attempted."
|
||||
)
|
||||
try:
|
||||
result = capture_mqtt(
|
||||
host,
|
||||
out,
|
||||
port=port,
|
||||
duration_seconds=duration,
|
||||
max_message_bytes=max_message_bytes,
|
||||
on_ready=lambda: console.print(
|
||||
"[green]MQTT subscriptions active; capture timer started.[/green]"
|
||||
),
|
||||
)
|
||||
except CaptureError as exc:
|
||||
console.print(f"[red]MQTT capture failed:[/red] {exc}")
|
||||
if exc.summary is not None:
|
||||
console.print(f"Partial artifacts preserved in: {out}")
|
||||
raise typer.Exit(code=2) from exc
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
console.print(f"[red]MQTT capture failed:[/red] {type(exc).__name__}: {exc}")
|
||||
raise typer.Exit(code=2) from exc
|
||||
|
||||
console.print(
|
||||
f"Capture stopped: {result['stop_reason']}; messages: {result['message_count']}; "
|
||||
f"payload bytes: {result['payload_bytes']}"
|
||||
)
|
||||
console.print("Saved sensitive raw MQTT evidence; do not commit the output.")
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
@usb_app.command("snapshot")
|
||||
def usb_snapshot_command(
|
||||
out: Annotated[
|
||||
Path,
|
||||
typer.Option(help="Ignored session JSON path; parent directories are created."),
|
||||
],
|
||||
) -> None:
|
||||
"""Save XGRIDS USB/interface/storage metadata without opening device files."""
|
||||
result = usb_snapshot()
|
||||
write_json_atomic(out, result)
|
||||
console.print(
|
||||
"Saved sensitive USB metadata only; no sudo, device-file reads or device writes used."
|
||||
)
|
||||
console.print(f"XGRIDS candidates: {result['xgrids_device_count']}")
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
@analyze_app.command("mqtt-streams")
|
||||
def analyze_mqtt_streams(
|
||||
capture: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--capture",
|
||||
exists=True,
|
||||
file_okay=True,
|
||||
dir_okay=False,
|
||||
readable=True,
|
||||
help="Repository-native mqtt.raw.k1mqtt capture to analyze offline.",
|
||||
),
|
||||
],
|
||||
out: Annotated[
|
||||
Path,
|
||||
typer.Option(
|
||||
"--out",
|
||||
help=(
|
||||
"Sensitive atomic JSON output; keep under captures/, sessions/, or "
|
||||
"artifacts/decoded/."
|
||||
),
|
||||
),
|
||||
],
|
||||
max_payload_bytes: Annotated[
|
||||
int,
|
||||
typer.Option(
|
||||
"--max-payload-bytes",
|
||||
min=1,
|
||||
max=MAX_STREAM_SUMMARY_PAYLOAD_BYTES,
|
||||
help="Reject a capture frame larger than this bounded payload limit.",
|
||||
),
|
||||
] = DEFAULT_STREAM_SUMMARY_MAX_PAYLOAD_BYTES,
|
||||
) -> None:
|
||||
"""Summarize captured firmware-3 point-cloud and pose streams without coordinates."""
|
||||
if capture.expanduser().resolve() == out.expanduser().resolve():
|
||||
console.print("[red]Analysis failed:[/red] capture and output must be different files")
|
||||
raise typer.Exit(code=2)
|
||||
|
||||
try:
|
||||
result = summarize_mqtt_streams(
|
||||
capture,
|
||||
max_payload_bytes=max_payload_bytes,
|
||||
)
|
||||
write_json_atomic(out, result)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
console.print(f"[red]Analysis failed:[/red] {type(exc).__name__}: {exc}")
|
||||
raise typer.Exit(code=2) from exc
|
||||
|
||||
console.print(
|
||||
f"Frames: {result['frames']['count']}; decode successes: "
|
||||
f"{result['decoding']['successes']}; errors: {result['decoding']['errors']}"
|
||||
)
|
||||
console.print("Saved sensitive aggregate-only output; keep it in ignored storage.")
|
||||
console.print(f"Saved: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import platform
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
|
||||
class CredentialDialogError(RuntimeError):
|
||||
"""Raised when the local macOS credential dialog cannot return a value."""
|
||||
|
||||
|
||||
def _dialog_text(prompt: str, hidden: bool) -> str:
|
||||
if platform.system() != "Darwin":
|
||||
raise CredentialDialogError("Secure credential dialogs are supported only on macOS")
|
||||
osascript = shutil.which("osascript")
|
||||
if osascript is None:
|
||||
raise CredentialDialogError("osascript is unavailable")
|
||||
|
||||
hidden_clause = " with hidden answer" if hidden else ""
|
||||
script = (
|
||||
f'text returned of (display dialog "{prompt}" default answer ""'
|
||||
f'{hidden_clause} buttons {{"Отмена", "Продолжить"}} '
|
||||
'default button "Продолжить" cancel button "Отмена")'
|
||||
)
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[osascript, "-e", script],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=300,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
raise CredentialDialogError("macOS credential dialog failed") from exc
|
||||
|
||||
if result.returncode != 0:
|
||||
raise CredentialDialogError("Credential entry was cancelled")
|
||||
value = result.stdout.rstrip("\r\n")
|
||||
if not value:
|
||||
raise CredentialDialogError("Credential value must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
def prompt_wifi_credentials() -> tuple[str, str]:
|
||||
"""Collect Wi-Fi credentials locally without placing them in command arguments."""
|
||||
ssid = _dialog_text("Имя Wi-Fi сети, к которой подключён Mac", hidden=False)
|
||||
password = _dialog_text("Пароль этой Wi-Fi сети", hidden=True)
|
||||
return ssid, password
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Read-only MQTT evidence capture for an owner-controlled K1."""
|
||||
|
||||
from k1link.mqtt.capture import (
|
||||
DEFAULT_MAX_MESSAGE_BYTES,
|
||||
MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||
REPORT_TOPICS,
|
||||
CaptureError,
|
||||
CaptureFormatError,
|
||||
CaptureFrame,
|
||||
CaptureSummary,
|
||||
capture_mqtt,
|
||||
iter_capture_frames,
|
||||
validate_private_ipv4,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DEFAULT_MAX_MESSAGE_BYTES",
|
||||
"MAX_CONFIGURABLE_MESSAGE_BYTES",
|
||||
"REPORT_TOPICS",
|
||||
"CaptureError",
|
||||
"CaptureFormatError",
|
||||
"CaptureFrame",
|
||||
"CaptureSummary",
|
||||
"capture_mqtt",
|
||||
"iter_capture_frames",
|
||||
"validate_private_ipv4",
|
||||
]
|
||||
@@ -0,0 +1,648 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import ipaddress
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import struct
|
||||
import time
|
||||
from collections.abc import Callable, Iterator
|
||||
from contextlib import suppress
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import IO, Literal, TypedDict
|
||||
|
||||
import paho.mqtt.client as mqtt
|
||||
from paho.mqtt.enums import CallbackAPIVersion
|
||||
from paho.mqtt.properties import Properties
|
||||
from paho.mqtt.reasoncodes import ReasonCode
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
REPORT_TOPICS: tuple[str, ...] = (
|
||||
"lixel/application/report/#",
|
||||
"RealtimePointcloud",
|
||||
"RealtimePath",
|
||||
"DeviceStatus",
|
||||
)
|
||||
|
||||
DEFAULT_MAX_MESSAGE_BYTES = 64 * 1024 * 1024
|
||||
MAX_CONFIGURABLE_MESSAGE_BYTES = 256 * 1024 * 1024
|
||||
MAX_TOPIC_BYTES = 65_535
|
||||
CONNECT_TIMEOUT_SECONDS = 10.0
|
||||
KEEPALIVE_SECONDS = 30
|
||||
LOOP_INTERVAL_SECONDS = 0.25
|
||||
|
||||
# Eight-byte file signature followed by repeated >IQ, topic UTF-8 bytes, payload bytes.
|
||||
RAW_MAGIC = b"K1MQTT\x00\x01"
|
||||
FRAME_HEADER = struct.Struct(">IQ")
|
||||
|
||||
_PRIVATE_NETWORKS = tuple(
|
||||
ipaddress.ip_network(cidr) for cidr in ("10.0.0.0/8", "172.16.0.0/12", "192.168.0.0/16")
|
||||
)
|
||||
|
||||
StopReason = Literal[
|
||||
"duration_elapsed",
|
||||
"keyboard_interrupt",
|
||||
"message_too_large",
|
||||
"connection_failed",
|
||||
"connection_lost",
|
||||
"subscription_failed",
|
||||
"capture_error",
|
||||
]
|
||||
|
||||
|
||||
class ArtifactPaths(TypedDict):
|
||||
raw: str
|
||||
metadata_jsonl: str
|
||||
summary: str
|
||||
|
||||
|
||||
class ArtifactHashes(TypedDict):
|
||||
raw_sha256: str
|
||||
metadata_jsonl_sha256: str
|
||||
|
||||
|
||||
class RawFormat(TypedDict):
|
||||
magic_hex: str
|
||||
frame_header_struct: str
|
||||
frame_layout: str
|
||||
|
||||
|
||||
class CaptureSummary(TypedDict):
|
||||
schema_version: int
|
||||
created_at_utc: str
|
||||
completed_at_utc: str
|
||||
sensitivity: str
|
||||
target_ipv4: str
|
||||
target_port: int
|
||||
mqtt_protocol: str
|
||||
subscription_qos: int
|
||||
clean_session: bool
|
||||
reconnect_enabled: bool
|
||||
publishing_enabled: bool
|
||||
subscriptions: list[str]
|
||||
requested_duration_seconds: float
|
||||
capture_elapsed_seconds: float
|
||||
operation_elapsed_seconds: float
|
||||
max_message_bytes: int
|
||||
connected: bool
|
||||
subscribed: bool
|
||||
stop_reason: StopReason
|
||||
error: str | None
|
||||
message_count: int
|
||||
rejected_message_count: int
|
||||
payload_bytes: int
|
||||
raw_bytes: int
|
||||
topic_counts: dict[str, int]
|
||||
raw_format: RawFormat
|
||||
artifacts: ArtifactPaths
|
||||
artifact_hashes: ArtifactHashes
|
||||
|
||||
|
||||
class CaptureError(RuntimeError):
|
||||
"""A one-shot capture failed after preserving all artifacts written so far."""
|
||||
|
||||
def __init__(self, message: str, summary: CaptureSummary | None = None) -> None:
|
||||
super().__init__(message)
|
||||
self.summary = summary
|
||||
|
||||
|
||||
class MessageTooLargeError(CaptureError):
|
||||
"""An MQTT message exceeded the configured evidence boundary."""
|
||||
|
||||
|
||||
class CaptureFormatError(ValueError):
|
||||
"""A raw capture is corrupt, truncated or outside configured reader bounds."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class CaptureFrame:
|
||||
sequence: int
|
||||
topic: str
|
||||
payload: bytes
|
||||
raw_frame_offset: int
|
||||
raw_payload_offset: int
|
||||
raw_frame_bytes: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class _CaptureState:
|
||||
connected: bool = False
|
||||
subscribed: bool = False
|
||||
stopping: bool = False
|
||||
subscription_mid: int | None = None
|
||||
stop_reason: StopReason = "capture_error"
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class _CaptureWriter:
|
||||
def __init__(self, out_dir: Path, max_message_bytes: int) -> None:
|
||||
self.out_dir = out_dir.expanduser().resolve()
|
||||
self.raw_path = self.out_dir / "mqtt.raw.k1mqtt"
|
||||
self.metadata_path = self.out_dir / "mqtt.metadata.jsonl"
|
||||
self.summary_path = self.out_dir / "mqtt.summary.json"
|
||||
self.max_message_bytes = max_message_bytes
|
||||
self.message_count = 0
|
||||
self.rejected_message_count = 0
|
||||
self.payload_bytes = 0
|
||||
self.topic_counts: dict[str, int] = {}
|
||||
self._raw: IO[bytes] | None = None
|
||||
self._metadata: IO[str] | None = None
|
||||
|
||||
def open(self) -> None:
|
||||
self.out_dir.mkdir(parents=True, exist_ok=True)
|
||||
artifact_paths = (self.raw_path, self.metadata_path, self.summary_path)
|
||||
existing = [path.name for path in artifact_paths if path.exists()]
|
||||
if existing:
|
||||
names = ", ".join(existing)
|
||||
raise FileExistsError(f"refusing to overwrite existing capture artifact(s): {names}")
|
||||
|
||||
try:
|
||||
self._raw = _open_binary_exclusive(self.raw_path)
|
||||
self._raw.write(RAW_MAGIC)
|
||||
self._metadata = _open_text_exclusive(self.metadata_path)
|
||||
except BaseException:
|
||||
with suppress(OSError):
|
||||
self.close()
|
||||
raise
|
||||
|
||||
def record(self, message: mqtt.MQTTMessage) -> None:
|
||||
raw = self._require_raw()
|
||||
metadata = self._require_metadata()
|
||||
topic = message.topic
|
||||
topic_bytes = topic.encode("utf-8")
|
||||
payload = message.payload
|
||||
received_at_utc = utc_now_iso()
|
||||
received_monotonic_ns = time.monotonic_ns()
|
||||
|
||||
if not 1 <= len(topic_bytes) <= MAX_TOPIC_BYTES:
|
||||
raise ValueError(
|
||||
f"incoming MQTT topic is {len(topic_bytes)} bytes; "
|
||||
f"expected 1..{MAX_TOPIC_BYTES}"
|
||||
)
|
||||
|
||||
if len(payload) > self.max_message_bytes:
|
||||
self.rejected_message_count += 1
|
||||
record = {
|
||||
"schema_version": 1,
|
||||
"record_type": "rejected_message",
|
||||
"sequence": self.message_count + self.rejected_message_count,
|
||||
"received_at_utc": received_at_utc,
|
||||
"received_monotonic_ns": received_monotonic_ns,
|
||||
"topic": topic,
|
||||
"payload_bytes": len(payload),
|
||||
"max_message_bytes": self.max_message_bytes,
|
||||
"reason": "message_too_large",
|
||||
}
|
||||
self._write_metadata(metadata, record)
|
||||
raise MessageTooLargeError(
|
||||
f"message on {topic!r} is {len(payload)} bytes; "
|
||||
f"limit is {self.max_message_bytes} bytes"
|
||||
)
|
||||
|
||||
offset = raw.tell()
|
||||
header = FRAME_HEADER.pack(len(topic_bytes), len(payload))
|
||||
raw.write(header)
|
||||
raw.write(topic_bytes)
|
||||
raw.write(payload)
|
||||
raw.flush()
|
||||
|
||||
self.message_count += 1
|
||||
self.payload_bytes += len(payload)
|
||||
self.topic_counts[topic] = self.topic_counts.get(topic, 0) + 1
|
||||
frame_bytes = len(header) + len(topic_bytes) + len(payload)
|
||||
record = {
|
||||
"schema_version": 1,
|
||||
"record_type": "message",
|
||||
"sequence": self.message_count,
|
||||
"received_at_utc": received_at_utc,
|
||||
"received_monotonic_ns": received_monotonic_ns,
|
||||
"topic": topic,
|
||||
"qos": message.qos,
|
||||
"retain": message.retain,
|
||||
"dup": message.dup,
|
||||
"payload_bytes": len(payload),
|
||||
"payload_sha256": hashlib.sha256(payload).hexdigest(),
|
||||
"raw_frame_offset": offset,
|
||||
"raw_payload_offset": offset + len(header) + len(topic_bytes),
|
||||
"raw_frame_bytes": frame_bytes,
|
||||
}
|
||||
self._write_metadata(metadata, record)
|
||||
|
||||
def close(self) -> None:
|
||||
first_error: OSError | None = None
|
||||
# Make raw frames durable before making their JSONL references durable.
|
||||
for stream in (self._raw, self._metadata):
|
||||
if stream is None or stream.closed:
|
||||
continue
|
||||
try:
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
except OSError as exc:
|
||||
if first_error is None:
|
||||
first_error = exc
|
||||
finally:
|
||||
try:
|
||||
stream.close()
|
||||
except OSError as exc:
|
||||
if first_error is None:
|
||||
first_error = exc
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
@property
|
||||
def raw_bytes(self) -> int:
|
||||
if self.raw_path.exists():
|
||||
return self.raw_path.stat().st_size
|
||||
return 0
|
||||
|
||||
def _require_raw(self) -> IO[bytes]:
|
||||
if self._raw is None or self._raw.closed:
|
||||
raise RuntimeError("capture writer is not open")
|
||||
return self._raw
|
||||
|
||||
def _require_metadata(self) -> IO[str]:
|
||||
if self._metadata is None or self._metadata.closed:
|
||||
raise RuntimeError("capture writer is not open")
|
||||
return self._metadata
|
||||
|
||||
@staticmethod
|
||||
def _write_metadata(stream: IO[str], record: dict[str, object]) -> None:
|
||||
stream.write(json.dumps(record, ensure_ascii=False, separators=(",", ":")) + "\n")
|
||||
stream.flush()
|
||||
|
||||
|
||||
def validate_private_ipv4(value: str) -> str:
|
||||
"""Require a literal RFC1918 address so capture cannot target arbitrary hosts."""
|
||||
try:
|
||||
address = ipaddress.ip_address(value)
|
||||
except ValueError as exc:
|
||||
raise ValueError("host must be a literal private IPv4 address") from exc
|
||||
if not isinstance(address, ipaddress.IPv4Address) or not any(
|
||||
address in network for network in _PRIVATE_NETWORKS
|
||||
):
|
||||
raise ValueError("host must be an RFC1918 private IPv4 address")
|
||||
return str(address)
|
||||
|
||||
|
||||
def iter_capture_frames(
|
||||
path: Path,
|
||||
*,
|
||||
max_payload_bytes: int = MAX_CONFIGURABLE_MESSAGE_BYTES,
|
||||
max_topic_bytes: int = MAX_TOPIC_BYTES,
|
||||
) -> Iterator[CaptureFrame]:
|
||||
"""Yield validated frames from a K1 MQTT raw capture without decoding payloads."""
|
||||
if not 1 <= max_payload_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
|
||||
raise ValueError(
|
||||
"max_payload_bytes must be between 1 and "
|
||||
f"{MAX_CONFIGURABLE_MESSAGE_BYTES}"
|
||||
)
|
||||
if not 1 <= max_topic_bytes <= MAX_TOPIC_BYTES:
|
||||
raise ValueError(f"max_topic_bytes must be between 1 and {MAX_TOPIC_BYTES}")
|
||||
|
||||
with path.expanduser().open("rb") as stream:
|
||||
magic = stream.read(len(RAW_MAGIC))
|
||||
if magic != RAW_MAGIC:
|
||||
if len(magic) < len(RAW_MAGIC):
|
||||
raise CaptureFormatError("raw capture is truncated before the complete magic")
|
||||
raise CaptureFormatError("raw capture magic/version is not supported")
|
||||
|
||||
sequence = 0
|
||||
while True:
|
||||
frame_offset = stream.tell()
|
||||
header = stream.read(FRAME_HEADER.size)
|
||||
if not header:
|
||||
return
|
||||
if len(header) != FRAME_HEADER.size:
|
||||
raise CaptureFormatError(
|
||||
f"frame at offset {frame_offset} has a truncated length header"
|
||||
)
|
||||
topic_length, payload_length = FRAME_HEADER.unpack(header)
|
||||
if not 1 <= topic_length <= max_topic_bytes:
|
||||
raise CaptureFormatError(
|
||||
f"frame at offset {frame_offset} topic length {topic_length} "
|
||||
f"is outside 1..{max_topic_bytes}"
|
||||
)
|
||||
if payload_length > max_payload_bytes:
|
||||
raise CaptureFormatError(
|
||||
f"frame at offset {frame_offset} payload length {payload_length} "
|
||||
f"exceeds {max_payload_bytes}"
|
||||
)
|
||||
|
||||
topic_raw = _read_exact(
|
||||
stream,
|
||||
topic_length,
|
||||
description=f"topic at frame offset {frame_offset}",
|
||||
)
|
||||
try:
|
||||
topic = topic_raw.decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise CaptureFormatError(
|
||||
f"frame at offset {frame_offset} topic is not valid UTF-8"
|
||||
) from exc
|
||||
payload_offset = stream.tell()
|
||||
payload = _read_exact(
|
||||
stream,
|
||||
payload_length,
|
||||
description=f"payload at frame offset {frame_offset}",
|
||||
)
|
||||
sequence += 1
|
||||
yield CaptureFrame(
|
||||
sequence=sequence,
|
||||
topic=topic,
|
||||
payload=payload,
|
||||
raw_frame_offset=frame_offset,
|
||||
raw_payload_offset=payload_offset,
|
||||
raw_frame_bytes=FRAME_HEADER.size + topic_length + payload_length,
|
||||
)
|
||||
|
||||
|
||||
def capture_mqtt(
|
||||
host: str,
|
||||
out_dir: Path,
|
||||
*,
|
||||
port: int = 1883,
|
||||
duration_seconds: float = 60.0,
|
||||
max_message_bytes: int = DEFAULT_MAX_MESSAGE_BYTES,
|
||||
on_ready: Callable[[], None] | None = None,
|
||||
_client_factory: Callable[[], mqtt.Client] | None = None,
|
||||
) -> CaptureSummary:
|
||||
"""Capture the fixed K1 report subscriptions once, without publishing or reconnecting."""
|
||||
target_ipv4 = validate_private_ipv4(host)
|
||||
if not 1 <= port <= 65535:
|
||||
raise ValueError("port must be between 1 and 65535")
|
||||
if not math.isfinite(duration_seconds) or duration_seconds <= 0:
|
||||
raise ValueError("duration_seconds must be finite and greater than zero")
|
||||
if not 1 <= max_message_bytes <= MAX_CONFIGURABLE_MESSAGE_BYTES:
|
||||
raise ValueError(
|
||||
"max_message_bytes must be between 1 and "
|
||||
f"{MAX_CONFIGURABLE_MESSAGE_BYTES}"
|
||||
)
|
||||
|
||||
client = (
|
||||
_client_factory()
|
||||
if _client_factory is not None
|
||||
else mqtt.Client(
|
||||
callback_api_version=CallbackAPIVersion.VERSION2,
|
||||
clean_session=True,
|
||||
protocol=mqtt.MQTTv311,
|
||||
reconnect_on_failure=False,
|
||||
)
|
||||
)
|
||||
writer = _CaptureWriter(out_dir, max_message_bytes)
|
||||
writer.open()
|
||||
state = _CaptureState()
|
||||
created_at_utc = utc_now_iso()
|
||||
operation_started = time.monotonic()
|
||||
capture_started: float | None = None
|
||||
failure: CaptureError | None = None
|
||||
|
||||
def fail(reason: StopReason, message: str) -> None:
|
||||
if state.error is None:
|
||||
state.stop_reason = reason
|
||||
state.error = message
|
||||
|
||||
def on_connect(
|
||||
callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
_flags: mqtt.ConnectFlags,
|
||||
reason_code: ReasonCode,
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
if reason_code.is_failure:
|
||||
fail("connection_failed", f"broker rejected connection: {reason_code}")
|
||||
return
|
||||
state.connected = True
|
||||
try:
|
||||
result, mid = callback_client.subscribe([(topic, 0) for topic in REPORT_TOPICS])
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
fail(
|
||||
"subscription_failed",
|
||||
f"subscribe failed: {type(exc).__name__}: {exc}",
|
||||
)
|
||||
return
|
||||
if result != mqtt.MQTT_ERR_SUCCESS or mid is None:
|
||||
fail("subscription_failed", f"subscribe failed: {mqtt.error_string(result)}")
|
||||
return
|
||||
state.subscription_mid = mid
|
||||
|
||||
def on_subscribe(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
mid: int,
|
||||
reason_codes: list[ReasonCode],
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
if mid != state.subscription_mid:
|
||||
fail("subscription_failed", f"unexpected SUBACK message id: {mid}")
|
||||
return
|
||||
if len(reason_codes) != len(REPORT_TOPICS) or any(
|
||||
reason_code.is_failure for reason_code in reason_codes
|
||||
):
|
||||
fail("subscription_failed", "broker rejected one or more fixed subscriptions")
|
||||
return
|
||||
state.subscribed = True
|
||||
|
||||
def on_message(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
message: mqtt.MQTTMessage,
|
||||
) -> None:
|
||||
if state.error is not None:
|
||||
return
|
||||
try:
|
||||
writer.record(message)
|
||||
except MessageTooLargeError as exc:
|
||||
fail("message_too_large", str(exc))
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
fail("capture_error", f"artifact write failed: {type(exc).__name__}: {exc}")
|
||||
|
||||
def on_disconnect(
|
||||
_callback_client: mqtt.Client,
|
||||
_userdata: object,
|
||||
_flags: mqtt.DisconnectFlags,
|
||||
reason_code: ReasonCode,
|
||||
_properties: Properties | None,
|
||||
) -> None:
|
||||
if not state.stopping:
|
||||
fail("connection_lost", f"broker connection ended: {reason_code}")
|
||||
|
||||
client.on_connect = on_connect
|
||||
client.on_subscribe = on_subscribe
|
||||
client.on_message = on_message
|
||||
client.on_disconnect = on_disconnect
|
||||
|
||||
connect_attempted = False
|
||||
try:
|
||||
connect_attempted = True
|
||||
connect_result = client.connect(
|
||||
target_ipv4,
|
||||
port=port,
|
||||
keepalive=KEEPALIVE_SECONDS,
|
||||
)
|
||||
if connect_result != mqtt.MQTT_ERR_SUCCESS:
|
||||
fail("connection_failed", f"connect failed: {mqtt.error_string(connect_result)}")
|
||||
|
||||
while state.error is None:
|
||||
now = time.monotonic()
|
||||
if state.subscribed and capture_started is None:
|
||||
capture_started = now
|
||||
if on_ready is not None:
|
||||
on_ready()
|
||||
if capture_started is not None and now - capture_started >= duration_seconds:
|
||||
state.stop_reason = "duration_elapsed"
|
||||
break
|
||||
if capture_started is None and now - operation_started >= CONNECT_TIMEOUT_SECONDS:
|
||||
fail("connection_failed", "timed out waiting for CONNACK/SUBACK")
|
||||
break
|
||||
|
||||
loop_result = client.loop(timeout=LOOP_INTERVAL_SECONDS)
|
||||
if loop_result != mqtt.MQTT_ERR_SUCCESS and state.error is None:
|
||||
fail(
|
||||
"connection_lost",
|
||||
f"MQTT network loop failed: {mqtt.error_string(loop_result)}",
|
||||
)
|
||||
except KeyboardInterrupt:
|
||||
state.stop_reason = "keyboard_interrupt"
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
fail("connection_failed", f"MQTT capture failed: {type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
state.stopping = True
|
||||
if connect_attempted:
|
||||
try:
|
||||
client.disconnect()
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
if state.error is None:
|
||||
fail("capture_error", f"disconnect failed: {type(exc).__name__}: {exc}")
|
||||
try:
|
||||
writer.close()
|
||||
except OSError as exc:
|
||||
if state.error is None:
|
||||
fail("capture_error", f"artifact close failed: {type(exc).__name__}: {exc}")
|
||||
|
||||
operation_completed = time.monotonic()
|
||||
capture_elapsed = (
|
||||
0.0 if capture_started is None else operation_completed - capture_started
|
||||
)
|
||||
|
||||
summary = _build_summary(
|
||||
writer=writer,
|
||||
target_ipv4=target_ipv4,
|
||||
port=port,
|
||||
duration_seconds=duration_seconds,
|
||||
capture_elapsed=capture_elapsed,
|
||||
operation_elapsed=operation_completed - operation_started,
|
||||
max_message_bytes=max_message_bytes,
|
||||
created_at_utc=created_at_utc,
|
||||
state=state,
|
||||
)
|
||||
try:
|
||||
_write_summary_exclusive(writer.summary_path, summary)
|
||||
except OSError as exc:
|
||||
raise CaptureError(f"could not write capture summary: {type(exc).__name__}: {exc}") from exc
|
||||
|
||||
if state.error is not None:
|
||||
failure = CaptureError(state.error, summary)
|
||||
if failure is not None:
|
||||
raise failure
|
||||
return summary
|
||||
|
||||
|
||||
def _build_summary(
|
||||
*,
|
||||
writer: _CaptureWriter,
|
||||
target_ipv4: str,
|
||||
port: int,
|
||||
duration_seconds: float,
|
||||
capture_elapsed: float,
|
||||
operation_elapsed: float,
|
||||
max_message_bytes: int,
|
||||
created_at_utc: str,
|
||||
state: _CaptureState,
|
||||
) -> CaptureSummary:
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"created_at_utc": created_at_utc,
|
||||
"completed_at_utc": utc_now_iso(),
|
||||
"sensitivity": "contains raw K1 MQTT payloads and local addressing; do not commit",
|
||||
"target_ipv4": target_ipv4,
|
||||
"target_port": port,
|
||||
"mqtt_protocol": "3.1.1",
|
||||
"subscription_qos": 0,
|
||||
"clean_session": True,
|
||||
"reconnect_enabled": False,
|
||||
"publishing_enabled": False,
|
||||
"subscriptions": list(REPORT_TOPICS),
|
||||
"requested_duration_seconds": duration_seconds,
|
||||
"capture_elapsed_seconds": round(capture_elapsed, 6),
|
||||
"operation_elapsed_seconds": round(operation_elapsed, 6),
|
||||
"max_message_bytes": max_message_bytes,
|
||||
"connected": state.connected,
|
||||
"subscribed": state.subscribed,
|
||||
"stop_reason": state.stop_reason,
|
||||
"error": state.error,
|
||||
"message_count": writer.message_count,
|
||||
"rejected_message_count": writer.rejected_message_count,
|
||||
"payload_bytes": writer.payload_bytes,
|
||||
"raw_bytes": writer.raw_bytes,
|
||||
"topic_counts": dict(sorted(writer.topic_counts.items())),
|
||||
"raw_format": {
|
||||
"magic_hex": RAW_MAGIC.hex(),
|
||||
"frame_header_struct": FRAME_HEADER.format,
|
||||
"frame_layout": "topic_length:uint32, payload_length:uint64, topic_utf8, payload",
|
||||
},
|
||||
"artifacts": {
|
||||
"raw": writer.raw_path.name,
|
||||
"metadata_jsonl": writer.metadata_path.name,
|
||||
"summary": writer.summary_path.name,
|
||||
},
|
||||
"artifact_hashes": {
|
||||
"raw_sha256": _sha256_file(writer.raw_path),
|
||||
"metadata_jsonl_sha256": _sha256_file(writer.metadata_path),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _sha256_file(path: Path) -> str:
|
||||
digest = hashlib.sha256()
|
||||
with path.open("rb") as stream:
|
||||
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def _read_exact(stream: IO[bytes], length: int, *, description: str) -> bytes:
|
||||
value = stream.read(length)
|
||||
if len(value) != length:
|
||||
raise CaptureFormatError(
|
||||
f"{description} is truncated: expected {length} bytes, got {len(value)}"
|
||||
)
|
||||
return value
|
||||
|
||||
|
||||
def _open_binary_exclusive(path: Path) -> IO[bytes]:
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
return os.fdopen(descriptor, "wb")
|
||||
except BaseException:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
|
||||
def _open_text_exclusive(path: Path) -> IO[str]:
|
||||
descriptor = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
return os.fdopen(descriptor, "w", encoding="utf-8", newline="\n")
|
||||
except BaseException:
|
||||
os.close(descriptor)
|
||||
raise
|
||||
|
||||
|
||||
def _write_summary_exclusive(path: Path, summary: CaptureSummary) -> None:
|
||||
serialized = json.dumps(summary, ensure_ascii=False, indent=2) + "\n"
|
||||
with _open_text_exclusive(path) as stream:
|
||||
stream.write(serialized)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
@@ -0,0 +1,35 @@
|
||||
"""Verified protocol decoders for captured K1 application streams."""
|
||||
|
||||
from k1link.protocol.streams import (
|
||||
DecodeLimits,
|
||||
LegacyPoint,
|
||||
LegacyPointCloudFrame,
|
||||
LegacyPoseFrame,
|
||||
LioPoint,
|
||||
LioPointCloudFrame,
|
||||
LioPoseFrame,
|
||||
StreamDecodeError,
|
||||
UnsupportedCompressionError,
|
||||
decode_legacy_pointcloud,
|
||||
decode_legacy_pose,
|
||||
decode_lio_pcl,
|
||||
decode_lio_pose,
|
||||
decode_pre_path_array,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DecodeLimits",
|
||||
"LegacyPoint",
|
||||
"LegacyPointCloudFrame",
|
||||
"LegacyPoseFrame",
|
||||
"LioPoint",
|
||||
"LioPointCloudFrame",
|
||||
"LioPoseFrame",
|
||||
"StreamDecodeError",
|
||||
"UnsupportedCompressionError",
|
||||
"decode_legacy_pointcloud",
|
||||
"decode_legacy_pose",
|
||||
"decode_lio_pcl",
|
||||
"decode_lio_pose",
|
||||
"decode_pre_path_array",
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Iterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
class ProtobufWireError(ValueError):
|
||||
"""Raised when a bounded protobuf wire parse fails."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class ProtoField:
|
||||
number: int
|
||||
wire_type: int
|
||||
value: int | bytes
|
||||
|
||||
|
||||
def read_varint(data: bytes, offset: int) -> tuple[int, int]:
|
||||
"""Read one protobuf unsigned varint, bounded to 64 bits."""
|
||||
value = 0
|
||||
for shift in range(0, 70, 7):
|
||||
if offset >= len(data):
|
||||
raise ProtobufWireError("truncated varint")
|
||||
octet = data[offset]
|
||||
offset += 1
|
||||
if shift == 63 and octet > 1:
|
||||
raise ProtobufWireError("varint exceeds 64 bits")
|
||||
value |= (octet & 0x7F) << shift
|
||||
if not octet & 0x80:
|
||||
return value, offset
|
||||
raise ProtobufWireError("varint exceeds 10 bytes")
|
||||
|
||||
|
||||
def decode_zigzag64(value: int) -> int:
|
||||
"""Decode protobuf sint64 ZigZag representation."""
|
||||
if value < 0 or value > 0xFFFFFFFFFFFFFFFF:
|
||||
raise ProtobufWireError("ZigZag input is outside uint64")
|
||||
return (value >> 1) ^ -(value & 1)
|
||||
|
||||
|
||||
def iter_fields(data: bytes, *, max_fields: int = 1_000_000) -> Iterator[ProtoField]:
|
||||
"""Iterate supported protobuf fields without recursion or unbounded allocation."""
|
||||
if max_fields < 1:
|
||||
raise ValueError("max_fields must be positive")
|
||||
|
||||
offset = 0
|
||||
field_count = 0
|
||||
while offset < len(data):
|
||||
field_count += 1
|
||||
if field_count > max_fields:
|
||||
raise ProtobufWireError(f"message exceeds {max_fields} fields")
|
||||
|
||||
key, offset = read_varint(data, offset)
|
||||
number = key >> 3
|
||||
wire_type = key & 0x07
|
||||
if number == 0:
|
||||
raise ProtobufWireError("protobuf field number zero is invalid")
|
||||
|
||||
if wire_type == 0:
|
||||
value, offset = read_varint(data, offset)
|
||||
yield ProtoField(number, wire_type, value)
|
||||
continue
|
||||
|
||||
if wire_type == 1:
|
||||
end = offset + 8
|
||||
if end > len(data):
|
||||
raise ProtobufWireError("truncated fixed64 field")
|
||||
yield ProtoField(number, wire_type, data[offset:end])
|
||||
offset = end
|
||||
continue
|
||||
|
||||
if wire_type == 2:
|
||||
length, offset = read_varint(data, offset)
|
||||
end = offset + length
|
||||
if end > len(data):
|
||||
raise ProtobufWireError("truncated length-delimited field")
|
||||
yield ProtoField(number, wire_type, data[offset:end])
|
||||
offset = end
|
||||
continue
|
||||
|
||||
if wire_type == 5:
|
||||
end = offset + 4
|
||||
if end > len(data):
|
||||
raise ProtobufWireError("truncated fixed32 field")
|
||||
yield ProtoField(number, wire_type, data[offset:end])
|
||||
offset = end
|
||||
continue
|
||||
|
||||
raise ProtobufWireError(f"unsupported protobuf wire type {wire_type}")
|
||||
@@ -0,0 +1,410 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
from typing import NamedTuple
|
||||
|
||||
import lz4.block
|
||||
|
||||
from k1link.protocol.protobuf_wire import (
|
||||
ProtobufWireError,
|
||||
ProtoField,
|
||||
decode_zigzag64,
|
||||
iter_fields,
|
||||
)
|
||||
|
||||
|
||||
class StreamDecodeError(ValueError):
|
||||
"""Raised when a K1 stream payload violates its verified bounds or schema."""
|
||||
|
||||
|
||||
class UnsupportedCompressionError(StreamDecodeError):
|
||||
"""Raised for a protocol compression type that has not been verified."""
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class DecodeLimits:
|
||||
max_mqtt_payload_bytes: int = 2 * 1024 * 1024
|
||||
max_compressed_bytes: int = 1024 * 1024
|
||||
max_decompressed_bytes: int = 8 * 1024 * 1024
|
||||
max_compression_ratio: int = 64
|
||||
max_points_per_frame: int = 250_000
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
values = (
|
||||
self.max_mqtt_payload_bytes,
|
||||
self.max_compressed_bytes,
|
||||
self.max_decompressed_bytes,
|
||||
self.max_compression_ratio,
|
||||
self.max_points_per_frame,
|
||||
)
|
||||
if any(value < 1 for value in values):
|
||||
raise ValueError("all decode limits must be positive")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class MqttHeader:
|
||||
seq: int
|
||||
stamp: int
|
||||
scaler: int
|
||||
device_id: str
|
||||
session_id: str
|
||||
openapi_key: str | None
|
||||
|
||||
|
||||
class LioPoint(NamedTuple):
|
||||
x_raw: int
|
||||
y_raw: int
|
||||
z_raw: int
|
||||
rgbi: int
|
||||
|
||||
@property
|
||||
def intensity(self) -> int:
|
||||
"""Return the only RGBA interpretation verified in the application."""
|
||||
return self.rgbi & 0xFF
|
||||
|
||||
def scaled_xyz(self, scaler: int) -> tuple[float, float, float]:
|
||||
if scaler == 0:
|
||||
raise StreamDecodeError("point scaler is zero")
|
||||
return self.x_raw / scaler, self.y_raw / scaler, self.z_raw / scaler
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LioPointCloudFrame:
|
||||
header: MqttHeader
|
||||
compression: int
|
||||
compressed_bytes: int
|
||||
decompressed_bytes: int
|
||||
points: tuple[LioPoint, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LioPoseFrame:
|
||||
header: MqttHeader
|
||||
pose_stamp: int
|
||||
position_xyz: tuple[float, float, float]
|
||||
orientation_xyzw: tuple[float, float, float, float]
|
||||
distance: float
|
||||
pose_accuracy: float
|
||||
|
||||
|
||||
class LegacyPoint(NamedTuple):
|
||||
x: float
|
||||
y: float
|
||||
z: float
|
||||
r: int
|
||||
g: int
|
||||
b: int
|
||||
intensity: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LegacyPointCloudFrame:
|
||||
envelope: bytes
|
||||
stride: int
|
||||
points: tuple[LegacyPoint, ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LegacyPoseFrame:
|
||||
position_xyz: tuple[float, float, float]
|
||||
orientation_xyzw: tuple[float, float, float, float]
|
||||
skipped_offset_12: bytes
|
||||
unknown_tail: bytes
|
||||
|
||||
|
||||
def _int_value(field: ProtoField, name: str) -> int:
|
||||
if field.wire_type != 0 or not isinstance(field.value, int):
|
||||
raise StreamDecodeError(f"{name} has the wrong protobuf wire type")
|
||||
return field.value
|
||||
|
||||
|
||||
def _bytes_value(field: ProtoField, name: str, wire_type: int = 2) -> bytes:
|
||||
if field.wire_type != wire_type or not isinstance(field.value, bytes):
|
||||
raise StreamDecodeError(f"{name} has the wrong protobuf wire type")
|
||||
return field.value
|
||||
|
||||
|
||||
def _float32(field: ProtoField, name: str) -> float:
|
||||
value = float(struct.unpack("<f", _bytes_value(field, name, 5))[0])
|
||||
if not math.isfinite(value):
|
||||
raise StreamDecodeError(f"{name} is not finite")
|
||||
return value
|
||||
|
||||
|
||||
def _float64(field: ProtoField, name: str) -> float:
|
||||
value = float(struct.unpack("<d", _bytes_value(field, name, 1))[0])
|
||||
if not math.isfinite(value):
|
||||
raise StreamDecodeError(f"{name} is not finite")
|
||||
return value
|
||||
|
||||
|
||||
def _text(field: ProtoField, name: str) -> str:
|
||||
try:
|
||||
return _bytes_value(field, name).decode("utf-8")
|
||||
except UnicodeDecodeError as exc:
|
||||
raise StreamDecodeError(f"{name} is not valid UTF-8") from exc
|
||||
|
||||
|
||||
def _decode_header(payload: bytes) -> MqttHeader:
|
||||
seq = 0
|
||||
stamp = 0
|
||||
scaler = 0
|
||||
device_id = ""
|
||||
session_id = ""
|
||||
openapi_key: str | None = None
|
||||
for field in iter_fields(payload, max_fields=64):
|
||||
if field.number == 1:
|
||||
seq = _int_value(field, "header.seq")
|
||||
elif field.number == 2:
|
||||
stamp = decode_zigzag64(_int_value(field, "header.stamp"))
|
||||
elif field.number == 3:
|
||||
scaler = decode_zigzag64(_int_value(field, "header.scaler"))
|
||||
elif field.number == 4:
|
||||
device_id = _text(field, "header.device_id")
|
||||
elif field.number == 5:
|
||||
session_id = _text(field, "header.session_id")
|
||||
elif field.number == 6:
|
||||
openapi_key = _text(field, "header.openapi_key")
|
||||
return MqttHeader(seq, stamp, scaler, device_id, session_id, openapi_key)
|
||||
|
||||
|
||||
def _decode_lio_point(payload: bytes) -> LioPoint:
|
||||
x_raw = 0
|
||||
y_raw = 0
|
||||
z_raw = 0
|
||||
rgbi = 0
|
||||
for field in iter_fields(payload, max_fields=16):
|
||||
if field.number == 1:
|
||||
x_raw = decode_zigzag64(_int_value(field, "point.x"))
|
||||
elif field.number == 2:
|
||||
y_raw = decode_zigzag64(_int_value(field, "point.y"))
|
||||
elif field.number == 3:
|
||||
z_raw = decode_zigzag64(_int_value(field, "point.z"))
|
||||
elif field.number == 4:
|
||||
rgbi = _int_value(field, "point.rgbi") & 0xFFFFFFFF
|
||||
return LioPoint(x_raw, y_raw, z_raw, rgbi)
|
||||
|
||||
|
||||
def _decode_lio_pcl_report(
|
||||
payload: bytes,
|
||||
limits: DecodeLimits,
|
||||
) -> tuple[MqttHeader, tuple[LioPoint, ...]]:
|
||||
header: MqttHeader | None = None
|
||||
points: list[LioPoint] = []
|
||||
try:
|
||||
for field in iter_fields(payload, max_fields=limits.max_points_per_frame + 64):
|
||||
if field.number == 1:
|
||||
header = _decode_header(_bytes_value(field, "lio_pcl.header"))
|
||||
elif field.number == 2:
|
||||
if len(points) >= limits.max_points_per_frame:
|
||||
raise StreamDecodeError(
|
||||
f"point frame exceeds {limits.max_points_per_frame} points"
|
||||
)
|
||||
points.append(_decode_lio_point(_bytes_value(field, "lio_pcl.point")))
|
||||
except ProtobufWireError as exc:
|
||||
raise StreamDecodeError(f"invalid LioPclReport: {exc}") from exc
|
||||
|
||||
if header is None:
|
||||
raise StreamDecodeError("LioPclReport has no header")
|
||||
if header.scaler == 0:
|
||||
raise StreamDecodeError("LioPclReport header scaler is zero")
|
||||
if not points:
|
||||
raise StreamDecodeError("LioPclReport has no points")
|
||||
return header, tuple(points)
|
||||
|
||||
|
||||
def decode_lio_pcl(payload: bytes, limits: DecodeLimits | None = None) -> LioPointCloudFrame:
|
||||
"""Decode the verified K1 lio_pcl envelope and raw LZ4 protobuf block."""
|
||||
bounds = limits or DecodeLimits()
|
||||
if len(payload) > bounds.max_mqtt_payload_bytes:
|
||||
raise StreamDecodeError("lio_pcl MQTT payload exceeds configured limit")
|
||||
|
||||
compression = 0
|
||||
decompressed_size = 0
|
||||
compressed_data: bytes | None = None
|
||||
try:
|
||||
for field in iter_fields(payload, max_fields=64):
|
||||
if field.number == 2:
|
||||
compression = _int_value(field, "compression")
|
||||
elif field.number == 3:
|
||||
decompressed_size = _int_value(field, "compressed_size")
|
||||
elif field.number == 4:
|
||||
compressed_data = _bytes_value(field, "compressed_data")
|
||||
except ProtobufWireError as exc:
|
||||
raise StreamDecodeError(f"invalid MqttCompressMsg: {exc}") from exc
|
||||
|
||||
if compression != 0:
|
||||
raise UnsupportedCompressionError(
|
||||
f"compression enum {compression} is not the verified raw-LZ4 mode"
|
||||
)
|
||||
if compressed_data is None or not compressed_data:
|
||||
raise StreamDecodeError("MqttCompressMsg has no compressed_data")
|
||||
if len(compressed_data) > bounds.max_compressed_bytes:
|
||||
raise StreamDecodeError("compressed_data exceeds configured limit")
|
||||
if decompressed_size < 1 or decompressed_size > bounds.max_decompressed_bytes:
|
||||
raise StreamDecodeError("compressed_size is outside configured bounds")
|
||||
if decompressed_size > len(compressed_data) * bounds.max_compression_ratio:
|
||||
raise StreamDecodeError("claimed LZ4 expansion ratio exceeds configured limit")
|
||||
|
||||
try:
|
||||
decompressed = lz4.block.decompress(
|
||||
compressed_data,
|
||||
uncompressed_size=decompressed_size,
|
||||
)
|
||||
except lz4.block.LZ4BlockError as exc:
|
||||
raise StreamDecodeError(f"raw LZ4 decode failed: {exc}") from exc
|
||||
if len(decompressed) != decompressed_size:
|
||||
raise StreamDecodeError(
|
||||
f"raw LZ4 length mismatch: expected {decompressed_size}, got {len(decompressed)}"
|
||||
)
|
||||
|
||||
header, points = _decode_lio_pcl_report(decompressed, bounds)
|
||||
return LioPointCloudFrame(
|
||||
header=header,
|
||||
compression=compression,
|
||||
compressed_bytes=len(compressed_data),
|
||||
decompressed_bytes=len(decompressed),
|
||||
points=points,
|
||||
)
|
||||
|
||||
|
||||
def _decode_position(payload: bytes) -> tuple[float, float, float]:
|
||||
values = [0.0, 0.0, 0.0]
|
||||
for field in iter_fields(payload, max_fields=16):
|
||||
if 1 <= field.number <= 3:
|
||||
values[field.number - 1] = _float64(field, f"position.{field.number}")
|
||||
return values[0], values[1], values[2]
|
||||
|
||||
|
||||
def _decode_orientation(payload: bytes) -> tuple[float, float, float, float]:
|
||||
values = [0.0, 0.0, 0.0, 0.0]
|
||||
for field in iter_fields(payload, max_fields=16):
|
||||
if 1 <= field.number <= 4:
|
||||
values[field.number - 1] = _float64(field, f"orientation.{field.number}")
|
||||
return values[0], values[1], values[2], values[3]
|
||||
|
||||
|
||||
def _decode_pose(
|
||||
payload: bytes,
|
||||
) -> tuple[tuple[float, float, float], tuple[float, float, float, float]]:
|
||||
position = (0.0, 0.0, 0.0)
|
||||
orientation = (0.0, 0.0, 0.0, 0.0)
|
||||
for field in iter_fields(payload, max_fields=16):
|
||||
if field.number == 1:
|
||||
position = _decode_position(_bytes_value(field, "pose.position"))
|
||||
elif field.number == 2:
|
||||
orientation = _decode_orientation(_bytes_value(field, "pose.orientation"))
|
||||
return position, orientation
|
||||
|
||||
|
||||
def _decode_pose_stamped(
|
||||
payload: bytes,
|
||||
) -> tuple[int, tuple[float, float, float], tuple[float, float, float, float]]:
|
||||
stamp = 0
|
||||
position = (0.0, 0.0, 0.0)
|
||||
orientation = (0.0, 0.0, 0.0, 0.0)
|
||||
for field in iter_fields(payload, max_fields=16):
|
||||
if field.number == 1:
|
||||
stamp = decode_zigzag64(_int_value(field, "pose_stamp.stamp"))
|
||||
elif field.number == 2:
|
||||
position, orientation = _decode_pose(_bytes_value(field, "pose_stamp.pose"))
|
||||
return stamp, position, orientation
|
||||
|
||||
|
||||
def decode_lio_pose(payload: bytes, limits: DecodeLimits | None = None) -> LioPoseFrame:
|
||||
"""Decode a direct lixel/application/report/lio_pose protobuf payload."""
|
||||
bounds = limits or DecodeLimits()
|
||||
if len(payload) > bounds.max_mqtt_payload_bytes:
|
||||
raise StreamDecodeError("lio_pose MQTT payload exceeds configured limit")
|
||||
|
||||
header: MqttHeader | None = None
|
||||
pose_stamp = 0
|
||||
position = (0.0, 0.0, 0.0)
|
||||
orientation = (0.0, 0.0, 0.0, 0.0)
|
||||
distance = 0.0
|
||||
pose_accuracy = 0.0
|
||||
try:
|
||||
for field in iter_fields(payload, max_fields=64):
|
||||
if field.number == 1:
|
||||
header = _decode_header(_bytes_value(field, "lio_pose.header"))
|
||||
elif field.number == 2:
|
||||
pose_stamp, position, orientation = _decode_pose_stamped(
|
||||
_bytes_value(field, "lio_pose.pose")
|
||||
)
|
||||
elif field.number == 3:
|
||||
distance = _float32(field, "lio_pose.distance")
|
||||
elif field.number == 4:
|
||||
pose_accuracy = _float32(field, "lio_pose.pose_accuracy")
|
||||
except ProtobufWireError as exc:
|
||||
raise StreamDecodeError(f"invalid LioPoseReport: {exc}") from exc
|
||||
|
||||
if header is None:
|
||||
raise StreamDecodeError("LioPoseReport has no header")
|
||||
return LioPoseFrame(
|
||||
header=header,
|
||||
pose_stamp=pose_stamp,
|
||||
position_xyz=position,
|
||||
orientation_xyzw=orientation,
|
||||
distance=distance,
|
||||
pose_accuracy=pose_accuracy,
|
||||
)
|
||||
|
||||
|
||||
def decode_legacy_pointcloud(
|
||||
payload: bytes,
|
||||
*,
|
||||
max_points: int = 250_000,
|
||||
) -> LegacyPointCloudFrame:
|
||||
"""Decode the verified legacy RealtimePointcloud envelope and point records."""
|
||||
if max_points < 1:
|
||||
raise ValueError("max_points must be positive")
|
||||
if len(payload) < 12:
|
||||
raise StreamDecodeError("legacy pointcloud payload is shorter than 12-byte envelope")
|
||||
stride = int.from_bytes(payload[:4], "little")
|
||||
if stride < 15:
|
||||
raise StreamDecodeError("legacy point stride is smaller than xyz+rgb")
|
||||
body = payload[12:]
|
||||
if len(body) % stride:
|
||||
raise StreamDecodeError("legacy pointcloud body is not divisible by stride")
|
||||
point_count = len(body) // stride
|
||||
if point_count > max_points:
|
||||
raise StreamDecodeError(f"legacy pointcloud exceeds {max_points} points")
|
||||
|
||||
points: list[LegacyPoint] = []
|
||||
for offset in range(0, len(body), stride):
|
||||
x, y, z = struct.unpack_from("<fff", body, offset)
|
||||
if not all(math.isfinite(value) for value in (x, y, z)):
|
||||
raise StreamDecodeError("legacy point position is not finite")
|
||||
r, g, b = body[offset + 12 : offset + 15]
|
||||
intensity = body[offset + 15] if stride >= 16 else 255
|
||||
points.append(LegacyPoint(x, y, z, r, g, b, intensity))
|
||||
return LegacyPointCloudFrame(payload[:12], stride, tuple(points))
|
||||
|
||||
|
||||
def decode_legacy_pose(payload: bytes) -> LegacyPoseFrame:
|
||||
"""Decode the verified legacy RealtimePath position/quaternion fields."""
|
||||
if len(payload) < 32:
|
||||
raise StreamDecodeError("legacy pose payload is shorter than 32 bytes")
|
||||
x, y, z = struct.unpack_from("<fff", payload, 0)
|
||||
wire_w, qx, qy, qz = struct.unpack_from("<ffff", payload, 16)
|
||||
values = (x, y, z, qx, qy, qz, wire_w)
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
raise StreamDecodeError("legacy pose contains a non-finite value")
|
||||
return LegacyPoseFrame(
|
||||
position_xyz=(x, y, z),
|
||||
orientation_xyzw=(qx, qy, qz, wire_w),
|
||||
skipped_offset_12=payload[12:16],
|
||||
unknown_tail=payload[32:],
|
||||
)
|
||||
|
||||
|
||||
def decode_pre_path_array(payload: bytes) -> tuple[float, ...]:
|
||||
"""Decode the exact 16-float64 legacy PrePathArray matrix."""
|
||||
if len(payload) != 128:
|
||||
raise StreamDecodeError("PrePathArray payload must be exactly 128 bytes")
|
||||
values = struct.unpack("<16d", payload)
|
||||
if not all(math.isfinite(value) for value in values):
|
||||
raise StreamDecodeError("PrePathArray contains a non-finite value")
|
||||
return values
|
||||
@@ -0,0 +1 @@
|
||||
"""Read-only USB discovery helpers for macOS."""
|
||||
@@ -0,0 +1,515 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import plistlib
|
||||
import re
|
||||
import subprocess
|
||||
from collections.abc import Callable, Iterator
|
||||
from dataclasses import dataclass
|
||||
from pathlib import PurePosixPath
|
||||
from typing import TypedDict, cast
|
||||
|
||||
from k1link.artifacts import utc_now_iso
|
||||
|
||||
USB_IOREG_COMMAND = (
|
||||
"/usr/sbin/ioreg",
|
||||
"-a",
|
||||
"-r",
|
||||
"-c",
|
||||
"IOUSBHostDevice",
|
||||
"-l",
|
||||
"-w",
|
||||
"0",
|
||||
)
|
||||
SERIAL_IOREG_COMMAND = (
|
||||
"/usr/sbin/ioreg",
|
||||
"-a",
|
||||
"-r",
|
||||
"-c",
|
||||
"IOSerialBSDClient",
|
||||
"-l",
|
||||
"-w",
|
||||
"0",
|
||||
)
|
||||
DISKUTIL_COMMAND = ("/usr/sbin/diskutil", "list", "-plist", "external", "physical")
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CommandOutput:
|
||||
argv: tuple[str, ...]
|
||||
returncode: int | None
|
||||
stdout: bytes
|
||||
stderr: bytes
|
||||
error: str | None
|
||||
|
||||
|
||||
CommandRunner = Callable[[list[str]], CommandOutput]
|
||||
|
||||
|
||||
class SourceStatus(TypedDict):
|
||||
name: str
|
||||
argv: list[str]
|
||||
ok: bool
|
||||
returncode: int | None
|
||||
error: str | None
|
||||
|
||||
|
||||
class UsbInterfaceRecord(TypedDict):
|
||||
name: str | None
|
||||
function_hints: list[str]
|
||||
interface_number: int | None
|
||||
interface_class: int | None
|
||||
interface_class_hex: str | None
|
||||
interface_class_name: str | None
|
||||
interface_subclass: int | None
|
||||
interface_protocol: int | None
|
||||
alternate_setting: int | None
|
||||
configuration_value: int | None
|
||||
endpoint_count: int | None
|
||||
|
||||
|
||||
class UsbDeviceRecord(TypedDict):
|
||||
product_name: str | None
|
||||
vendor_name: str | None
|
||||
serial_number: str | None
|
||||
vendor_id: int | None
|
||||
vendor_id_hex: str | None
|
||||
product_id: int | None
|
||||
product_id_hex: str | None
|
||||
device_class: int | None
|
||||
device_subclass: int | None
|
||||
device_protocol: int | None
|
||||
usb_bcd: int | None
|
||||
device_bcd: int | None
|
||||
usb_speed: int | None
|
||||
link_speed_bits_per_second: int | None
|
||||
usb_address: int | None
|
||||
location_id: int | None
|
||||
registry_entry_id: int | None
|
||||
bsd_names: list[str]
|
||||
interface_capabilities: list[str]
|
||||
interfaces: list[UsbInterfaceRecord]
|
||||
|
||||
|
||||
class ExternalStorageEntry(TypedDict):
|
||||
device_identifier: str
|
||||
parent_device_identifier: str | None
|
||||
content: str | None
|
||||
size_bytes: int | None
|
||||
volume_name: str | None
|
||||
mount_point: str | None
|
||||
os_internal: bool | None
|
||||
xgrids_related: bool
|
||||
|
||||
|
||||
class ExternalStorageRecord(TypedDict):
|
||||
all_disks: list[str]
|
||||
whole_disks: list[str]
|
||||
volumes_from_disks: list[str]
|
||||
entries: list[ExternalStorageEntry]
|
||||
|
||||
|
||||
class UsbModemRecord(TypedDict):
|
||||
callout_device: str | None
|
||||
dialin_device: str | None
|
||||
tty_base_name: str | None
|
||||
client_type: str | None
|
||||
|
||||
|
||||
class SafetyRecord(TypedDict):
|
||||
metadata_only: bool
|
||||
sudo_used: bool
|
||||
device_file_contents_read: bool
|
||||
device_writes_performed: bool
|
||||
|
||||
|
||||
class UsbSnapshot(TypedDict):
|
||||
schema_version: int
|
||||
created_at_utc: str
|
||||
sensitivity: str
|
||||
safety: SafetyRecord
|
||||
sources: list[SourceStatus]
|
||||
xgrids_device_count: int
|
||||
xgrids_devices: list[UsbDeviceRecord]
|
||||
external_storage: ExternalStorageRecord
|
||||
usbmodem_device_names: list[str]
|
||||
usbmodem_devices: list[UsbModemRecord]
|
||||
|
||||
|
||||
def run_command(argv: list[str]) -> CommandOutput:
|
||||
"""Run a fixed read-only macOS metadata command without privilege escalation."""
|
||||
try:
|
||||
result = subprocess.run(
|
||||
argv,
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=20,
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError) as exc:
|
||||
return CommandOutput(
|
||||
argv=tuple(argv),
|
||||
returncode=None,
|
||||
stdout=b"",
|
||||
stderr=b"",
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
)
|
||||
return CommandOutput(
|
||||
argv=tuple(argv),
|
||||
returncode=result.returncode,
|
||||
stdout=result.stdout,
|
||||
stderr=result.stderr,
|
||||
error=None,
|
||||
)
|
||||
|
||||
|
||||
def _node(value: object) -> dict[str, object] | None:
|
||||
if not isinstance(value, dict):
|
||||
return None
|
||||
raw = cast("dict[object, object]", value)
|
||||
return {key: item for key, item in raw.items() if isinstance(key, str)}
|
||||
|
||||
|
||||
def _items(value: object) -> list[object]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return cast("list[object]", value)
|
||||
|
||||
|
||||
def _walk_registry_nodes(value: object) -> Iterator[dict[str, object]]:
|
||||
for item in _items(value):
|
||||
yield from _walk_registry_nodes(item)
|
||||
|
||||
node = _node(value)
|
||||
if node is None:
|
||||
return
|
||||
yield node
|
||||
yield from _walk_registry_nodes(node.get("IORegistryEntryChildren"))
|
||||
|
||||
|
||||
def _string(node: dict[str, object], *keys: str) -> str | None:
|
||||
for key in keys:
|
||||
value = node.get(key)
|
||||
if isinstance(value, str):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _integer(node: dict[str, object], key: str) -> int | None:
|
||||
value = node.get(key)
|
||||
if isinstance(value, int) and not isinstance(value, bool):
|
||||
return value
|
||||
return None
|
||||
|
||||
|
||||
def _boolean(node: dict[str, object], key: str) -> bool | None:
|
||||
value = node.get(key)
|
||||
return value if isinstance(value, bool) else None
|
||||
|
||||
|
||||
def _hex_id(value: int | None) -> str | None:
|
||||
return None if value is None else f"0x{value:04x}"
|
||||
|
||||
|
||||
def _is_xgrids_device(node: dict[str, object]) -> bool:
|
||||
names = [
|
||||
_string(node, "USB Product Name"),
|
||||
_string(node, "kUSBProductString"),
|
||||
_string(node, "IORegistryEntryName"),
|
||||
_string(node, "USB Vendor Name"),
|
||||
_string(node, "kUSBVendorString"),
|
||||
]
|
||||
identity = " ".join(name.casefold() for name in names if name is not None)
|
||||
return "xgrids" in identity or "lixel" in identity
|
||||
|
||||
|
||||
def _usb_class_name(interface_class: int | None) -> str | None:
|
||||
if interface_class is None:
|
||||
return None
|
||||
return {
|
||||
0x02: "communications_and_cdc_control",
|
||||
0x08: "mass_storage",
|
||||
0x0A: "cdc_data",
|
||||
0xE0: "wireless_controller",
|
||||
0xFF: "vendor_specific",
|
||||
}.get(interface_class)
|
||||
|
||||
|
||||
def _function_hints(
|
||||
name: str | None,
|
||||
interface_class: int | None,
|
||||
interface_subclass: int | None,
|
||||
) -> list[str]:
|
||||
normalized = (name or "").casefold()
|
||||
hints: set[str] = set()
|
||||
if "rndis" in normalized:
|
||||
hints.add("rndis")
|
||||
if "mass storage" in normalized or interface_class == 0x08:
|
||||
hints.add("mass_storage")
|
||||
if "ncm" in normalized or (interface_class == 0x02 and interface_subclass == 0x0D):
|
||||
hints.add("ncm")
|
||||
if any(marker in normalized for marker in ("serial", "modem", "acm")) or (
|
||||
interface_class == 0x02 and interface_subclass == 0x02
|
||||
):
|
||||
hints.add("serial")
|
||||
if interface_class == 0x0A:
|
||||
hints.add("cdc_data")
|
||||
return sorted(hints)
|
||||
|
||||
|
||||
def _interface_record(node: dict[str, object]) -> UsbInterfaceRecord:
|
||||
name = _string(node, "kUSBString", "IORegistryEntryName")
|
||||
interface_class = _integer(node, "bInterfaceClass")
|
||||
interface_subclass = _integer(node, "bInterfaceSubClass")
|
||||
return {
|
||||
"name": name,
|
||||
"function_hints": _function_hints(name, interface_class, interface_subclass),
|
||||
"interface_number": _integer(node, "bInterfaceNumber"),
|
||||
"interface_class": interface_class,
|
||||
"interface_class_hex": _hex_id(interface_class),
|
||||
"interface_class_name": _usb_class_name(interface_class),
|
||||
"interface_subclass": interface_subclass,
|
||||
"interface_protocol": _integer(node, "bInterfaceProtocol"),
|
||||
"alternate_setting": _integer(node, "bAlternateSetting"),
|
||||
"configuration_value": _integer(node, "bConfigurationValue"),
|
||||
"endpoint_count": _integer(node, "bNumEndpoints"),
|
||||
}
|
||||
|
||||
|
||||
def _interface_sort_key(record: UsbInterfaceRecord) -> tuple[bool, int, str]:
|
||||
number = record["interface_number"]
|
||||
return (number is None, number if number is not None else 0, record["name"] or "")
|
||||
|
||||
|
||||
def _device_record(node: dict[str, object]) -> UsbDeviceRecord:
|
||||
interfaces = [
|
||||
_interface_record(child)
|
||||
for child in _walk_registry_nodes(node.get("IORegistryEntryChildren"))
|
||||
if _string(child, "IOObjectClass") == "IOUSBHostInterface"
|
||||
]
|
||||
interfaces.sort(key=_interface_sort_key)
|
||||
|
||||
bsd_names = sorted(
|
||||
{
|
||||
name
|
||||
for child in _walk_registry_nodes(node.get("IORegistryEntryChildren"))
|
||||
if (name := _string(child, "BSD Name")) is not None
|
||||
}
|
||||
)
|
||||
interface_capabilities = sorted(
|
||||
{hint for interface in interfaces for hint in interface["function_hints"]}
|
||||
)
|
||||
vendor_id = _integer(node, "idVendor")
|
||||
product_id = _integer(node, "idProduct")
|
||||
return {
|
||||
"product_name": _string(
|
||||
node, "USB Product Name", "kUSBProductString", "IORegistryEntryName"
|
||||
),
|
||||
"vendor_name": _string(node, "USB Vendor Name", "kUSBVendorString"),
|
||||
"serial_number": _string(node, "USB Serial Number", "kUSBSerialNumberString"),
|
||||
"vendor_id": vendor_id,
|
||||
"vendor_id_hex": _hex_id(vendor_id),
|
||||
"product_id": product_id,
|
||||
"product_id_hex": _hex_id(product_id),
|
||||
"device_class": _integer(node, "bDeviceClass"),
|
||||
"device_subclass": _integer(node, "bDeviceSubClass"),
|
||||
"device_protocol": _integer(node, "bDeviceProtocol"),
|
||||
"usb_bcd": _integer(node, "bcdUSB"),
|
||||
"device_bcd": _integer(node, "bcdDevice"),
|
||||
"usb_speed": _integer(node, "USBSpeed"),
|
||||
"link_speed_bits_per_second": _integer(node, "UsbLinkSpeed"),
|
||||
"usb_address": _integer(node, "USB Address"),
|
||||
"location_id": _integer(node, "locationID"),
|
||||
"registry_entry_id": _integer(node, "IORegistryEntryID"),
|
||||
"bsd_names": bsd_names,
|
||||
"interface_capabilities": interface_capabilities,
|
||||
"interfaces": interfaces,
|
||||
}
|
||||
|
||||
|
||||
def parse_xgrids_devices(plist: object) -> list[UsbDeviceRecord]:
|
||||
devices = [
|
||||
_device_record(node)
|
||||
for node in (_node(item) for item in _items(plist))
|
||||
if node is not None and _is_xgrids_device(node)
|
||||
]
|
||||
devices.sort(
|
||||
key=lambda record: (
|
||||
record["product_name"] or "",
|
||||
record["serial_number"] or "",
|
||||
record["location_id"] or 0,
|
||||
)
|
||||
)
|
||||
return devices
|
||||
|
||||
|
||||
def _string_list(node: dict[str, object], key: str) -> list[str]:
|
||||
return sorted(item for item in _items(node.get(key)) if isinstance(item, str))
|
||||
|
||||
|
||||
def _disk_root(device_identifier: str) -> str | None:
|
||||
match = re.fullmatch(r"(disk\d+)(?:s\d+)*", device_identifier)
|
||||
return None if match is None else match.group(1)
|
||||
|
||||
|
||||
def _external_storage_entries(
|
||||
value: object,
|
||||
parent_device_identifier: str | None,
|
||||
xgrids_disk_roots: set[str],
|
||||
) -> list[ExternalStorageEntry]:
|
||||
entries: list[ExternalStorageEntry] = []
|
||||
for item in _items(value):
|
||||
node = _node(item)
|
||||
if node is None:
|
||||
continue
|
||||
device_identifier = _string(node, "DeviceIdentifier")
|
||||
next_parent = parent_device_identifier
|
||||
if device_identifier is not None:
|
||||
disk_root = _disk_root(device_identifier)
|
||||
entries.append(
|
||||
{
|
||||
"device_identifier": device_identifier,
|
||||
"parent_device_identifier": parent_device_identifier,
|
||||
"content": _string(node, "Content"),
|
||||
"size_bytes": _integer(node, "Size"),
|
||||
"volume_name": _string(node, "VolumeName"),
|
||||
"mount_point": _string(node, "MountPoint"),
|
||||
"os_internal": _boolean(node, "OSInternal"),
|
||||
"xgrids_related": bool(
|
||||
disk_root is not None and disk_root in xgrids_disk_roots
|
||||
),
|
||||
}
|
||||
)
|
||||
next_parent = device_identifier
|
||||
for child_key in ("Partitions", "APFSVolumes"):
|
||||
entries.extend(
|
||||
_external_storage_entries(
|
||||
node.get(child_key),
|
||||
next_parent,
|
||||
xgrids_disk_roots,
|
||||
)
|
||||
)
|
||||
return entries
|
||||
|
||||
|
||||
def parse_external_storage(
|
||||
plist: object,
|
||||
xgrids_devices: list[UsbDeviceRecord],
|
||||
) -> ExternalStorageRecord:
|
||||
node = _node(plist) or {}
|
||||
xgrids_disk_roots = {
|
||||
root
|
||||
for device in xgrids_devices
|
||||
for name in device["bsd_names"]
|
||||
if (root := _disk_root(name)) is not None
|
||||
}
|
||||
entries = _external_storage_entries(
|
||||
node.get("AllDisksAndPartitions"),
|
||||
None,
|
||||
xgrids_disk_roots,
|
||||
)
|
||||
entries.sort(key=lambda entry: entry["device_identifier"])
|
||||
return {
|
||||
"all_disks": _string_list(node, "AllDisks"),
|
||||
"whole_disks": _string_list(node, "WholeDisks"),
|
||||
"volumes_from_disks": _string_list(node, "VolumesFromDisks"),
|
||||
"entries": entries,
|
||||
}
|
||||
|
||||
|
||||
def _is_usbmodem_path(path: str | None) -> bool:
|
||||
return path is not None and PurePosixPath(path).name.startswith(("cu.usbmodem", "tty.usbmodem"))
|
||||
|
||||
|
||||
def parse_usbmodem_devices(plist: object) -> tuple[list[str], list[UsbModemRecord]]:
|
||||
names: set[str] = set()
|
||||
records: list[UsbModemRecord] = []
|
||||
for item in _items(plist):
|
||||
node = _node(item)
|
||||
if node is None:
|
||||
continue
|
||||
callout = _string(node, "IOCalloutDevice")
|
||||
dialin = _string(node, "IODialinDevice")
|
||||
matched_paths: list[str] = []
|
||||
for path in (callout, dialin):
|
||||
if path is not None and _is_usbmodem_path(path):
|
||||
matched_paths.append(path)
|
||||
if not matched_paths:
|
||||
continue
|
||||
names.update(matched_paths)
|
||||
records.append(
|
||||
{
|
||||
"callout_device": callout,
|
||||
"dialin_device": dialin,
|
||||
"tty_base_name": _string(node, "IOTTYBaseName"),
|
||||
"client_type": _string(node, "IOSerialBSDClientType"),
|
||||
}
|
||||
)
|
||||
records.sort(key=lambda record: (record["callout_device"] or "", record["dialin_device"] or ""))
|
||||
return sorted(names), records
|
||||
|
||||
|
||||
def _source_status(name: str, output: CommandOutput) -> SourceStatus:
|
||||
error = output.error
|
||||
if error is None and output.returncode != 0:
|
||||
stderr = output.stderr.decode("utf-8", errors="replace").strip()
|
||||
error = stderr[:500] or f"command exited with status {output.returncode}"
|
||||
return {
|
||||
"name": name,
|
||||
"argv": list(output.argv),
|
||||
"ok": error is None and output.returncode == 0,
|
||||
"returncode": output.returncode,
|
||||
"error": error,
|
||||
}
|
||||
|
||||
|
||||
def _load_plist(
|
||||
name: str,
|
||||
output: CommandOutput,
|
||||
*,
|
||||
empty_is_list: bool = False,
|
||||
) -> tuple[object | None, SourceStatus]:
|
||||
status = _source_status(name, output)
|
||||
if not status["ok"]:
|
||||
return None, status
|
||||
if empty_is_list and not output.stdout.strip():
|
||||
return [], status
|
||||
try:
|
||||
return plistlib.loads(output.stdout), status
|
||||
except (plistlib.InvalidFileException, ValueError, TypeError, OverflowError) as exc:
|
||||
status["ok"] = False
|
||||
status["error"] = f"invalid plist: {type(exc).__name__}: {exc}"
|
||||
return None, status
|
||||
|
||||
|
||||
def snapshot(runner: CommandRunner = run_command) -> UsbSnapshot:
|
||||
"""Collect USB registry and storage metadata without opening device files."""
|
||||
usb_output = runner(list(USB_IOREG_COMMAND))
|
||||
serial_output = runner(list(SERIAL_IOREG_COMMAND))
|
||||
storage_output = runner(list(DISKUTIL_COMMAND))
|
||||
|
||||
usb_plist, usb_status = _load_plist("usb_ioreg", usb_output, empty_is_list=True)
|
||||
serial_plist, serial_status = _load_plist("serial_ioreg", serial_output, empty_is_list=True)
|
||||
storage_plist, storage_status = _load_plist("external_disks", storage_output)
|
||||
|
||||
devices = parse_xgrids_devices(usb_plist)
|
||||
usbmodem_names, usbmodem_devices = parse_usbmodem_devices(serial_plist)
|
||||
external_storage = parse_external_storage(storage_plist, devices)
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"created_at_utc": utc_now_iso(),
|
||||
"sensitivity": (
|
||||
"contains USB serial numbers, BSD device names and external volume metadata; "
|
||||
"store only in an ignored session path and do not commit"
|
||||
),
|
||||
"safety": {
|
||||
"metadata_only": True,
|
||||
"sudo_used": False,
|
||||
"device_file_contents_read": False,
|
||||
"device_writes_performed": False,
|
||||
},
|
||||
"sources": [usb_status, serial_status, storage_status],
|
||||
"xgrids_device_count": len(devices),
|
||||
"xgrids_devices": devices,
|
||||
"external_storage": external_storage,
|
||||
"usbmodem_device_names": usbmodem_names,
|
||||
"usbmodem_devices": usbmodem_devices,
|
||||
}
|
||||
Reference in New Issue
Block a user