feat: prove and decode K1 realtime MQTT streams
This commit is contained in:
parent
faa442eefc
commit
6b22e5a1d2
|
|
@ -37,8 +37,10 @@ artifacts/decoded/
|
||||||
*.pcd
|
*.pcd
|
||||||
*.npz
|
*.npz
|
||||||
*.raw
|
*.raw
|
||||||
|
*.k1mqtt
|
||||||
|
mqtt.metadata.jsonl
|
||||||
|
mqtt.summary.json
|
||||||
|
|
||||||
# Small synthetic/redacted fixtures under tests/fixtures are allowed.
|
# Small synthetic/redacted fixtures under tests/fixtures are allowed.
|
||||||
!tests/fixtures/**/*.pcap
|
!tests/fixtures/**/*.pcap
|
||||||
!tests/fixtures/**/*.pcapng
|
!tests/fixtures/**/*.pcapng
|
||||||
|
|
||||||
|
|
|
||||||
56
README.md
56
README.md
|
|
@ -4,9 +4,15 @@ Pre-production research project for connecting an owner-controlled
|
||||||
XGRIDS/LixelKity K1 to a Mac without LixelGO, firmware changes, device opening,
|
XGRIDS/LixelKity K1 to a Mac without LixelGO, firmware changes, device opening,
|
||||||
or speculative writes.
|
or speculative writes.
|
||||||
|
|
||||||
Current status: repository scaffold and implementation plan. No command in the
|
Current status: live proof completed on firmware 3.0.2. The Mac provisioned the
|
||||||
repository currently writes to the K1, changes the router, or installs system
|
K1 onto an existing LAN without LixelGO, connected to its MQTT broker, captured
|
||||||
packages.
|
the scan-correlated point-cloud and pose streams, and decoded both successfully.
|
||||||
|
|
||||||
|
The repository now contains one narrowly gated state-changing command:
|
||||||
|
`ble wifi-configure`. It accepts only the reviewed firmware-3 provisioning
|
||||||
|
profile and requires explicit `--confirm-write`; the Wi-Fi password is collected
|
||||||
|
through a hidden local macOS dialog. MQTT capture and decoding are read-only.
|
||||||
|
Nothing changes router settings, firmware or global Python packages.
|
||||||
|
|
||||||
## Available stand
|
## Available stand
|
||||||
|
|
||||||
|
|
@ -29,9 +35,10 @@ The project has three independent gates:
|
||||||
3. Without LixelGO, K1 can be associated with Wi-Fi and a proprietary data
|
3. Without LixelGO, K1 can be associated with Wi-Fi and a proprietary data
|
||||||
session can be opened.
|
session can be opened.
|
||||||
|
|
||||||
Only after gate 3 do point-cloud, pose, status, and camera stream decoders become
|
All three gates are now proven on the tested unit. The external stream is plain
|
||||||
meaningful implementation work. Local `map.las` and `poses.csv` prove internal
|
MQTT 3.1.1 on TCP 1883. Firmware-3 `lio_pcl` is protobuf wrapped in a raw LZ4
|
||||||
capabilities, not an externally accessible network format.
|
block, and `lio_pose` is an uncompressed protobuf. Raw panoramic camera access is
|
||||||
|
still unproven and is not implied by point-cloud success.
|
||||||
|
|
||||||
## Local environment
|
## Local environment
|
||||||
|
|
||||||
|
|
@ -50,19 +57,33 @@ uv run pytest
|
||||||
and reports external tools; it does not request Bluetooth permission, scan the
|
and reports external tools; it does not request Bluetooth permission, scan the
|
||||||
LAN, touch the K1, alter Homebrew, or change capture permissions.
|
LAN, touch the K1, alter Homebrew, or change capture permissions.
|
||||||
|
|
||||||
The currently implemented laboratory commands are:
|
The implemented laboratory commands include:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv run k1link ble scan --duration 30 --out sessions/<id>/captures/ble.json
|
uv run k1link ble scan --duration 30 --out sessions/<id>/captures/ble.json
|
||||||
uv run k1link ble gatt-dump --device <corebluetooth-uuid> \
|
uv run k1link ble gatt-dump --device <corebluetooth-uuid> \
|
||||||
--out sessions/<id>/captures/gatt.json
|
--out sessions/<id>/captures/gatt.json
|
||||||
|
uv run k1link ble wifi-configure --device <corebluetooth-uuid> \
|
||||||
|
--profile xgrids-k1-fw3-wifi-v1 --write-mode with_response \
|
||||||
|
--confirm-write --out sessions/<id>/captures/wifi.sensitive.json
|
||||||
uv run k1link net snapshot --out sessions/<id>/captures/network.json
|
uv run k1link net snapshot --out sessions/<id>/captures/network.json
|
||||||
|
uv run k1link net mqtt-capture --host <confirmed-private-k1-ip> \
|
||||||
|
--confirm-owned-device --duration 180 \
|
||||||
|
--out sessions/<id>/captures/mqtt-run
|
||||||
|
uv run k1link analyze mqtt-streams \
|
||||||
|
--capture sessions/<id>/captures/mqtt-run/mqtt.raw.k1mqtt \
|
||||||
|
--out sessions/<id>/analysis/mqtt-streams.summary.json
|
||||||
```
|
```
|
||||||
|
|
||||||
On macOS the scan is active CoreBluetooth discovery, but it does not connect to
|
On macOS the BLE scan is active CoreBluetooth discovery, but it does not connect
|
||||||
or modify devices. `gatt-dump` connects and performs service discovery only; it
|
to or modify devices. `gatt-dump` connects and performs service discovery only.
|
||||||
does not read characteristic values, subscribe to notifications or write
|
`mqtt-capture` accepts only a literal RFC1918 target, uses a fixed report-topic
|
||||||
configuration. Session output is sensitive and ignored by Git.
|
allowlist, never publishes and never reconnects. It writes a length-framed raw
|
||||||
|
file, JSONL metadata and an integrity summary with mode `0600`.
|
||||||
|
|
||||||
|
Session output is sensitive and ignored by Git. It can contain device identity,
|
||||||
|
trajectory, mapped interiors and local addressing even when no credentials are
|
||||||
|
present.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|
@ -70,6 +91,9 @@ configuration. Session output is sensitive and ignored by Git.
|
||||||
- [Implementation gates](docs/01_IMPLEMENTATION_PLAN.md)
|
- [Implementation gates](docs/01_IMPLEMENTATION_PLAN.md)
|
||||||
- [First lab runbook](docs/02_FIRST_LAB_RUNBOOK.md)
|
- [First lab runbook](docs/02_FIRST_LAB_RUNBOOK.md)
|
||||||
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
|
- [Artifact and secret policy](docs/03_ARTIFACT_POLICY.md)
|
||||||
|
- [Reviewed BLE Wi-Fi profile](docs/04_K1_WIFI_PROVISIONING_PROFILE.md)
|
||||||
|
- [Verified MQTT stream profile](docs/05_K1_MQTT_STREAM_PROFILE.md)
|
||||||
|
- [Redacted live lab report](docs/lab/001_K1_LIVE_MQTT_20260715.redacted.md)
|
||||||
- [Session manifest schema](schemas/session-manifest.schema.json)
|
- [Session manifest schema](schemas/session-manifest.schema.json)
|
||||||
- [Reference input provenance](docs/reference/README.md)
|
- [Reference input provenance](docs/reference/README.md)
|
||||||
|
|
||||||
|
|
@ -84,10 +108,12 @@ controlled notification listening, autonomous button operation, targeted
|
||||||
capture of traffic to or from the confirmed K1 address, and offline analysis of
|
capture of traffic to or from the confirmed K1 address, and offline analysis of
|
||||||
owned artifacts.
|
owned artifacts.
|
||||||
|
|
||||||
BLE writes, provisioning, application-session packets, active service probes,
|
The reviewed provisioning write requires its named profile and explicit operator
|
||||||
and router configuration changes require evidence and an explicit reviewed
|
confirmation. Application command publishing remains disabled: physical
|
||||||
step. Random writes, fuzzing, brute force, firmware operations, destructive file
|
double-click is the verified start/stop mechanism. Any future MQTT publisher,
|
||||||
access, and credential guessing are out of scope.
|
router configuration change or new BLE write requires its own evidence and
|
||||||
|
reviewed step. Random writes, fuzzing, brute force, firmware operations,
|
||||||
|
destructive file access and credential guessing remain out of scope.
|
||||||
|
|
||||||
Real captures, projects, router metadata, serials, credentials, maps, images,
|
Real captures, projects, router metadata, serials, credentials, maps, images,
|
||||||
and logs are ignored by normal Git. Redacted manifests and SHA-256 inventories
|
and logs are ignored by normal Git. Redacted manifests and SHA-256 inventories
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,19 @@
|
||||||
# Technical audit
|
# Technical audit
|
||||||
|
|
||||||
Status: pre-production planning, 2026-07-15.
|
Status: live feasibility validated, 2026-07-15.
|
||||||
|
|
||||||
## Executive finding
|
## Executive finding
|
||||||
|
|
||||||
The project is feasible as a disciplined black-box investigation, but the
|
The K1-to-Mac realtime link is feasible and has been demonstrated without
|
||||||
existence of a complete K1-to-Mac realtime link is not yet established. The
|
LixelGO. The tested Mac provisioned firmware 3.0.2 over BLE, confirmed K1 LAN
|
||||||
first hard problem is not decoding points or images. It is bootstrapping the K1
|
association, opened the device's MQTT 3.1.1 service on TCP 1883, captured a
|
||||||
from BLE availability into Wi-Fi association and then opening the proprietary
|
physical-button scan and decoded both point-cloud and pose reports.
|
||||||
application data session without LixelGO.
|
|
||||||
|
All 1,140 captured `lio_pcl` frames decoded as raw-LZ4 protobuf blocks, yielding
|
||||||
|
4,165,862 points. All 1,215 `lio_pose` messages decoded, and the resulting
|
||||||
|
approximately 1.566 m displacement matched the controlled movement. The
|
||||||
|
remaining feasibility question is the panoramic camera branch, not the core
|
||||||
|
realtime LiDAR/pose path.
|
||||||
|
|
||||||
The supplied Bible is useful as an OSINT dossier. It is not an executable plan
|
The supplied Bible is useful as an OSINT dossier. It is not an executable plan
|
||||||
for the actual stand because many experiments assume a phone and LixelGO. The
|
for the actual stand because many experiments assume a phone and LixelGO. The
|
||||||
|
|
@ -31,16 +36,16 @@ writes.
|
||||||
Each of these remains scoped to the actual firmware/hardware state observed in
|
Each of these remains scoped to the actual firmware/hardware state observed in
|
||||||
the lab. The physical unit is authoritative.
|
the lab. The physical unit is authoritative.
|
||||||
|
|
||||||
### Strong hypotheses
|
### Resolved hypotheses
|
||||||
|
|
||||||
- BLE is the bootstrap/control plane and Wi-Fi is the likely high-rate data
|
- BLE is the bootstrap/provisioning plane and Wi-Fi/MQTT is the high-rate data
|
||||||
plane.
|
plane on the tested firmware.
|
||||||
- The user-provided SSID and PSK are transported inside a vendor-defined GATT
|
- SSID and PSK use a verified fixed 99-byte GATT frame; the hidden credential
|
||||||
protocol.
|
path never persists the password.
|
||||||
- The application may need a second token, certificate, handshake, or stream
|
- The report subscriptions require no second MQTT credential, certificate or
|
||||||
subscription after ordinary Wi-Fi association.
|
application publish handshake on the direct LAN path.
|
||||||
- The externally exposed point cloud is more likely a processed/downsampled
|
- The external `lio_pcl` stream is a processed point cloud rather than raw
|
||||||
preview than raw LiDAR packets.
|
LiDAR packets.
|
||||||
|
|
||||||
XGRIDS provides stronger product evidence for an external point-cloud path than
|
XGRIDS provides stronger product evidence for an external point-cloud path than
|
||||||
the local LAS alone: current LixelStudio materials describe K1 remote control
|
the local LAS alone: current LixelStudio materials describe K1 remote control
|
||||||
|
|
@ -49,18 +54,18 @@ and this does not disclose or guarantee access to the protocol from macOS. It
|
||||||
does, however, justify keeping realtime point cloud as the primary stream target.
|
does, however, justify keeping realtime point cloud as the primary stream target.
|
||||||
No comparable official evidence proves an exportable raw panorama/camera stream.
|
No comparable official evidence proves an exportable raw panorama/camera stream.
|
||||||
|
|
||||||
### Unsupported until measured
|
### Still unsupported until measured
|
||||||
|
|
||||||
- K1 remembers an existing Wi-Fi profile.
|
|
||||||
- K1 exposes its own access point.
|
|
||||||
- Provisioning fields are plain UTF-8, JSON, TLV, CBOR, or protobuf.
|
|
||||||
- A writable GATT characteristic can be used without bonding or an app token.
|
|
||||||
- Local `map.las` layout resembles the network stream.
|
- Local `map.las` layout resembles the network stream.
|
||||||
- Live pose or camera frames leave the device.
|
- Raw or stitched panoramic camera frames leave the device.
|
||||||
- A point stream contains simple float32 XYZ tuples.
|
- The upper 24 bits of point `rgbi` are usable packed RGB.
|
||||||
- Network payloads are unencrypted.
|
- MQTT application commands can be published safely without first reproducing
|
||||||
|
device/session header state and response handling.
|
||||||
|
|
||||||
## Major corrections to the source plan
|
## Initial corrections to the source plan
|
||||||
|
|
||||||
|
These corrections governed the safe experiment. The resolved outcomes above
|
||||||
|
and the verified profiles now supersede their pre-lab uncertainty.
|
||||||
|
|
||||||
1. Experiments requiring LixelGO are removed from the active critical path.
|
1. Experiments requiring LixelGO are removed from the active critical path.
|
||||||
There will be no app-session capture, provisioning diff, app-start comparison,
|
There will be no app-session capture, provisioning diff, app-start comparison,
|
||||||
|
|
@ -142,6 +147,11 @@ device baseline
|
||||||
-> point cloud / pose / status / optional camera decoders
|
-> point cloud / pose / status / optional camera decoders
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The chain through point-cloud/pose decoding is now complete. See the
|
||||||
|
[MQTT stream profile](05_K1_MQTT_STREAM_PROFILE.md) and
|
||||||
|
[redacted lab report](lab/001_K1_LIVE_MQTT_20260715.redacted.md) for measured
|
||||||
|
formats, counts and artifact hashes.
|
||||||
|
|
||||||
## Verified primary references
|
## Verified primary references
|
||||||
|
|
||||||
- [XGRIDS K1 firmware and release notes](https://www.xgrids.com/intl/support/download?page=K1)
|
- [XGRIDS K1 firmware and release notes](https://www.xgrids.com/intl/support/download?page=K1)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,24 @@
|
||||||
This plan supersedes the app-dependent experiment order in the reference Bible.
|
This plan supersedes the app-dependent experiment order in the reference Bible.
|
||||||
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
Each gate produces evidence and an explicit GO, PAUSE or BLOCKED result.
|
||||||
|
|
||||||
|
## Live checkpoint — 2026-07-15
|
||||||
|
|
||||||
|
| Stage | Result |
|
||||||
|
| --- | --- |
|
||||||
|
| Stage 0 host/repository | GO — isolated Python 3.12 environment and private Git |
|
||||||
|
| Gate 1 physical operation | GO — autonomous double-click start/stop verified |
|
||||||
|
| Stage 1 BLE discovery | GO — repeatable advertisement and GATT profile |
|
||||||
|
| Stage 2 BLE provisioning | GO — reviewed 99-byte profile, LAN association confirmed |
|
||||||
|
| Stage 3 application session | GO — MQTT 3.1.1 on confirmed K1 TCP 1883 |
|
||||||
|
| Stage 4 artifacts/flows | GO — bounded capture, hashes and negative control |
|
||||||
|
| Stage 5 point cloud | GO — raw-LZ4 protobuf, 1,140 live frames decoded |
|
||||||
|
| Stage 5 pose | GO — 1,215 live frames decoded and motion-correlated |
|
||||||
|
| Stage 5 camera | PAUSE — no independent frame/video stream observed |
|
||||||
|
|
||||||
|
USB project copying remains optional ground truth rather than a blocker for the
|
||||||
|
now-verified network path. MQTT control publishing remains deliberately deferred
|
||||||
|
because the physical button is a known-safe start/stop mechanism.
|
||||||
|
|
||||||
## Stage 0 — repository and host baseline
|
## Stage 0 — repository and host baseline
|
||||||
|
|
||||||
Deliverables:
|
Deliverables:
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Reviewed K1 Wi-Fi provisioning profile
|
||||||
|
|
||||||
|
Profile ID: `xgrids-k1-fw3-wifi-v1`
|
||||||
|
|
||||||
|
Status: reviewed for one controlled write against the owner-controlled K1. This
|
||||||
|
profile is not a generic XGRIDS protocol claim and must not be used for fuzzing.
|
||||||
|
|
||||||
|
## Evidence and target
|
||||||
|
|
||||||
|
- Observed device firmware: 3.0.2.
|
||||||
|
- Live metadata-only GATT discovery exposes service `7f00`, read/write
|
||||||
|
characteristic `7f01`, and read characteristic `7f02`.
|
||||||
|
- Static analysis of the official Android application's production path maps
|
||||||
|
its service field to `7f00`, discovered write field to `7f01`, and status/read
|
||||||
|
field to `7f02`.
|
||||||
|
- The application sends operator-supplied router credentials; it does not derive
|
||||||
|
them from the K1 and it does not require the password of the K1's own AP.
|
||||||
|
|
||||||
|
Full UUIDs:
|
||||||
|
|
||||||
|
- service: `00007f00-0000-1000-8000-00805f9b34fb`;
|
||||||
|
- write: `00007f01-0000-1000-8000-00805f9b34fb`;
|
||||||
|
- status/read: `00007f02-0000-1000-8000-00805f9b34fb`.
|
||||||
|
|
||||||
|
## Request frame
|
||||||
|
|
||||||
|
The application writes exactly 99 bytes:
|
||||||
|
|
||||||
|
| Offset | Length | Meaning |
|
||||||
|
| --- | ---: | --- |
|
||||||
|
| 0 | 1 | SSID UTF-8 byte length, 1 through 32 |
|
||||||
|
| 1 | 32 | SSID UTF-8 bytes followed by zero padding |
|
||||||
|
| 33 | 1 | password UTF-8 byte length, 1 through 64 |
|
||||||
|
| 34 | 64 | password UTF-8 bytes followed by zero padding |
|
||||||
|
| 98 | 1 | zero |
|
||||||
|
|
||||||
|
There is no checksum, nonce, token, certificate, signature, or separate commit
|
||||||
|
command in this production call path. The implementation must never print,
|
||||||
|
persist, or accept the password as a command-line argument.
|
||||||
|
|
||||||
|
The Android application unequivocally requests a write without response and
|
||||||
|
negotiates MTU 120, making the 99-byte frame one ATT command. A live read-only
|
||||||
|
CoreBluetooth check reports MTU 256 and a maximum write-without-response size of
|
||||||
|
253, so the same frame also fits one command on this Mac.
|
||||||
|
|
||||||
|
CoreBluetooth metadata advertises `write`, not `write-without-response`, despite
|
||||||
|
the official application forcing the latter. The macOS tool exposes that
|
||||||
|
discrepancy: `auto` follows advertised properties, while the explicit
|
||||||
|
`without_response` mode mirrors the application only after checking the live
|
||||||
|
maximum is at least 99. The first controlled experiment uses that explicit
|
||||||
|
mode. It never fragments or retries the payload automatically.
|
||||||
|
|
||||||
|
## Expected transition and evidence of acceptance
|
||||||
|
|
||||||
|
A completed GATT write only proves transport completion. It does not prove that
|
||||||
|
the K1 joined Wi-Fi. The application polls `7f02`; the observed response frame
|
||||||
|
contains a fixed-width mode slot, an address slot, and a status byte at offset
|
||||||
|
50. The current AP baseline reports mode `WIFI_AP` and address `192.168.56.1`.
|
||||||
|
|
||||||
|
For the controlled experiment, acceptance required at least one of:
|
||||||
|
|
||||||
|
1. `7f02` reports a non-AP IPv4 address;
|
||||||
|
2. the same address appears as a new router/ARP client after the write;
|
||||||
|
3. a statically evidenced K1 application endpoint is reached at that confirmed
|
||||||
|
address.
|
||||||
|
|
||||||
|
Do not infer success from a write callback alone.
|
||||||
|
|
||||||
|
## Safety, recovery and stop conditions
|
||||||
|
|
||||||
|
- Perform one write per explicitly named attempt, using credentials for the LAN
|
||||||
|
already used by the Mac. Never retry automatically.
|
||||||
|
- Do not alter Deco settings, scan the subnet, or guess any credential.
|
||||||
|
- If the status does not change, do not retry automatically.
|
||||||
|
- If the supplied credentials are wrong, reconnect over BLE and overwrite them
|
||||||
|
with corrected credentials using the same reviewed profile.
|
||||||
|
- A single normal power cycle is allowed as a recovery check. It is not claimed
|
||||||
|
to erase stored credentials. Factory reset and firmware actions are prohibited.
|
||||||
|
- Stop on repeated disconnects, an unexpected GATT layout, overheating, an
|
||||||
|
activation prompt, or any device fault indication.
|
||||||
|
|
||||||
|
The exact factory-return command is not known. That limitation is accepted for
|
||||||
|
the first write because BLE remains the documented configuration transport and
|
||||||
|
the operator controls the intended LAN credentials.
|
||||||
|
|
||||||
|
## Live transport attempts
|
||||||
|
|
||||||
|
Attempt 01 mirrored the Android application: one 99-byte write without response,
|
||||||
|
with a live CoreBluetooth maximum of 253 bytes. For 60 seconds afterward, `7f02`
|
||||||
|
remained at the AP baseline and the existing LAN neighbor table did not change.
|
||||||
|
There was no automatic retry.
|
||||||
|
|
||||||
|
Attempt 02 was separately reviewed because `7f01` explicitly advertises ordinary
|
||||||
|
write-with-response to CoreBluetooth. The identical 99-byte payload was sent
|
||||||
|
once using that advertised transport. This tested the transport mismatch only;
|
||||||
|
it did not change credential values or application framing.
|
||||||
|
|
||||||
|
Attempt 02 succeeded immediately. `7f02` reported the operator-selected LAN,
|
||||||
|
status code 1 and a non-AP private IPv4 address. The existing neighbor table then
|
||||||
|
confirmed the same new address on the Mac's Wi-Fi interface. No third
|
||||||
|
provisioning write was made.
|
||||||
|
|
||||||
|
Offline analysis of the owned application and a targeted check against only that
|
||||||
|
confirmed address identified plain MQTT on TCP 1883 as the production
|
||||||
|
application endpoint. Port 8008 belongs to a separate QuickLink/AP WebSocket
|
||||||
|
branch and was correctly refused in the bridge/LAN topology. The successful
|
||||||
|
MQTT result is documented in `05_K1_MQTT_STREAM_PROFILE.md`.
|
||||||
|
|
@ -0,0 +1,164 @@
|
||||||
|
# Verified K1 MQTT stream profile
|
||||||
|
|
||||||
|
Status: verified against one owner-controlled LixelKity K1 running firmware
|
||||||
|
3.0.2. This is a capture and decoder profile, not a claim of vendor support or a
|
||||||
|
stable public API.
|
||||||
|
|
||||||
|
## Transport
|
||||||
|
|
||||||
|
After BLE provisioning, the K1 joins the operator's LAN and exposes:
|
||||||
|
|
||||||
|
| Property | Verified value |
|
||||||
|
| --- | --- |
|
||||||
|
| Transport | TCP |
|
||||||
|
| Port | 1883 |
|
||||||
|
| Application protocol | MQTT 3.1.1 |
|
||||||
|
| TLS | not used by this direct-LAN path |
|
||||||
|
| Username/password | not used by the observed client setup |
|
||||||
|
| K1 address | DHCP/private IPv4, retained only in the ignored session |
|
||||||
|
|
||||||
|
The direct route for the confirmed K1 address used the Mac Wi-Fi interface even
|
||||||
|
while the Mac's default route was a VPN tunnel. Commands must resolve the route
|
||||||
|
for the confirmed K1 address and must not scan the surrounding subnet.
|
||||||
|
|
||||||
|
The repository's capture client is subscribe-only. It sends the minimum MQTT
|
||||||
|
CONNECT, SUBSCRIBE, keepalive and DISCONNECT protocol packets; it has no reason
|
||||||
|
to publish an application command.
|
||||||
|
|
||||||
|
## Verified report topics
|
||||||
|
|
||||||
|
The firmware-3 stream set is:
|
||||||
|
|
||||||
|
```text
|
||||||
|
lixel/application/report/heartbeat
|
||||||
|
lixel/application/report/device_status
|
||||||
|
lixel/application/report/lio_pcl
|
||||||
|
lixel/application/report/lio_pose
|
||||||
|
lixel/application/report/modeling
|
||||||
|
```
|
||||||
|
|
||||||
|
Additional report topics exist for system errors, modeling status, PGO,
|
||||||
|
control points and upgrades. The safe discovery subscription is restricted to
|
||||||
|
`lixel/application/report/#`. It does not subscribe to request topics.
|
||||||
|
|
||||||
|
The application also retains compatibility subscribers for:
|
||||||
|
|
||||||
|
```text
|
||||||
|
RealtimePointcloud
|
||||||
|
RealtimePath
|
||||||
|
DeviceStatus
|
||||||
|
```
|
||||||
|
|
||||||
|
No legacy stream was emitted by the tested firmware during the live run.
|
||||||
|
|
||||||
|
## Firmware-3 point cloud
|
||||||
|
|
||||||
|
`lixel/application/report/lio_pcl` contains a protobuf compression envelope:
|
||||||
|
|
||||||
|
```proto
|
||||||
|
message MqttCompressMsg {
|
||||||
|
Header header = 1;
|
||||||
|
CompressionType compression = 2; // 0=LZ4, 1=Zstd
|
||||||
|
uint32 compressed_size = 3; // expected decoded byte count
|
||||||
|
bytes compressed_data = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The verified value is compression enum 0. `compressed_data` is a raw LZ4 block,
|
||||||
|
not an LZ4 frame. The decoder must supply `compressed_size`, enforce the exact
|
||||||
|
decoded length and reject enum 1 until a bounded Zstd path is reviewed.
|
||||||
|
|
||||||
|
The decoded protobuf is:
|
||||||
|
|
||||||
|
```proto
|
||||||
|
message LioPclReport {
|
||||||
|
Header header = 1;
|
||||||
|
repeated Point points = 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
message Point {
|
||||||
|
sint64 x = 1;
|
||||||
|
sint64 y = 2;
|
||||||
|
sint64 z = 3;
|
||||||
|
uint32 rgbi = 4;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Coordinates are ZigZag `sint64` values. Sensor-native metric coordinates are
|
||||||
|
`(x/header.scaler, y/header.scaler, z/header.scaler)`; a zero scaler is invalid.
|
||||||
|
The tested frames consistently used scaler 1000.
|
||||||
|
|
||||||
|
The application uses only `rgbi & 0xff` as intensity/alpha. The connector keeps
|
||||||
|
the complete `uint32`; interpreting the upper 24 bits as RGB remains unverified.
|
||||||
|
Raw coordinates are preserved. Any Y/Z swap belongs in a derived visualization,
|
||||||
|
not in capture storage.
|
||||||
|
|
||||||
|
## Firmware-3 pose
|
||||||
|
|
||||||
|
`lixel/application/report/lio_pose` is an uncompressed protobuf:
|
||||||
|
|
||||||
|
```proto
|
||||||
|
message LioPoseReport {
|
||||||
|
Header header = 1;
|
||||||
|
PoseStamped pose = 2;
|
||||||
|
float distance = 3;
|
||||||
|
float pose_accuracy = 4;
|
||||||
|
}
|
||||||
|
|
||||||
|
message PoseStamped { sint64 stamp = 1; Pose pose = 2; }
|
||||||
|
message Pose { Point position = 1; Quaternion orientation = 2; }
|
||||||
|
message Point { double x = 1; double y = 2; double z = 3; }
|
||||||
|
message Quaternion { double x = 1; double y = 2; double z = 3; double w = 4; }
|
||||||
|
```
|
||||||
|
|
||||||
|
Storage keeps position `(x,y,z)` and quaternion `(x,y,z,w)` exactly as received.
|
||||||
|
Non-finite values are rejected. Quaternion normalization, coordinate transforms
|
||||||
|
and sensor-to-vehicle extrinsics are derived operations and must not overwrite
|
||||||
|
raw values.
|
||||||
|
|
||||||
|
## Legacy formats
|
||||||
|
|
||||||
|
`RealtimePointcloud` starts with a 12-byte envelope. Little-endian uint32 at
|
||||||
|
offset 0 is the point stride. Records contain float32 `x,y,z` at offsets 0,4,8,
|
||||||
|
RGB bytes at 12,13,14 and, when stride is at least 16, intensity at 15. Envelope
|
||||||
|
bytes 4 through 11 remain uninterpreted and are retained.
|
||||||
|
|
||||||
|
`RealtimePath` contains float32 position `x,y,z` at offsets 0,4,8; offset 12 is
|
||||||
|
unknown; quaternion wire order is `w,x,y,z` at offsets 16,20,24,28. The decoded
|
||||||
|
orientation is exposed as `(x,y,z,w)`. Unknown tail fields are retained.
|
||||||
|
|
||||||
|
`PrePathArray` is exactly 16 little-endian float64 values.
|
||||||
|
|
||||||
|
## Application control boundary
|
||||||
|
|
||||||
|
Static analysis identified the application start/stop topic as
|
||||||
|
`lixel/application/request/modeling`, QoS 2, with a protobuf
|
||||||
|
`ModelingRequest`. Start action is 1 and stop action is 2. A valid request also
|
||||||
|
contains a device/session/OpenAPI header and, for start, project/record/scan/mount
|
||||||
|
settings.
|
||||||
|
|
||||||
|
Publishing is deliberately not implemented. The physical double-click provides
|
||||||
|
a verified autonomous start/stop path and avoids inventing session headers or
|
||||||
|
changing scan settings. A future publisher requires a separate reviewed profile,
|
||||||
|
explicit confirmation, response handling and rollback.
|
||||||
|
|
||||||
|
## Decoder and capture bounds
|
||||||
|
|
||||||
|
Default defensive limits are applied before allocation or iteration:
|
||||||
|
|
||||||
|
- 2 MiB maximum MQTT payload;
|
||||||
|
- 1 MiB maximum compressed block;
|
||||||
|
- 8 MiB maximum decoded block;
|
||||||
|
- 64:1 maximum claimed expansion ratio;
|
||||||
|
- 250,000 points per frame;
|
||||||
|
- bounded protobuf field counts and exact LZ4 decoded length.
|
||||||
|
|
||||||
|
Per-frame failures do not justify firmware writes or speculative recovery.
|
||||||
|
Always preserve the raw MQTT record and report the decoder error separately.
|
||||||
|
|
||||||
|
## Current camera result
|
||||||
|
|
||||||
|
No independent camera, panorama, JPEG, H.264 or RTSP topic was observed in this
|
||||||
|
run. `lio_pcl` may carry intensity or packed color information, but that does not
|
||||||
|
prove access to the two raw panoramic camera streams. Camera discovery remains a
|
||||||
|
separate evidence gate.
|
||||||
|
|
@ -0,0 +1,114 @@
|
||||||
|
{
|
||||||
|
"schema_version": 1,
|
||||||
|
"session_id": "20260715T122850Z_live_power_cycle",
|
||||||
|
"experiment": "BLE provisioning and physical-button MQTT stream capture",
|
||||||
|
"tool": {
|
||||||
|
"version": "0.1.0",
|
||||||
|
"git_commit": "faa442e+working-tree-lab-tooling"
|
||||||
|
},
|
||||||
|
"host": {
|
||||||
|
"macos_version": "26.5.1",
|
||||||
|
"machine": "arm64",
|
||||||
|
"python_version": "3.12.13",
|
||||||
|
"wifi_interface": "en0",
|
||||||
|
"vpn_active": true
|
||||||
|
},
|
||||||
|
"device": {
|
||||||
|
"model": "XGRIDS/LixelKity K1",
|
||||||
|
"serial_suffix": null,
|
||||||
|
"activation_state": "active",
|
||||||
|
"firmware": "3.0.2"
|
||||||
|
},
|
||||||
|
"network": {
|
||||||
|
"topology": "current_lan",
|
||||||
|
"k1_address_redacted": "RFC1918 private IPv4"
|
||||||
|
},
|
||||||
|
"timeline": [
|
||||||
|
{
|
||||||
|
"event": "MQTT standby baseline begins",
|
||||||
|
"monotonic_seconds": 0.0,
|
||||||
|
"utc": "2026-07-15T14:05:15.186Z",
|
||||||
|
"note": "heartbeat and device_status only"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "first live pose report",
|
||||||
|
"monotonic_seconds": 57.778,
|
||||||
|
"utc": "2026-07-15T14:06:12.964Z",
|
||||||
|
"note": "physical double-click scan start"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "first live point-cloud report",
|
||||||
|
"monotonic_seconds": 57.795,
|
||||||
|
"utc": "2026-07-15T14:06:12.981Z",
|
||||||
|
"note": "firmware-3 lio_pcl topic"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "full-payload capture window ends",
|
||||||
|
"monotonic_seconds": 179.166,
|
||||||
|
"utc": "2026-07-15T14:08:14.352Z",
|
||||||
|
"note": "180-second subscriber timeout"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "post-stop negative control begins",
|
||||||
|
"monotonic_seconds": 369.254,
|
||||||
|
"utc": "2026-07-15T14:11:24.440Z",
|
||||||
|
"note": "solid-green standby; no lio_pcl/lio_pose/modeling reports"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"event": "repository-native standby smoke capture",
|
||||||
|
"monotonic_seconds": 2443.564,
|
||||||
|
"utc": "2026-07-15T14:45:58.750Z",
|
||||||
|
"note": "5-second fixed-topic subscriber; 11 heartbeat/status frames"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"artifacts": [
|
||||||
|
{
|
||||||
|
"path": "reference/LG_i18n_125_20250820.apk",
|
||||||
|
"size_bytes": 118234630,
|
||||||
|
"sha256": "c83736f782981282b933cd4737c4f9fa46cbd7f651660c1a9057580f7312361f",
|
||||||
|
"classification": "sensitive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "captures/wifi_provisioning_attempt_01.json",
|
||||||
|
"size_bytes": 1258,
|
||||||
|
"sha256": "cc73705c185315286f4009b6619f82a7953b38528a919f7fd2b18ecd788c7637",
|
||||||
|
"classification": "sensitive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "captures/wifi_provisioning_attempt_02.json",
|
||||||
|
"size_bytes": 1247,
|
||||||
|
"sha256": "4acf432dd8797802a0033efb7c89f3c11827f681bbf152249c02cccb41d9f873",
|
||||||
|
"classification": "sensitive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "captures/mqtt_scan_full_payloads_03.tsv",
|
||||||
|
"size_bytes": 79937918,
|
||||||
|
"sha256": "6b2a4a66d24a89ac048a2be11c5ab45923ee60d4ef07ad3ed3fa3cc508bf8500",
|
||||||
|
"classification": "sensitive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "captures/mqtt_post_stop_metadata_04.tsv",
|
||||||
|
"size_bytes": 1488,
|
||||||
|
"sha256": "677835db8175ab6001bec6ecc703413c07795062bb9b4e86c187c77c3e6c73eb",
|
||||||
|
"classification": "sensitive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "captures/native_mqtt_standby_05/mqtt.raw.k1mqtt",
|
||||||
|
"size_bytes": 1924,
|
||||||
|
"sha256": "1ff2984d7f305e41f80c13b820405b9b18c8c79c15efe698d51a02fffa22ca82",
|
||||||
|
"classification": "sensitive"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "analysis/native_mqtt_standby_05.summary.json",
|
||||||
|
"size_bytes": 1481,
|
||||||
|
"sha256": "da72847c9454bd69915c65d49101cb93261d034ec1c1db329ead0b20a6dcbb0e",
|
||||||
|
"classification": "sensitive"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"decision": {
|
||||||
|
"status": "GO",
|
||||||
|
"gate": "Stage 5 point-cloud and pose decoding",
|
||||||
|
"reason": "All captured firmware-3 point-cloud and pose frames decoded and correlated with controlled physical motion.",
|
||||||
|
"next_smallest_experiment": "Longer repository-native capture on verified power bank; keep camera discovery separate."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,117 @@
|
||||||
|
# K1 Lab Report 001 — redacted
|
||||||
|
|
||||||
|
Date: 2026-07-15
|
||||||
|
|
||||||
|
Decision: **GO** for BLE provisioning, LAN association, MQTT capture, firmware-3
|
||||||
|
point-cloud decoding and live pose decoding. Camera stream remains unproven.
|
||||||
|
|
||||||
|
## Stand
|
||||||
|
|
||||||
|
- owner-controlled XGRIDS/LixelKity K1;
|
||||||
|
- observed firmware 3.0.2;
|
||||||
|
- Apple Silicon MacBook;
|
||||||
|
- existing TP-Link mesh LAN;
|
||||||
|
- no LixelGO, phone, SDK, router reconfiguration or firmware change;
|
||||||
|
- device identity, SSID, password, IP and MAC retained only in ignored artifacts.
|
||||||
|
|
||||||
|
## Physical result
|
||||||
|
|
||||||
|
- power-on: slow blue to solid green standby;
|
||||||
|
- autonomous scan start: physical double-click from standby;
|
||||||
|
- start transition: fast green, LiDAR rotation, then scanning green;
|
||||||
|
- initialization: device held still for at least 20 seconds;
|
||||||
|
- controlled movement: approximately one metre plus a turn;
|
||||||
|
- stop: physical double-click, fast green while saving, LiDAR stopped, solid green;
|
||||||
|
- single-click is not the scan-start action;
|
||||||
|
- a depleted battery caused one clean power loss; the battery was replaced before
|
||||||
|
the successful run;
|
||||||
|
- later transfer from battery/USB connection to a verified power bank did not
|
||||||
|
reset the device or change the standby LED.
|
||||||
|
|
||||||
|
## BLE and Wi-Fi result
|
||||||
|
|
||||||
|
The reviewed 99-byte firmware-3 provisioning frame was tested twice:
|
||||||
|
|
||||||
|
1. write without response, mirroring the Android application: transport
|
||||||
|
completed but K1 remained in AP mode;
|
||||||
|
2. write with response, matching the live CoreBluetooth property: immediate
|
||||||
|
success, status code 1 and a LAN IPv4 address.
|
||||||
|
|
||||||
|
There was no automatic retry and no third provisioning write. The password was
|
||||||
|
entered in a hidden local macOS dialog and never appeared in arguments, logs,
|
||||||
|
artifacts or Git.
|
||||||
|
|
||||||
|
## MQTT result
|
||||||
|
|
||||||
|
Targeted connection to the confirmed K1 address accepted plain MQTT 3.1.1 on
|
||||||
|
TCP 1883 with no username/password. A subscribe-only client received standby
|
||||||
|
heartbeat and status at approximately 1 Hz.
|
||||||
|
|
||||||
|
The 180-second scan capture contained 2,836 valid records and no framing/declared
|
||||||
|
length errors:
|
||||||
|
|
||||||
|
| Topic | Messages | MQTT payload bytes |
|
||||||
|
| --- | ---: | ---: |
|
||||||
|
| `device_status` | 180 | 31,830 |
|
||||||
|
| `heartbeat` | 180 | 17,997 |
|
||||||
|
| `lio_pcl` | 1,140 | 39,617,777 |
|
||||||
|
| `lio_pose` | 1,215 | 204,370 |
|
||||||
|
| `modeling` | 121 | 10,867 |
|
||||||
|
|
||||||
|
After physical stop, a separate 12-second negative-control subscription received
|
||||||
|
only 12 heartbeat and 12 status messages. It received no `lio_pcl`, `lio_pose` or
|
||||||
|
`modeling` reports.
|
||||||
|
|
||||||
|
After implementation, the repository-native `k1link net mqtt-capture` command
|
||||||
|
was smoke-tested for 5 seconds in standby. It completed a fixed allowlisted
|
||||||
|
subscription, saved 11 messages in the length-framed `.k1mqtt` format and exited
|
||||||
|
on duration. `k1link analyze mqtt-streams` then verified the raw capture hash and
|
||||||
|
all frame boundaries; as expected for standby, it found no PCL or pose frames.
|
||||||
|
|
||||||
|
## Decoder result
|
||||||
|
|
||||||
|
All 1,140 point-cloud MQTT payloads decoded successfully:
|
||||||
|
|
||||||
|
- compression: raw LZ4 block, enum 0 in every frame;
|
||||||
|
- decoded protobuf size: 37,083 through 55,111 bytes per frame;
|
||||||
|
- scaler: 1000 in every frame;
|
||||||
|
- 2,703 through 4,065 points per frame;
|
||||||
|
- total decoded points: 4,165,862;
|
||||||
|
- decode failures: 0.
|
||||||
|
|
||||||
|
All 1,215 pose payloads decoded successfully:
|
||||||
|
|
||||||
|
- protobuf position and quaternion values were finite;
|
||||||
|
- first-to-last pose displacement was approximately 1.566 m, consistent with
|
||||||
|
the operator's controlled movement;
|
||||||
|
- reported pose accuracy was approximately 0.001 throughout;
|
||||||
|
- decode failures: 0.
|
||||||
|
|
||||||
|
Scene bounds, trajectory coordinates and raw payloads remain private and ignored.
|
||||||
|
|
||||||
|
## Artifact integrity
|
||||||
|
|
||||||
|
The raw files are under the ignored session
|
||||||
|
`20260715T122850Z_live_power_cycle`. Only redacted facts and hashes are committed.
|
||||||
|
|
||||||
|
| Artifact | SHA-256 |
|
||||||
|
| --- | --- |
|
||||||
|
| owned reference APK | `c83736f782981282b933cd4737c4f9fa46cbd7f651660c1a9057580f7312361f` |
|
||||||
|
| provisioning attempt 01 | `cc73705c185315286f4009b6619f82a7953b38528a919f7fd2b18ecd788c7637` |
|
||||||
|
| provisioning attempt 02 | `4acf432dd8797802a0033efb7c89f3c11827f681bbf152249c02cccb41d9f873` |
|
||||||
|
| full MQTT scan capture | `6b2a4a66d24a89ac048a2be11c5ab45923ee60d4ef07ad3ed3fa3cc508bf8500` |
|
||||||
|
| post-stop metadata control | `677835db8175ab6001bec6ecc703413c07795062bb9b4e86c187c77c3e6c73eb` |
|
||||||
|
| native standby `.k1mqtt` smoke capture | `1ff2984d7f305e41f80c13b820405b9b18c8c79c15efe698d51a02fffa22ca82` |
|
||||||
|
| native aggregate analysis | `da72847c9454bd69915c65d49101cb93261d034ec1c1db329ead0b20a6dcbb0e` |
|
||||||
|
|
||||||
|
## Remaining gates
|
||||||
|
|
||||||
|
1. Integrate the bounded stream decoder with the repository-native binary MQTT
|
||||||
|
capture format.
|
||||||
|
2. Run a longer power-bank capture and measure packet loss, CPU, disk and thermal
|
||||||
|
behavior.
|
||||||
|
3. Establish K1-to-vehicle/airframe extrinsics and time synchronization before
|
||||||
|
using pose on a robot or UAV.
|
||||||
|
4. Determine whether usable RGB is packed into `rgbi` or delivered elsewhere.
|
||||||
|
5. Treat raw panoramic camera access as unproven until a scan-correlated stream
|
||||||
|
or endpoint is observed.
|
||||||
|
|
@ -12,6 +12,8 @@ license = { text = "Proprietary" }
|
||||||
authors = [{ name = "NODE.DC" }]
|
authors = [{ name = "NODE.DC" }]
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bleak==3.0.2",
|
"bleak==3.0.2",
|
||||||
|
"lz4>=4.4,<5",
|
||||||
|
"paho-mqtt>=2.1,<3",
|
||||||
"rich>=13.9,<15",
|
"rich>=13.9,<15",
|
||||||
"typer>=0.15,<1",
|
"typer>=0.15,<1",
|
||||||
]
|
]
|
||||||
|
|
@ -45,3 +47,6 @@ python_version = "3.12"
|
||||||
strict = true
|
strict = true
|
||||||
packages = ["k1link"]
|
packages = ["k1link"]
|
||||||
|
|
||||||
|
[[tool.mypy.overrides]]
|
||||||
|
module = ["lz4", "lz4.*"]
|
||||||
|
ignore_missing_imports = true
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
@ -15,10 +15,28 @@ from rich.console import Console
|
||||||
from rich.table import Table
|
from rich.table import Table
|
||||||
|
|
||||||
from k1link import __version__
|
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.artifacts import write_json_atomic
|
||||||
from k1link.ble.gatt import dump_metadata
|
from k1link.ble.gatt import dump_metadata
|
||||||
from k1link.ble.scanner import scan
|
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.net.snapshot import snapshot
|
||||||
|
from k1link.usb.snapshot import snapshot as usb_snapshot
|
||||||
|
|
||||||
app = typer.Typer(
|
app = typer.Typer(
|
||||||
name="k1link",
|
name="k1link",
|
||||||
|
|
@ -27,9 +45,13 @@ app = typer.Typer(
|
||||||
)
|
)
|
||||||
console = Console()
|
console = Console()
|
||||||
ble_app = typer.Typer(help="Bluetooth LE discovery and metadata commands.", no_args_is_help=True)
|
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(ble_app, name="ble")
|
||||||
app.add_typer(net_app, name="net")
|
app.add_typer(net_app, name="net")
|
||||||
|
app.add_typer(usb_app, name="usb")
|
||||||
|
app.add_typer(analyze_app, name="analyze")
|
||||||
|
|
||||||
|
|
||||||
class ToolStatus(TypedDict):
|
class ToolStatus(TypedDict):
|
||||||
|
|
@ -245,6 +267,86 @@ def ble_gatt_dump(
|
||||||
console.print(f"Saved: {out}")
|
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")
|
@net_app.command("snapshot")
|
||||||
def net_snapshot(
|
def net_snapshot(
|
||||||
out: Annotated[
|
out: Annotated[
|
||||||
|
|
@ -259,5 +361,155 @@ def net_snapshot(
|
||||||
console.print(f"Saved: {out}")
|
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__":
|
if __name__ == "__main__":
|
||||||
app()
|
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,
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,6 @@
|
||||||
import json
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
from typer.testing import CliRunner
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
|
@ -21,3 +23,68 @@ def test_doctor_json() -> None:
|
||||||
assert isinstance(payload["tools"], list)
|
assert isinstance(payload["tools"], list)
|
||||||
assert isinstance(payload["network"], dict)
|
assert isinstance(payload["network"], dict)
|
||||||
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
assert any(item["name"] == "tcpdump" for item in payload["tools"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_capture_requires_owned_device_confirmation(tmp_path: Path) -> None:
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"net",
|
||||||
|
"mqtt-capture",
|
||||||
|
"--host",
|
||||||
|
"192.168.1.20",
|
||||||
|
"--out",
|
||||||
|
str(tmp_path / "capture"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
assert result.exit_code == 2
|
||||||
|
assert "ownership not confirmed" in result.stdout
|
||||||
|
assert not (tmp_path / "capture").exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_capture_cli_uses_bounded_read_only_capture(
|
||||||
|
monkeypatch: Any,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
captured: dict[str, object] = {}
|
||||||
|
|
||||||
|
def fake_capture(host: str, out: Path, **kwargs: object) -> dict[str, object]:
|
||||||
|
on_ready = kwargs.pop("on_ready")
|
||||||
|
assert callable(on_ready)
|
||||||
|
on_ready()
|
||||||
|
captured.update({"host": host, "out": out, **kwargs})
|
||||||
|
return {
|
||||||
|
"stop_reason": "duration_elapsed",
|
||||||
|
"message_count": 2,
|
||||||
|
"payload_bytes": 128,
|
||||||
|
}
|
||||||
|
|
||||||
|
monkeypatch.setattr("k1link.cli.capture_mqtt", fake_capture)
|
||||||
|
out = tmp_path / "capture"
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"net",
|
||||||
|
"mqtt-capture",
|
||||||
|
"--host",
|
||||||
|
"10.0.0.42",
|
||||||
|
"--out",
|
||||||
|
str(out),
|
||||||
|
"--duration",
|
||||||
|
"5",
|
||||||
|
"--max-message-bytes",
|
||||||
|
"1024",
|
||||||
|
"--confirm-owned-device",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert captured == {
|
||||||
|
"host": "10.0.0.42",
|
||||||
|
"out": out,
|
||||||
|
"port": 1883,
|
||||||
|
"duration_seconds": 5.0,
|
||||||
|
"max_message_bytes": 1024,
|
||||||
|
}
|
||||||
|
assert "messages: 2" in result.stdout
|
||||||
|
assert "subscriptions active" in result.stdout
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,35 @@
|
||||||
|
from unittest.mock import Mock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.macos_credentials import CredentialDialogError, _dialog_text
|
||||||
|
|
||||||
|
|
||||||
|
@patch("k1link.macos_credentials.platform.system", return_value="Darwin")
|
||||||
|
@patch("k1link.macos_credentials.shutil.which", return_value="/usr/bin/osascript")
|
||||||
|
@patch("k1link.macos_credentials.subprocess.run")
|
||||||
|
def test_dialog_returns_value_without_printing(
|
||||||
|
run: Mock,
|
||||||
|
_which: Mock,
|
||||||
|
_system: Mock,
|
||||||
|
) -> None:
|
||||||
|
run.return_value = Mock(returncode=0, stdout="local-value\n", stderr="")
|
||||||
|
|
||||||
|
assert _dialog_text("Prompt", hidden=True) == "local-value"
|
||||||
|
command = run.call_args.args[0]
|
||||||
|
assert "local-value" not in command
|
||||||
|
assert "with hidden answer" in command[-1]
|
||||||
|
|
||||||
|
|
||||||
|
@patch("k1link.macos_credentials.platform.system", return_value="Darwin")
|
||||||
|
@patch("k1link.macos_credentials.shutil.which", return_value="/usr/bin/osascript")
|
||||||
|
@patch("k1link.macos_credentials.subprocess.run")
|
||||||
|
def test_dialog_cancel_is_not_echoed(
|
||||||
|
run: Mock,
|
||||||
|
_which: Mock,
|
||||||
|
_system: Mock,
|
||||||
|
) -> None:
|
||||||
|
run.return_value = Mock(returncode=1, stdout="", stderr="User canceled.")
|
||||||
|
|
||||||
|
with pytest.raises(CredentialDialogError, match="cancelled"):
|
||||||
|
_dialog_text("Prompt", hidden=False)
|
||||||
|
|
@ -0,0 +1,209 @@
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import stat
|
||||||
|
from collections.abc import Callable
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, cast
|
||||||
|
|
||||||
|
import paho.mqtt.client as mqtt
|
||||||
|
import pytest
|
||||||
|
from paho.mqtt.packettypes import PacketTypes
|
||||||
|
from paho.mqtt.reasoncodes import ReasonCode
|
||||||
|
|
||||||
|
from k1link.mqtt.capture import (
|
||||||
|
FRAME_HEADER,
|
||||||
|
RAW_MAGIC,
|
||||||
|
REPORT_TOPICS,
|
||||||
|
CaptureError,
|
||||||
|
CaptureFormatError,
|
||||||
|
capture_mqtt,
|
||||||
|
iter_capture_frames,
|
||||||
|
validate_private_ipv4,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeClient:
|
||||||
|
def __init__(self, *, topic: str = "RealtimePath", payload: bytes = b"pose-data") -> None:
|
||||||
|
self.on_connect: Callable[..., None] | None = None
|
||||||
|
self.on_subscribe: Callable[..., None] | None = None
|
||||||
|
self.on_message: Callable[..., None] | None = None
|
||||||
|
self.on_disconnect: Callable[..., None] | None = None
|
||||||
|
self.topic = topic
|
||||||
|
self.payload = payload
|
||||||
|
self.connect_calls: list[tuple[str, int, int]] = []
|
||||||
|
self.subscribe_calls: list[Any] = []
|
||||||
|
self.disconnect_count = 0
|
||||||
|
self._step = 0
|
||||||
|
|
||||||
|
def connect(self, host: str, port: int, keepalive: int) -> mqtt.MQTTErrorCode:
|
||||||
|
self.connect_calls.append((host, port, keepalive))
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
def subscribe(self, topics: Any) -> tuple[mqtt.MQTTErrorCode, int]:
|
||||||
|
self.subscribe_calls.append(topics)
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS, 7
|
||||||
|
|
||||||
|
def loop(self, timeout: float) -> mqtt.MQTTErrorCode:
|
||||||
|
assert timeout > 0
|
||||||
|
self._step += 1
|
||||||
|
if self._step == 1:
|
||||||
|
assert self.on_connect is not None
|
||||||
|
self.on_connect(
|
||||||
|
self,
|
||||||
|
None,
|
||||||
|
mqtt.ConnectFlags(session_present=False),
|
||||||
|
ReasonCode(PacketTypes.CONNACK, "Success"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
elif self._step == 2:
|
||||||
|
assert self.on_subscribe is not None
|
||||||
|
self.on_subscribe(
|
||||||
|
self,
|
||||||
|
None,
|
||||||
|
7,
|
||||||
|
[ReasonCode(PacketTypes.SUBACK, identifier=0) for _ in REPORT_TOPICS],
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
elif self._step == 3:
|
||||||
|
assert self.on_message is not None
|
||||||
|
message = mqtt.MQTTMessage(topic=self.topic.encode())
|
||||||
|
message.payload = self.payload
|
||||||
|
message.qos = 0
|
||||||
|
self.on_message(self, None, message)
|
||||||
|
else:
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
def disconnect(self) -> mqtt.MQTTErrorCode:
|
||||||
|
self.disconnect_count += 1
|
||||||
|
return mqtt.MQTT_ERR_SUCCESS
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("address", ["10.0.0.1", "172.16.0.1", "172.31.255.254", "192.168.4.2"])
|
||||||
|
def test_validate_private_ipv4_accepts_only_rfc1918(address: str) -> None:
|
||||||
|
assert validate_private_ipv4(address) == address
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
"address",
|
||||||
|
["8.8.8.8", "127.0.0.1", "169.254.1.2", "::1", "k1.local", " 192.168.1.2"],
|
||||||
|
)
|
||||||
|
def test_validate_private_ipv4_rejects_other_targets(address: str) -> None:
|
||||||
|
with pytest.raises(ValueError, match="private IPv4|RFC1918"):
|
||||||
|
validate_private_ipv4(address)
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_writes_verifiable_frames_metadata_and_summary(tmp_path: Path) -> None:
|
||||||
|
fake = FakeClient()
|
||||||
|
|
||||||
|
summary = capture_mqtt(
|
||||||
|
"192.168.1.50",
|
||||||
|
tmp_path / "capture",
|
||||||
|
duration_seconds=30,
|
||||||
|
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert fake.connect_calls == [("192.168.1.50", 1883, 30)]
|
||||||
|
assert fake.subscribe_calls == [[(topic, 0) for topic in REPORT_TOPICS]]
|
||||||
|
assert fake.disconnect_count == 1
|
||||||
|
assert summary["stop_reason"] == "keyboard_interrupt"
|
||||||
|
assert summary["message_count"] == 1
|
||||||
|
assert summary["payload_bytes"] == len(fake.payload)
|
||||||
|
assert summary["subscriptions"] == list(REPORT_TOPICS)
|
||||||
|
|
||||||
|
capture_dir = tmp_path / "capture"
|
||||||
|
raw = (capture_dir / "mqtt.raw.k1mqtt").read_bytes()
|
||||||
|
assert raw.startswith(RAW_MAGIC)
|
||||||
|
topic_length, payload_length = FRAME_HEADER.unpack_from(raw, len(RAW_MAGIC))
|
||||||
|
topic_start = len(RAW_MAGIC) + FRAME_HEADER.size
|
||||||
|
payload_start = topic_start + topic_length
|
||||||
|
assert raw[topic_start:payload_start].decode() == fake.topic
|
||||||
|
assert payload_length == len(fake.payload)
|
||||||
|
assert raw[payload_start:] == fake.payload
|
||||||
|
|
||||||
|
records: list[dict[str, object]] = [
|
||||||
|
json.loads(line)
|
||||||
|
for line in (capture_dir / "mqtt.metadata.jsonl").read_text().splitlines()
|
||||||
|
]
|
||||||
|
assert len(records) == 1
|
||||||
|
record = records[0]
|
||||||
|
assert record["record_type"] == "message"
|
||||||
|
assert record["topic"] == fake.topic
|
||||||
|
assert record["payload_bytes"] == len(fake.payload)
|
||||||
|
assert record["payload_sha256"] == hashlib.sha256(fake.payload).hexdigest()
|
||||||
|
assert record["raw_frame_offset"] == len(RAW_MAGIC)
|
||||||
|
assert record["raw_payload_offset"] == payload_start
|
||||||
|
|
||||||
|
frames = list(iter_capture_frames(capture_dir / "mqtt.raw.k1mqtt"))
|
||||||
|
assert len(frames) == 1
|
||||||
|
assert frames[0].sequence == 1
|
||||||
|
assert frames[0].topic == fake.topic
|
||||||
|
assert frames[0].payload == fake.payload
|
||||||
|
assert frames[0].raw_frame_offset == len(RAW_MAGIC)
|
||||||
|
assert frames[0].raw_payload_offset == payload_start
|
||||||
|
|
||||||
|
saved_summary = json.loads((capture_dir / "mqtt.summary.json").read_text())
|
||||||
|
assert saved_summary == summary
|
||||||
|
assert saved_summary["artifact_hashes"]["raw_sha256"] == hashlib.sha256(raw).hexdigest()
|
||||||
|
for artifact_name in ("mqtt.raw.k1mqtt", "mqtt.metadata.jsonl", "mqtt.summary.json"):
|
||||||
|
assert stat.S_IMODE((capture_dir / artifact_name).stat().st_mode) == 0o600
|
||||||
|
|
||||||
|
|
||||||
|
def test_oversize_payload_is_not_written_to_raw_capture(tmp_path: Path) -> None:
|
||||||
|
fake = FakeClient(payload=b"oversize")
|
||||||
|
capture_dir = tmp_path / "capture"
|
||||||
|
|
||||||
|
with pytest.raises(CaptureError, match="limit is 4 bytes") as error:
|
||||||
|
capture_mqtt(
|
||||||
|
"10.1.2.3",
|
||||||
|
capture_dir,
|
||||||
|
max_message_bytes=4,
|
||||||
|
_client_factory=lambda: cast(mqtt.Client, fake),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (capture_dir / "mqtt.raw.k1mqtt").read_bytes() == RAW_MAGIC
|
||||||
|
record = json.loads((capture_dir / "mqtt.metadata.jsonl").read_text())
|
||||||
|
assert record["record_type"] == "rejected_message"
|
||||||
|
assert record["payload_bytes"] == len(fake.payload)
|
||||||
|
assert error.value.summary is not None
|
||||||
|
assert error.value.summary["stop_reason"] == "message_too_large"
|
||||||
|
assert error.value.summary["message_count"] == 0
|
||||||
|
assert error.value.summary["rejected_message_count"] == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_capture_refuses_to_overwrite_existing_artifacts(tmp_path: Path) -> None:
|
||||||
|
capture_dir = tmp_path / "capture"
|
||||||
|
capture_dir.mkdir()
|
||||||
|
raw = capture_dir / "mqtt.raw.k1mqtt"
|
||||||
|
raw.write_bytes(b"existing evidence")
|
||||||
|
|
||||||
|
with pytest.raises(FileExistsError, match="refusing to overwrite"):
|
||||||
|
capture_mqtt(
|
||||||
|
"192.168.1.2",
|
||||||
|
capture_dir,
|
||||||
|
_client_factory=lambda: cast(mqtt.Client, FakeClient()),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert raw.read_bytes() == b"existing evidence"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw", "message"),
|
||||||
|
[
|
||||||
|
(b"not-mqtt", "magic/version"),
|
||||||
|
(RAW_MAGIC + b"\x00", "truncated length header"),
|
||||||
|
(RAW_MAGIC + FRAME_HEADER.pack(4, 1) + b"ab", "topic .* is truncated"),
|
||||||
|
(RAW_MAGIC + FRAME_HEADER.pack(1, 4) + b"t" + b"ab", "payload .* is truncated"),
|
||||||
|
(RAW_MAGIC + FRAME_HEADER.pack(1, 5) + b"t" + b"abcde", "exceeds 4"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_capture_reader_rejects_invalid_or_unbounded_frames(
|
||||||
|
tmp_path: Path,
|
||||||
|
raw: bytes,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
path = tmp_path / "capture.raw"
|
||||||
|
path.write_bytes(raw)
|
||||||
|
|
||||||
|
with pytest.raises(CaptureFormatError, match=message):
|
||||||
|
list(iter_capture_frames(path, max_payload_bytes=4))
|
||||||
|
|
@ -0,0 +1,165 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import math
|
||||||
|
import struct
|
||||||
|
|
||||||
|
import lz4.block
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.protocol.protobuf_wire import ProtobufWireError, decode_zigzag64, iter_fields
|
||||||
|
from k1link.protocol.streams import (
|
||||||
|
DecodeLimits,
|
||||||
|
StreamDecodeError,
|
||||||
|
UnsupportedCompressionError,
|
||||||
|
decode_legacy_pointcloud,
|
||||||
|
decode_legacy_pose,
|
||||||
|
decode_lio_pcl,
|
||||||
|
decode_lio_pose,
|
||||||
|
decode_pre_path_array,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _varint(value: int) -> bytes:
|
||||||
|
encoded = bytearray()
|
||||||
|
while value > 0x7F:
|
||||||
|
encoded.append((value & 0x7F) | 0x80)
|
||||||
|
value >>= 7
|
||||||
|
encoded.append(value)
|
||||||
|
return bytes(encoded)
|
||||||
|
|
||||||
|
|
||||||
|
def _key(number: int, wire_type: int) -> bytes:
|
||||||
|
return _varint((number << 3) | wire_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _uint(number: int, value: int) -> bytes:
|
||||||
|
return _key(number, 0) + _varint(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _sint(number: int, value: int) -> bytes:
|
||||||
|
zigzag = (value << 1) ^ (value >> 63)
|
||||||
|
return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes(number: int, value: bytes) -> bytes:
|
||||||
|
return _key(number, 2) + _varint(len(value)) + value
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed32(number: int, value: float) -> bytes:
|
||||||
|
return _key(number, 5) + struct.pack("<f", value)
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed64(number: int, value: float) -> bytes:
|
||||||
|
return _key(number, 1) + struct.pack("<d", value)
|
||||||
|
|
||||||
|
|
||||||
|
def _header(*, scaler: int = 1000) -> bytes:
|
||||||
|
return b"".join(
|
||||||
|
(
|
||||||
|
_uint(1, 7),
|
||||||
|
_sint(2, 123456),
|
||||||
|
_sint(3, scaler),
|
||||||
|
_bytes(4, b"device-redacted"),
|
||||||
|
_bytes(5, b"session-redacted"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pcl_payload(*, compression: int = 0, scaler: int = 1000) -> bytes:
|
||||||
|
point_1 = _sint(1, 1000) + _sint(2, -2000) + _sint(3, 500) + _uint(4, 0x11223344)
|
||||||
|
point_2 = _sint(1, -250) + _sint(2, 0) + _sint(3, 4000) + _uint(4, 0xAABBCC09)
|
||||||
|
report = _bytes(1, _header(scaler=scaler)) + _bytes(2, point_1) + _bytes(2, point_2)
|
||||||
|
compressed = lz4.block.compress(report, store_size=False)
|
||||||
|
fields = []
|
||||||
|
if compression:
|
||||||
|
fields.append(_uint(2, compression))
|
||||||
|
fields.extend((_uint(3, len(report)), _bytes(4, compressed)))
|
||||||
|
return b"".join(fields)
|
||||||
|
|
||||||
|
|
||||||
|
def test_protobuf_wire_zigzag_and_bounds() -> None:
|
||||||
|
assert decode_zigzag64(0) == 0
|
||||||
|
assert decode_zigzag64(1) == -1
|
||||||
|
assert decode_zigzag64(2) == 1
|
||||||
|
with pytest.raises(ProtobufWireError, match="truncated"):
|
||||||
|
list(iter_fields(b"\x0a\x02\x01"))
|
||||||
|
with pytest.raises(ProtobufWireError, match="unsupported"):
|
||||||
|
list(iter_fields(b"\x0b"))
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_lio_pcl_raw_lz4() -> None:
|
||||||
|
frame = decode_lio_pcl(_pcl_payload())
|
||||||
|
assert frame.header.seq == 7
|
||||||
|
assert frame.header.stamp == 123456
|
||||||
|
assert frame.header.scaler == 1000
|
||||||
|
assert len(frame.points) == 2
|
||||||
|
assert frame.points[0].scaled_xyz(frame.header.scaler) == (1.0, -2.0, 0.5)
|
||||||
|
assert frame.points[0].rgbi == 0x11223344
|
||||||
|
assert frame.points[0].intensity == 0x44
|
||||||
|
assert frame.points[1].scaled_xyz(frame.header.scaler) == (-0.25, 0.0, 4.0)
|
||||||
|
assert frame.points[1].intensity == 9
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_lio_pcl_rejects_unverified_or_unsafe_frames() -> None:
|
||||||
|
with pytest.raises(UnsupportedCompressionError, match="enum 1"):
|
||||||
|
decode_lio_pcl(_pcl_payload(compression=1))
|
||||||
|
with pytest.raises(StreamDecodeError, match="scaler is zero"):
|
||||||
|
decode_lio_pcl(_pcl_payload(scaler=0))
|
||||||
|
with pytest.raises(StreamDecodeError, match="exceeds 1 points"):
|
||||||
|
decode_lio_pcl(_pcl_payload(), DecodeLimits(max_points_per_frame=1))
|
||||||
|
with pytest.raises(StreamDecodeError, match="MQTT payload exceeds"):
|
||||||
|
decode_lio_pcl(_pcl_payload(), DecodeLimits(max_mqtt_payload_bytes=4))
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_lio_pose() -> None:
|
||||||
|
position = _fixed64(1, 1.25) + _fixed64(2, -2.5) + _fixed64(3, 3.75)
|
||||||
|
orientation = (
|
||||||
|
_fixed64(1, 0.1)
|
||||||
|
+ _fixed64(2, 0.2)
|
||||||
|
+ _fixed64(3, 0.3)
|
||||||
|
+ _fixed64(4, 0.9)
|
||||||
|
)
|
||||||
|
pose = _bytes(1, position) + _bytes(2, orientation)
|
||||||
|
stamped = _sint(1, 987654321) + _bytes(2, pose)
|
||||||
|
payload = (
|
||||||
|
_bytes(1, _header())
|
||||||
|
+ _bytes(2, stamped)
|
||||||
|
+ _fixed32(3, 12.5)
|
||||||
|
+ _fixed32(4, 0.001)
|
||||||
|
)
|
||||||
|
|
||||||
|
frame = decode_lio_pose(payload)
|
||||||
|
assert frame.pose_stamp == 987654321
|
||||||
|
assert frame.position_xyz == (1.25, -2.5, 3.75)
|
||||||
|
assert frame.orientation_xyzw == pytest.approx((0.1, 0.2, 0.3, 0.9))
|
||||||
|
assert frame.distance == 12.5
|
||||||
|
assert frame.pose_accuracy == pytest.approx(0.001)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_lio_pose_rejects_nonfinite_float() -> None:
|
||||||
|
payload = _bytes(1, _header()) + _fixed32(3, math.nan)
|
||||||
|
with pytest.raises(StreamDecodeError, match="not finite"):
|
||||||
|
decode_lio_pose(payload)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_legacy_pointcloud() -> None:
|
||||||
|
envelope = struct.pack("<III", 16, 123, 456)
|
||||||
|
body = struct.pack("<fffBBBB", 1.0, -2.0, 3.0, 10, 20, 30, 40)
|
||||||
|
frame = decode_legacy_pointcloud(envelope + body)
|
||||||
|
assert frame.stride == 16
|
||||||
|
assert frame.envelope == envelope
|
||||||
|
assert frame.points[0] == (1.0, -2.0, 3.0, 10, 20, 30, 40)
|
||||||
|
|
||||||
|
|
||||||
|
def test_decode_legacy_pose_and_matrix() -> None:
|
||||||
|
payload = struct.pack("<ffffffff", 1.0, 2.0, 3.0, 99.0, 0.9, 0.1, 0.2, 0.3)
|
||||||
|
frame = decode_legacy_pose(payload + b"tail")
|
||||||
|
assert frame.position_xyz == (1.0, 2.0, 3.0)
|
||||||
|
assert frame.orientation_xyzw == pytest.approx((0.1, 0.2, 0.3, 0.9))
|
||||||
|
assert frame.skipped_offset_12 == struct.pack("<f", 99.0)
|
||||||
|
assert frame.unknown_tail == b"tail"
|
||||||
|
|
||||||
|
matrix = tuple(float(index) for index in range(16))
|
||||||
|
assert decode_pre_path_array(struct.pack("<16d", *matrix)) == matrix
|
||||||
|
with pytest.raises(StreamDecodeError, match="exactly 128"):
|
||||||
|
decode_pre_path_array(b"short")
|
||||||
|
|
@ -0,0 +1,195 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import json
|
||||||
|
import stat
|
||||||
|
import struct
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import lz4.block
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from k1link.analyze.stream_summary import summarize_mqtt_streams
|
||||||
|
from k1link.cli import app
|
||||||
|
from k1link.mqtt.capture import FRAME_HEADER, RAW_MAGIC, CaptureFormatError
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def _varint(value: int) -> bytes:
|
||||||
|
encoded = bytearray()
|
||||||
|
while value > 0x7F:
|
||||||
|
encoded.append((value & 0x7F) | 0x80)
|
||||||
|
value >>= 7
|
||||||
|
encoded.append(value)
|
||||||
|
return bytes(encoded)
|
||||||
|
|
||||||
|
|
||||||
|
def _key(number: int, wire_type: int) -> bytes:
|
||||||
|
return _varint((number << 3) | wire_type)
|
||||||
|
|
||||||
|
|
||||||
|
def _uint(number: int, value: int) -> bytes:
|
||||||
|
return _key(number, 0) + _varint(value)
|
||||||
|
|
||||||
|
|
||||||
|
def _sint(number: int, value: int) -> bytes:
|
||||||
|
zigzag = (value << 1) ^ (value >> 63)
|
||||||
|
return _uint(number, zigzag & 0xFFFFFFFFFFFFFFFF)
|
||||||
|
|
||||||
|
|
||||||
|
def _bytes(number: int, value: bytes) -> bytes:
|
||||||
|
return _key(number, 2) + _varint(len(value)) + value
|
||||||
|
|
||||||
|
|
||||||
|
def _fixed64(number: int, value: float) -> bytes:
|
||||||
|
return _key(number, 1) + struct.pack("<d", value)
|
||||||
|
|
||||||
|
|
||||||
|
def _header(*, scaler: int) -> bytes:
|
||||||
|
return b"".join(
|
||||||
|
(
|
||||||
|
_uint(1, 7),
|
||||||
|
_sint(2, 123456),
|
||||||
|
_sint(3, scaler),
|
||||||
|
_bytes(4, b"device-super-secret"),
|
||||||
|
_bytes(5, b"session-super-secret"),
|
||||||
|
_bytes(6, b"openapi-super-secret"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _pcl_payload(*, scaler: int, point_count: int) -> bytes:
|
||||||
|
points = []
|
||||||
|
for index in range(point_count):
|
||||||
|
point = (
|
||||||
|
_sint(1, 1000 + index)
|
||||||
|
+ _sint(2, -2000 - index)
|
||||||
|
+ _sint(3, 500 + index)
|
||||||
|
+ _uint(4, index)
|
||||||
|
)
|
||||||
|
points.append(_bytes(2, point))
|
||||||
|
report = _bytes(1, _header(scaler=scaler)) + b"".join(points)
|
||||||
|
compressed = lz4.block.compress(report, store_size=False)
|
||||||
|
return _uint(3, len(report)) + _bytes(4, compressed)
|
||||||
|
|
||||||
|
|
||||||
|
def _pose_payload(position_xyz: tuple[float, float, float]) -> bytes:
|
||||||
|
position = b"".join(
|
||||||
|
_fixed64(field_number, value)
|
||||||
|
for field_number, value in enumerate(position_xyz, start=1)
|
||||||
|
)
|
||||||
|
orientation = (
|
||||||
|
_fixed64(1, 0.0)
|
||||||
|
+ _fixed64(2, 0.0)
|
||||||
|
+ _fixed64(3, 0.0)
|
||||||
|
+ _fixed64(4, 1.0)
|
||||||
|
)
|
||||||
|
pose = _bytes(1, position) + _bytes(2, orientation)
|
||||||
|
stamped = _sint(1, 987654321) + _bytes(2, pose)
|
||||||
|
return _bytes(1, _header(scaler=1000)) + _bytes(2, stamped)
|
||||||
|
|
||||||
|
|
||||||
|
def _write_capture(path: Path, frames: list[tuple[str, bytes]]) -> bytes:
|
||||||
|
raw = bytearray(RAW_MAGIC)
|
||||||
|
for topic, payload in frames:
|
||||||
|
topic_bytes = topic.encode()
|
||||||
|
raw.extend(FRAME_HEADER.pack(len(topic_bytes), len(payload)))
|
||||||
|
raw.extend(topic_bytes)
|
||||||
|
raw.extend(payload)
|
||||||
|
value = bytes(raw)
|
||||||
|
path.write_bytes(value)
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_is_bounded_aggregate_only_and_hashes_capture(tmp_path: Path) -> None:
|
||||||
|
frames = [
|
||||||
|
("lixel/application/report/lio_pcl", _pcl_payload(scaler=1000, point_count=2)),
|
||||||
|
("lixel/application/report/lio_pcl", _pcl_payload(scaler=2000, point_count=1)),
|
||||||
|
("lixel/application/report/lio_pcl", b"bad-protobuf"),
|
||||||
|
("lixel/application/report/lio_pose", _pose_payload((1.0, 2.0, 3.0))),
|
||||||
|
("lixel/application/report/lio_pose", _pose_payload((4.0, 6.0, 3.0))),
|
||||||
|
("lixel/application/report/lio_pose", b"bad-protobuf"),
|
||||||
|
("device-super-secret/session-super-secret", b"openapi-super-secret"),
|
||||||
|
]
|
||||||
|
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||||
|
raw = _write_capture(capture, frames)
|
||||||
|
|
||||||
|
summary = summarize_mqtt_streams(capture)
|
||||||
|
|
||||||
|
assert summary["source"] == {
|
||||||
|
"bytes": len(raw),
|
||||||
|
"sha256": hashlib.sha256(raw).hexdigest(),
|
||||||
|
}
|
||||||
|
assert summary["frames"] == {
|
||||||
|
"count": 7,
|
||||||
|
"payload_bytes": sum(len(payload) for _, payload in frames),
|
||||||
|
"encoded_frame_bytes": len(raw) - len(RAW_MAGIC),
|
||||||
|
"other_count": 1,
|
||||||
|
"other_payload_bytes": len(b"openapi-super-secret"),
|
||||||
|
}
|
||||||
|
assert summary["decoding"] == {"attempted": 6, "successes": 4, "errors": 2}
|
||||||
|
assert summary["point_cloud"]["frame_count"] == 3
|
||||||
|
assert summary["point_cloud"]["decode_successes"] == 2
|
||||||
|
assert summary["point_cloud"]["decode_errors"] == 1
|
||||||
|
assert summary["point_cloud"]["points"] == {
|
||||||
|
"total": 3,
|
||||||
|
"per_frame": {"min": 1, "max": 2},
|
||||||
|
}
|
||||||
|
assert summary["point_cloud"]["scalers"] == {
|
||||||
|
"min": 1000,
|
||||||
|
"max": 2000,
|
||||||
|
"constant": False,
|
||||||
|
}
|
||||||
|
assert summary["pose"]["frame_count"] == 3
|
||||||
|
assert summary["pose"]["decode_successes"] == 2
|
||||||
|
assert summary["pose"]["decode_errors"] == 1
|
||||||
|
assert summary["pose"]["first_to_last_displacement_meters"] == 5.0
|
||||||
|
|
||||||
|
serialized = json.dumps(summary)
|
||||||
|
for secret in (
|
||||||
|
"device-super-secret",
|
||||||
|
"session-super-secret",
|
||||||
|
"openapi-super-secret",
|
||||||
|
"bad-protobuf",
|
||||||
|
):
|
||||||
|
assert secret not in serialized
|
||||||
|
assert "position_xyz" not in serialized
|
||||||
|
assert "points" in summary["point_cloud"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_summary_rejects_a_frame_above_the_operator_limit(tmp_path: Path) -> None:
|
||||||
|
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||||
|
_write_capture(capture, [("other", b"12345")])
|
||||||
|
|
||||||
|
with pytest.raises(CaptureFormatError, match="exceeds 4"):
|
||||||
|
summarize_mqtt_streams(capture, max_payload_bytes=4)
|
||||||
|
|
||||||
|
|
||||||
|
def test_mqtt_streams_cli_writes_atomic_private_summary(tmp_path: Path) -> None:
|
||||||
|
capture = tmp_path / "mqtt.raw.k1mqtt"
|
||||||
|
raw = _write_capture(capture, [])
|
||||||
|
out = tmp_path / "captures" / "summary.json"
|
||||||
|
|
||||||
|
result = runner.invoke(
|
||||||
|
app,
|
||||||
|
[
|
||||||
|
"analyze",
|
||||||
|
"mqtt-streams",
|
||||||
|
"--capture",
|
||||||
|
str(capture),
|
||||||
|
"--out",
|
||||||
|
str(out),
|
||||||
|
"--max-payload-bytes",
|
||||||
|
"1024",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
saved = json.loads(out.read_text())
|
||||||
|
assert saved["source"]["sha256"] == hashlib.sha256(raw).hexdigest()
|
||||||
|
assert saved["limits"]["max_payload_bytes"] == 1024
|
||||||
|
assert saved["frames"]["count"] == 0
|
||||||
|
assert "aggregate-only" in result.stdout
|
||||||
|
assert stat.S_IMODE(out.stat().st_mode) == 0o600
|
||||||
|
|
@ -0,0 +1,251 @@
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import plistlib
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from typer.testing import CliRunner
|
||||||
|
|
||||||
|
from k1link.cli import app
|
||||||
|
from k1link.usb.snapshot import (
|
||||||
|
DISKUTIL_COMMAND,
|
||||||
|
SERIAL_IOREG_COMMAND,
|
||||||
|
USB_IOREG_COMMAND,
|
||||||
|
CommandOutput,
|
||||||
|
snapshot,
|
||||||
|
)
|
||||||
|
|
||||||
|
runner = CliRunner()
|
||||||
|
|
||||||
|
|
||||||
|
def _plist_output(argv: tuple[str, ...], payload: object) -> CommandOutput:
|
||||||
|
return CommandOutput(
|
||||||
|
argv=argv,
|
||||||
|
returncode=0,
|
||||||
|
stdout=plistlib.dumps(payload),
|
||||||
|
stderr=b"",
|
||||||
|
error=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _interface(
|
||||||
|
name: str,
|
||||||
|
number: int,
|
||||||
|
interface_class: int,
|
||||||
|
subclass: int,
|
||||||
|
protocol: int,
|
||||||
|
) -> dict[str, object]:
|
||||||
|
return {
|
||||||
|
"IOObjectClass": "IOUSBHostInterface",
|
||||||
|
"IORegistryEntryName": name,
|
||||||
|
"kUSBString": name,
|
||||||
|
"bInterfaceNumber": number,
|
||||||
|
"bInterfaceClass": interface_class,
|
||||||
|
"bInterfaceSubClass": subclass,
|
||||||
|
"bInterfaceProtocol": protocol,
|
||||||
|
"bAlternateSetting": 0,
|
||||||
|
"bConfigurationValue": 1,
|
||||||
|
"bNumEndpoints": 2,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_parses_xgrids_interfaces_storage_and_usbmodem() -> None:
|
||||||
|
usb_plist = [
|
||||||
|
{
|
||||||
|
"IOObjectClass": "IOUSBHostDevice",
|
||||||
|
"IORegistryEntryName": "XGRIDS Device",
|
||||||
|
"USB Product Name": "XGRIDS Device",
|
||||||
|
"USB Vendor Name": "rockchip",
|
||||||
|
"USB Serial Number": "synthetic-serial",
|
||||||
|
"idVendor": 0x2207,
|
||||||
|
"idProduct": 0x0019,
|
||||||
|
"bDeviceClass": 0xEF,
|
||||||
|
"bDeviceSubClass": 0x02,
|
||||||
|
"bDeviceProtocol": 0x01,
|
||||||
|
"bcdUSB": 0x0210,
|
||||||
|
"bcdDevice": 0x0310,
|
||||||
|
"USBSpeed": 3,
|
||||||
|
"UsbLinkSpeed": 480_000_000,
|
||||||
|
"USB Address": 1,
|
||||||
|
"locationID": 0x01100000,
|
||||||
|
"IORegistryEntryID": 12345,
|
||||||
|
"IORegistryEntryChildren": [
|
||||||
|
_interface("RNDIS Communications Control", 0, 0xE0, 0x01, 0x03),
|
||||||
|
_interface("RNDIS Ethernet Data", 1, 0x0A, 0x00, 0x00),
|
||||||
|
_interface("Mass Storage", 2, 0x08, 0x06, 0x50),
|
||||||
|
_interface("CDC NCM Control", 3, 0x02, 0x0D, 0x00),
|
||||||
|
_interface("CDC ACM Serial", 4, 0x02, 0x02, 0x01),
|
||||||
|
{
|
||||||
|
"IOObjectClass": "IOMedia",
|
||||||
|
"IORegistryEntryName": "Synthetic Media",
|
||||||
|
"BSD Name": "disk4",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"IOObjectClass": "IOUSBHostDevice",
|
||||||
|
"IORegistryEntryName": "Unrelated Camera",
|
||||||
|
"USB Product Name": "Unrelated Camera",
|
||||||
|
"idVendor": 999,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
serial_plist = [
|
||||||
|
{
|
||||||
|
"IOCalloutDevice": "/dev/cu.usbmodemK1TEST",
|
||||||
|
"IODialinDevice": "/dev/tty.usbmodemK1TEST",
|
||||||
|
"IOTTYBaseName": "usbmodemK1TEST",
|
||||||
|
"IOSerialBSDClientType": "IOSerialStream",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"IOCalloutDevice": "/dev/cu.debug-console",
|
||||||
|
"IODialinDevice": "/dev/tty.debug-console",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
storage_plist = {
|
||||||
|
"AllDisks": ["disk4", "disk4s1"],
|
||||||
|
"WholeDisks": ["disk4"],
|
||||||
|
"VolumesFromDisks": ["K1_DATA"],
|
||||||
|
"AllDisksAndPartitions": [
|
||||||
|
{
|
||||||
|
"DeviceIdentifier": "disk4",
|
||||||
|
"Content": "GUID_partition_scheme",
|
||||||
|
"Size": 64_000_000,
|
||||||
|
"OSInternal": False,
|
||||||
|
"Partitions": [
|
||||||
|
{
|
||||||
|
"DeviceIdentifier": "disk4s1",
|
||||||
|
"Content": "Microsoft Basic Data",
|
||||||
|
"Size": 63_000_000,
|
||||||
|
"VolumeName": "K1_DATA",
|
||||||
|
"MountPoint": "/Volumes/K1_DATA",
|
||||||
|
"OSInternal": False,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
],
|
||||||
|
}
|
||||||
|
outputs = {
|
||||||
|
USB_IOREG_COMMAND: _plist_output(USB_IOREG_COMMAND, usb_plist),
|
||||||
|
SERIAL_IOREG_COMMAND: _plist_output(SERIAL_IOREG_COMMAND, serial_plist),
|
||||||
|
DISKUTIL_COMMAND: _plist_output(DISKUTIL_COMMAND, storage_plist),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = snapshot(lambda argv: outputs[tuple(argv)])
|
||||||
|
|
||||||
|
assert result["xgrids_device_count"] == 1
|
||||||
|
device = result["xgrids_devices"][0]
|
||||||
|
assert device["vendor_id_hex"] == "0x2207"
|
||||||
|
assert device["product_id_hex"] == "0x0019"
|
||||||
|
assert device["bsd_names"] == ["disk4"]
|
||||||
|
assert device["interface_capabilities"] == [
|
||||||
|
"cdc_data",
|
||||||
|
"mass_storage",
|
||||||
|
"ncm",
|
||||||
|
"rndis",
|
||||||
|
"serial",
|
||||||
|
]
|
||||||
|
assert [interface["interface_number"] for interface in device["interfaces"]] == [
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
2,
|
||||||
|
3,
|
||||||
|
4,
|
||||||
|
]
|
||||||
|
assert all(entry["xgrids_related"] for entry in result["external_storage"]["entries"])
|
||||||
|
assert result["usbmodem_device_names"] == [
|
||||||
|
"/dev/cu.usbmodemK1TEST",
|
||||||
|
"/dev/tty.usbmodemK1TEST",
|
||||||
|
]
|
||||||
|
assert len(result["usbmodem_devices"]) == 1
|
||||||
|
assert all(source["ok"] for source in result["sources"])
|
||||||
|
assert all("sudo" not in source["argv"] for source in result["sources"])
|
||||||
|
assert result["safety"] == {
|
||||||
|
"metadata_only": True,
|
||||||
|
"sudo_used": False,
|
||||||
|
"device_file_contents_read": False,
|
||||||
|
"device_writes_performed": False,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_reports_command_and_plist_errors_without_raising() -> None:
|
||||||
|
outputs = {
|
||||||
|
USB_IOREG_COMMAND: CommandOutput(
|
||||||
|
argv=USB_IOREG_COMMAND,
|
||||||
|
returncode=0,
|
||||||
|
stdout=b"not a plist",
|
||||||
|
stderr=b"",
|
||||||
|
error=None,
|
||||||
|
),
|
||||||
|
SERIAL_IOREG_COMMAND: CommandOutput(
|
||||||
|
argv=SERIAL_IOREG_COMMAND,
|
||||||
|
returncode=1,
|
||||||
|
stdout=b"",
|
||||||
|
stderr=b"serial registry unavailable",
|
||||||
|
error=None,
|
||||||
|
),
|
||||||
|
DISKUTIL_COMMAND: _plist_output(
|
||||||
|
DISKUTIL_COMMAND,
|
||||||
|
{
|
||||||
|
"AllDisks": [],
|
||||||
|
"WholeDisks": [],
|
||||||
|
"VolumesFromDisks": [],
|
||||||
|
"AllDisksAndPartitions": [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = snapshot(lambda argv: outputs[tuple(argv)])
|
||||||
|
|
||||||
|
assert result["xgrids_devices"] == []
|
||||||
|
statuses = {source["name"]: source for source in result["sources"]}
|
||||||
|
assert statuses["usb_ioreg"]["ok"] is False
|
||||||
|
assert statuses["usb_ioreg"]["error"].startswith("invalid plist:")
|
||||||
|
assert statuses["serial_ioreg"]["ok"] is False
|
||||||
|
assert statuses["serial_ioreg"]["error"] == "serial registry unavailable"
|
||||||
|
assert statuses["external_disks"]["ok"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_snapshot_treats_empty_ioreg_output_as_no_devices() -> None:
|
||||||
|
outputs = {
|
||||||
|
USB_IOREG_COMMAND: CommandOutput(USB_IOREG_COMMAND, 0, b"", b"", None),
|
||||||
|
SERIAL_IOREG_COMMAND: CommandOutput(SERIAL_IOREG_COMMAND, 0, b"", b"", None),
|
||||||
|
DISKUTIL_COMMAND: _plist_output(
|
||||||
|
DISKUTIL_COMMAND,
|
||||||
|
{
|
||||||
|
"AllDisks": [],
|
||||||
|
"WholeDisks": [],
|
||||||
|
"VolumesFromDisks": [],
|
||||||
|
"AllDisksAndPartitions": [],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
result = snapshot(lambda argv: outputs[tuple(argv)])
|
||||||
|
|
||||||
|
assert result["xgrids_device_count"] == 0
|
||||||
|
assert all(source["ok"] for source in result["sources"])
|
||||||
|
|
||||||
|
|
||||||
|
def test_usb_snapshot_cli_writes_json(
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
output = tmp_path / "nested" / "usb-snapshot.json"
|
||||||
|
payload = {
|
||||||
|
"schema_version": 1,
|
||||||
|
"xgrids_device_count": 0,
|
||||||
|
"safety": {
|
||||||
|
"metadata_only": True,
|
||||||
|
"sudo_used": False,
|
||||||
|
"device_file_contents_read": False,
|
||||||
|
"device_writes_performed": False,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
monkeypatch.setattr("k1link.cli.usb_snapshot", lambda: payload)
|
||||||
|
|
||||||
|
result = runner.invoke(app, ["usb", "snapshot", "--out", str(output)])
|
||||||
|
|
||||||
|
assert result.exit_code == 0
|
||||||
|
assert json.loads(output.read_text(encoding="utf-8")) == payload
|
||||||
|
assert "no sudo" in result.stdout
|
||||||
|
|
@ -0,0 +1,69 @@
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from k1link.ble.wifi_provisioning import (
|
||||||
|
FRAME_LENGTH,
|
||||||
|
build_wifi_provisioning_frame,
|
||||||
|
parse_wifi_status,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_wifi_provisioning_frame_layout() -> None:
|
||||||
|
frame = build_wifi_provisioning_frame("LabNet", "correct horse")
|
||||||
|
|
||||||
|
assert len(frame) == FRAME_LENGTH
|
||||||
|
assert frame[0] == 6
|
||||||
|
assert frame[1:7] == b"LabNet"
|
||||||
|
assert frame[7:33] == bytes(26)
|
||||||
|
assert frame[33] == 13
|
||||||
|
assert frame[34:47] == b"correct horse"
|
||||||
|
assert frame[47:98] == bytes(51)
|
||||||
|
assert frame[98] == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_build_wifi_provisioning_frame_uses_utf8_byte_lengths() -> None:
|
||||||
|
frame = build_wifi_provisioning_frame("Сеть", "пароль")
|
||||||
|
|
||||||
|
assert frame[0] == len("Сеть".encode())
|
||||||
|
assert frame[33] == len("пароль".encode())
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("ssid", "password", "message"),
|
||||||
|
[
|
||||||
|
("", "password", "SSID must not be empty"),
|
||||||
|
("network", "", "password must not be empty"),
|
||||||
|
("x" * 33, "password", "at most 32 UTF-8 bytes"),
|
||||||
|
("network", "x" * 65, "at most 64 UTF-8 bytes"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_build_wifi_provisioning_frame_rejects_invalid_lengths(
|
||||||
|
ssid: str,
|
||||||
|
password: str,
|
||||||
|
message: str,
|
||||||
|
) -> None:
|
||||||
|
with pytest.raises(ValueError, match=message):
|
||||||
|
build_wifi_provisioning_frame(ssid, password)
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_wifi_status_ap_baseline() -> None:
|
||||||
|
value = bytearray(54)
|
||||||
|
value[0] = 7
|
||||||
|
value[1:8] = b"WIFI_AP"
|
||||||
|
value[33] = 4
|
||||||
|
value[34:38] = bytes((192, 168, 56, 1))
|
||||||
|
value[50] = 1
|
||||||
|
value[52:54] = b"XX"
|
||||||
|
|
||||||
|
assert parse_wifi_status(bytes(value)) == {
|
||||||
|
"value_length": 54,
|
||||||
|
"mode": "WIFI_AP",
|
||||||
|
"ipv4": "192.168.56.1",
|
||||||
|
"status_code": 1,
|
||||||
|
"reserved": 0,
|
||||||
|
"trailer_hex": "5858",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_parse_wifi_status_rejects_short_frame() -> None:
|
||||||
|
with pytest.raises(ValueError, match="at least 51 bytes"):
|
||||||
|
parse_wifi_status(bytes(50))
|
||||||
29
uv.lock
29
uv.lock
|
|
@ -88,6 +88,22 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" },
|
{ url = "https://files.pythonhosted.org/packages/d0/65/aead61bbf3b5358593f9d4779d2a0e88eaf6ec191a6342dde36dd1df6371/librt-0.13.0-cp312-cp312-win_arm64.whl", hash = "sha256:c718e99a0992127af84385378460db624103b559ab260435abcfe77a4e4ed1c1", size = 112236, upload-time = "2026-07-08T12:25:18.425Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lz4"
|
||||||
|
version = "4.4.5"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/57/51/f1b86d93029f418033dddf9b9f79c8d2641e7454080478ee2aab5123173e/lz4-4.4.5.tar.gz", hash = "sha256:5f0b9e53c1e82e88c10d7c180069363980136b9d7a8306c4dca4f760d60c39f0", size = 172886, upload-time = "2025-11-03T13:02:36.061Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/1b/ac/016e4f6de37d806f7cc8f13add0a46c9a7cfc41a5ddc2bc831d7954cf1ce/lz4-4.4.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:df5aa4cead2044bab83e0ebae56e0944cc7fcc1505c7787e9e1057d6d549897e", size = 207163, upload-time = "2025-11-03T13:01:45.895Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/8d/df/0fadac6e5bd31b6f34a1a8dbd4db6a7606e70715387c27368586455b7fc9/lz4-4.4.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6d0bf51e7745484d2092b3a51ae6eb58c3bd3ce0300cf2b2c14f76c536d5697a", size = 207150, upload-time = "2025-11-03T13:01:47.205Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b7/17/34e36cc49bb16ca73fb57fbd4c5eaa61760c6b64bce91fcb4e0f4a97f852/lz4-4.4.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:7b62f94b523c251cf32aa4ab555f14d39bd1a9df385b72443fd76d7c7fb051f5", size = 1292045, upload-time = "2025-11-03T13:01:48.667Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/90/1c/b1d8e3741e9fc89ed3b5f7ef5f22586c07ed6bb04e8343c2e98f0fa7ff04/lz4-4.4.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2c3ea562c3af274264444819ae9b14dbbf1ab070aff214a05e97db6896c7597e", size = 1279546, upload-time = "2025-11-03T13:01:50.159Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/55/d9/e3867222474f6c1b76e89f3bd914595af69f55bf2c1866e984c548afdc15/lz4-4.4.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:24092635f47538b392c4eaeff14c7270d2c8e806bf4be2a6446a378591c5e69e", size = 1368249, upload-time = "2025-11-03T13:01:51.273Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/b2/e7/d667d337367686311c38b580d1ca3d5a23a6617e129f26becd4f5dc458df/lz4-4.4.5-cp312-cp312-win32.whl", hash = "sha256:214e37cfe270948ea7eb777229e211c601a3e0875541c1035ab408fbceaddf50", size = 88189, upload-time = "2025-11-03T13:01:52.605Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/a5/0b/a54cd7406995ab097fceb907c7eb13a6ddd49e0b231e448f1a81a50af65c/lz4-4.4.5-cp312-cp312-win_amd64.whl", hash = "sha256:713a777de88a73425cf08eb11f742cd2c98628e79a8673d6a52e3c5f0c116f33", size = 99497, upload-time = "2025-11-03T13:01:53.477Z" },
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/6a/7e/dc28a952e4bfa32ca16fa2eb026e7a6ce5d1411fcd5986cd08c74ec187b9/lz4-4.4.5-cp312-cp312-win_arm64.whl", hash = "sha256:a88cbb729cc333334ccfb52f070463c21560fca63afcf636a9f160a55fac3301", size = 91279, upload-time = "2025-11-03T13:01:54.419Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "markdown-it-py"
|
name = "markdown-it-py"
|
||||||
version = "4.2.0"
|
version = "4.2.0"
|
||||||
|
|
@ -146,6 +162,8 @@ version = "0.1.0"
|
||||||
source = { editable = "." }
|
source = { editable = "." }
|
||||||
dependencies = [
|
dependencies = [
|
||||||
{ name = "bleak" },
|
{ name = "bleak" },
|
||||||
|
{ name = "lz4" },
|
||||||
|
{ name = "paho-mqtt" },
|
||||||
{ name = "rich" },
|
{ name = "rich" },
|
||||||
{ name = "typer" },
|
{ name = "typer" },
|
||||||
]
|
]
|
||||||
|
|
@ -160,6 +178,8 @@ dev = [
|
||||||
[package.metadata]
|
[package.metadata]
|
||||||
requires-dist = [
|
requires-dist = [
|
||||||
{ name = "bleak", specifier = "==3.0.2" },
|
{ name = "bleak", specifier = "==3.0.2" },
|
||||||
|
{ name = "lz4", specifier = ">=4.4,<5" },
|
||||||
|
{ name = "paho-mqtt", specifier = ">=2.1,<3" },
|
||||||
{ name = "rich", specifier = ">=13.9,<15" },
|
{ name = "rich", specifier = ">=13.9,<15" },
|
||||||
{ name = "typer", specifier = ">=0.15,<1" },
|
{ name = "typer", specifier = ">=0.15,<1" },
|
||||||
]
|
]
|
||||||
|
|
@ -180,6 +200,15 @@ wheels = [
|
||||||
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "paho-mqtt"
|
||||||
|
version = "2.1.0"
|
||||||
|
source = { registry = "https://pypi.org/simple" }
|
||||||
|
sdist = { url = "https://files.pythonhosted.org/packages/39/15/0a6214e76d4d32e7f663b109cf71fb22561c2be0f701d67f93950cd40542/paho_mqtt-2.1.0.tar.gz", hash = "sha256:12d6e7511d4137555a3f6ea167ae846af2c7357b10bc6fa4f7c3968fc1723834", size = 148848, upload-time = "2024-04-29T19:52:55.591Z" }
|
||||||
|
wheels = [
|
||||||
|
{ url = "https://files.pythonhosted.org/packages/c4/cb/00451c3cf31790287768bb12c6bec834f5d292eaf3022afc88e14b8afc94/paho_mqtt-2.1.0-py3-none-any.whl", hash = "sha256:6db9ba9b34ed5bc6b6e3812718c7e06e2fd7444540df2455d2c51bd58808feee", size = 67219, upload-time = "2024-04-29T19:52:48.345Z" },
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pathspec"
|
name = "pathspec"
|
||||||
version = "1.1.1"
|
version = "1.1.1"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue