feat: prove and decode K1 realtime MQTT streams

This commit is contained in:
DCCONSTRUCTIONS
2026-07-15 19:10:41 +03:00
parent faa442eefc
commit 6b22e5a1d2
30 changed files with 4375 additions and 41 deletions
+15
View File
@@ -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",
]
+354
View File
@@ -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,
)
+68
View File
@@ -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(),
}
+289
View File
@@ -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
View File
@@ -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()
+48
View File
@@ -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
+27
View File
@@ -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",
]
+648
View File
@@ -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())
+35
View File
@@ -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",
]
+89
View File
@@ -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}")
+410
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
"""Read-only USB discovery helpers for macOS."""
+515
View File
@@ -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,
}