diff --git a/CHANGELOG.md b/CHANGELOG.md index 7341bd5..5a253fa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,34 @@ All notable user-facing changes are recorded here. Versions follow ## [Unreleased] +## [0.1.0rc4] - 2026-08-12 + +### Added + +- negotiated TAG v2 support with a native u64 device timestamp, distinct wire + magic, CRC-32 frame integrity, and exact boot-scoped start acknowledgement +- recording and replay metadata for negotiated TAG version, firmware capability, + and boot identity +- contract vectors and boundary tests covering fragmented acknowledgements, + malformed frames, multiple u32 epochs, reconnects, and reboot boundaries + +### Changed + +- preserved TAG v1 automatically for firmware 0.9.10 through 0.9.12 and select + TAG v2 only when the device explicitly advertises it +- documented 0.9.12 as the current signed fleet image while keeping 0.9.13 TAG v2 + physical qualification as a release gate + +### Fixed + +- fail closed when a TAG v2 start acknowledgement is missing, malformed, or + belongs to a different boot instead of silently accepting an ambiguous stream +- reject a TAG v2 frame whose header, payload, or CRC trailer is corrupt and + resynchronize at a later valid frame while leaving TAG v1 bytes unchanged +- stop and seal an incomplete episode when any fitted sensor stream makes no + progress for three seconds, including an open serial handle returning only + empty reads + ## [0.1.0rc3] - 2026-08-09 ### Changed @@ -73,7 +101,8 @@ First public release candidate. - zero persistence requires a power-cycle read-back when it is a release gate - multi-hour and slow-storage target-host qualification remain deployment tasks -[Unreleased]: https://github.com/OpenGraphLabs/oglo-python/compare/v0.1.0rc3...HEAD +[Unreleased]: https://github.com/OpenGraphLabs/oglo-python/compare/v0.1.0rc4...HEAD +[0.1.0rc4]: https://github.com/OpenGraphLabs/oglo-python/compare/v0.1.0rc3...v0.1.0rc4 [0.1.0rc3]: https://github.com/OpenGraphLabs/oglo-python/compare/v0.1.0rc2...v0.1.0rc3 [0.1.0rc2]: https://github.com/OpenGraphLabs/oglo-python/compare/v0.1.0rc1...v0.1.0rc2 [0.1.0rc1]: https://github.com/OpenGraphLabs/oglo-python/releases/tag/v0.1.0rc1 diff --git a/README.md b/README.md index a7446c7..dfe03a6 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,11 @@ Python access to the OGLO five-finger tactile glove: 80 taxels per hand at a nominal 250 Hz over USB, plus accelerometer, gyroscope, and optional magnetometer streams. -> **Release candidate:** `0.1.0rc3` is a USB-first research SDK for firmware -> 0.9.10 or newer with CONFIG schema 6. The current golden firmware for new flashes -> is 0.9.11; deployed 0.9.10 gloves remain supported. Older firmware is rejected -> for both live connections and replay. BLE is experimental and not release-qualified. +> **Release candidate:** `0.1.0rc4` is a USB-first research SDK for firmware +> 0.9.10 or newer with CONFIG schema 6. The current signed fleet image is 0.9.12; +> deployed 0.9.10 and 0.9.11 gloves remain supported. Firmware 0.9.13 adds +> negotiated TAG v2/u64 support, but that path remains unqualified until its final +> signed artifact is captured on physical hardware. BLE is experimental. This public repository is the sole canonical source for the SDK. Development, issues, pull requests, tags, and releases all belong under @@ -34,14 +35,14 @@ private or staging repository is an active upstream. Download the wheel from the matching [GitHub Release](https://github.com/OpenGraphLabs/oglo-python/releases), then install it locally: ```bash -python3 -m pip install ./oglo-0.1.0rc3-py3-none-any.whl +python3 -m pip install ./oglo-0.1.0rc4-py3-none-any.whl ``` To install the tagged source instead: ```bash python3 -m pip install \ - "oglo @ git+https://github.com/OpenGraphLabs/oglo-python.git@v0.1.0rc3" + "oglo @ git+https://github.com/OpenGraphLabs/oglo-python.git@v0.1.0rc4" ``` Python 3.10 or newer is required. @@ -57,7 +58,7 @@ oglo doctor `doctor` measures the attached device and host rather than assuming the nominal rates. Resolve any reported identity, firmware, loss, or throughput failure before recording data. Upgrade any live glove that reports firmware older than 0.9.10 or -anything other than schema 6. New flashes should use the current 0.9.11 golden image. +anything other than schema 6. New flashes should use the current signed 0.9.12 image. ## Read one glove diff --git a/docs/01_quickstart.md b/docs/01_quickstart.md index 1c9416f..ee059ef 100644 --- a/docs/01_quickstart.md +++ b/docs/01_quickstart.md @@ -6,20 +6,21 @@ Download the wheel from the matching [GitHub Release](https://github.com/OpenGraphLabs/oglo-python/releases), then: ```bash -python3 -m pip install ./oglo-0.1.0rc3-py3-none-any.whl +python3 -m pip install ./oglo-0.1.0rc4-py3-none-any.whl ``` Or install the immutable source tag: ```bash python3 -m pip install \ - "oglo @ git+https://github.com/OpenGraphLabs/oglo-python.git@v0.1.0rc3" + "oglo @ git+https://github.com/OpenGraphLabs/oglo-python.git@v0.1.0rc4" ``` Python 3.10 or newer is required. Supported live gloves run firmware 0.9.10 or -newer with schema 6. The current golden firmware for new flashes is 0.9.11; -deployed 0.9.10 gloves remain supported. `0.1.0rc3` rejects older firmware in both -live connections and recorded episodes. +newer with schema 6. The current signed fleet image is 0.9.12; deployed 0.9.10 and +0.9.11 gloves remain supported. `0.1.0rc4` rejects older firmware in both live +connections and recorded episodes. It can negotiate firmware 0.9.13 TAG v2, but +that path is not release-qualified until physical golden vectors and HIL pass. ## Diagnose before collecting data diff --git a/docs/02_data_reference.md b/docs/02_data_reference.md index 0054d79..f84fc41 100644 --- a/docs/02_data_reference.md +++ b/docs/02_data_reference.md @@ -21,10 +21,14 @@ The IMU packet cadence is not the physical sensor ODR. Firmware configures the accelerometer/gyroscope at 200 Hz but polls/emits its latest value on a nominal 2 ms schedule, so adjacent 500-packet/s records may contain the same physical measurement. -The supported contract is firmware 0.9.10 or newer with schema 6. The current -golden firmware for new flashes is 0.9.11, while deployed 0.9.10 gloves remain -supported. `0.1.0rc3` rejects older firmware in live connections, vector capture, -and replay instead of selecting a best-effort decoder. +The supported baseline is firmware 0.9.10 or newer with schema 6. Deployed firmware +that omits `tag_ver_max` explicitly stays on TAG v1. A firmware build that advertises +`tag_ver_max >= 2` negotiates the additive TAG v2 frame with a native u64 timestamp +and a CRC-32 trailer over its header and payload; +the host requires `#STREAM TAG2 on boot_id=<32 lowercase hex>`, verifies that session identity +against CONFIG and every pause/resume, and caps selection at the newest layout it knows. See +[`spec/TAG_V2.md`](../spec/TAG_V2.md). `0.1.0rc4` rejects older firmware in live +connections, vector capture, and replay instead of selecting a best-effort decoder. ## Identity and side @@ -60,8 +64,8 @@ matters. | `counts` | `(5, 4, 4)` uint16, **raw 12-bit ADC, not force** | | `residual` | counts above the zero baseline, float32 | | `seq` | per-stream sample number; a gap is loss | -| `t_us` | raw device u32 microseconds; wraps about every 71.6 minutes | -| `device_time_us` | the same clock unwrapped to a continuous 64-bit timeline | +| `t_us` | low u32 of device microseconds; TAG v1 wraps about every 71.6 minutes | +| `device_time_us` | TAG v1 host-unwrapped time, or the native TAG v2 u64 timestamp | | `host_t` / `host_t_ns` | host monotonic time at the USB-read/BLE-notify boundary, in seconds/nanoseconds | | `host_received_ns` | the same observed receive boundary, kept explicitly in recordings | | `dropped` | samples missing since the previous frame | @@ -146,12 +150,13 @@ and a capture-window delta. ## Timestamps -`t_us` is the raw 32-bit device counter. Use `device_time_us` to order samples and -measure spacing within one glove across rollover; both are **meaningless across two -gloves**. The unwrapped value deliberately starts with one spare 32-bit epoch so an -older IMU packet arriving just after a tactile rollover can still be represented -without a negative integer. Its absolute number is therefore arbitrary; use ordering -and differences, not its origin. +`t_us` preserves the low 32 bits for API and recording compatibility. On TAG v1, +`device_time_us` is unwrapped by the SDK; on TAG v2 it is the firmware's native u64 +microsecond value. Both are **meaningless across two gloves** without hardware clock +synchronisation. The v1 unwrapped value deliberately starts with one spare 32-bit +epoch so an older IMU packet arriving just after a tactile rollover can still be +represented without a negative integer. Its absolute number is therefore arbitrary; +use ordering and differences, not its origin. `host_t`, `host_t_ns` and `host_received_ns` mark the observed transport receive boundary. The SDK does not move samples backwards from that boundary using device @@ -168,8 +173,9 @@ others cannot. ## Integrity limit of supported firmware -The supported tagged USB frame has a magic and length but no checksum/CRC. Firmware -0.9.11 bounds TinyUSB writes so a stopped host cannot hold the TX path forever, but -that deadline is not payload integrity. The SDK cannot mathematically prove that -every plausible payload bit is intact. A future protocol -needs framed CRC protection for that guarantee. +TAG v1 has a magic and length but no checksum/CRC. Firmware 0.9.11 and newer bound +TinyUSB writes so a stopped host cannot hold the TX path forever, but that deadline +is not payload integrity. TAG v2 adds a little-endian IEEE CRC-32 over the exact +header and payload; the SDK rejects a frame whose CRC disagrees and resynchronizes +at a later valid frame. This detects accidental frame corruption but is not an +authentication or adversarial-tamper mechanism. diff --git a/docs/04_recording.md b/docs/04_recording.md index d9206ee..f1941a3 100644 --- a/docs/04_recording.md +++ b/docs/04_recording.md @@ -97,6 +97,11 @@ the earlier rows as proof that the sensor remained alive. At a requested duratio boundary the recorder performs one final non-blocking read, so bytes already queued while the host was descheduled are included before that freshness check. +During capture, three seconds without a new row from any fitted modality is a hard +stream stall. The recorder stops immediately and seals `complete=false`; an open USB +handle that returns empty reads forever cannot make an unattended recording appear +to continue. This threshold is a host safety timeout, not a sensor-rate setting. + On an exception, the original exception is re-raised with `partial_episode` pointing to that directory; the CLI prints the path. @@ -104,8 +109,9 @@ This bounds SDK memory, but it is not proof of unlimited recording. A chunk flus a synchronous write and `fsync` on the same thread that drains USB; a slow Raspberry Pi SD-card stall can still cause receive loss. The SDK refuses to mark the episode complete when a sequence gap, overflow, malformed frame or -sustained freshness gap is observable. Supported firmware has no end-to-end CRC or -read-failure counters, so that is not proof that every short tail loss is detectable; +sustained freshness gap is observable. TAG v1 has no end-to-end CRC, while TAG v2 +rejects CRC-corrupt frames; neither exposes transport read-failure counters, so that +is not proof that every short tail loss is detectable; release qualification must measure it on the target storage. A hard process/power loss can also lose the not-yet-flushed RAM tail; there is not yet a recovery command that publishes the already-spooled hidden chunks. diff --git a/docs/06_compatibility.md b/docs/06_compatibility.md index d3810b2..60dcc28 100644 --- a/docs/06_compatibility.md +++ b/docs/06_compatibility.md @@ -5,24 +5,27 @@ physical gloves. They are different claims. ## Supported contract -| Component | Status in 0.1.0rc3 | +| Component | Status in 0.1.0rc4 | | --- | --- | | Python | 3.10 or newer | | Minimum supported firmware | 0.9.10 | -| Current golden firmware for new flashes | 0.9.11 | +| Current signed fleet image | 0.9.12 | | CONFIG schema | exactly 6 | -| USB tagged stream | supported and hardware-validated | +| USB TAG v1 (firmware 0.9.10-0.9.12) | supported; physical 0.9.10 pair validated | +| USB TAG v2 (firmware 0.9.13+) | parser/negotiation tested; physical release gate pending | | BLE schema-6 notifications | experimental; parser-tested, not release-qualified | | Firmware older than 0.9.10 | rejected for connect, replay, and vector capture | -`0.1.0rc3` has one firmware floor: 0.9.10. Live devices, checked-in vectors, and -recorded episodes below that floor fail closed. Firmware 0.9.11 keeps schema 6 and -the same SDK wire contract while adding a bounded TinyUSB write path; it is the -current image for new flashes. Deployed 0.9.10 gloves remain compatible. +`0.1.0rc4` has one firmware floor: 0.9.10. Live devices, checked-in vectors, and +recorded episodes below that floor fail closed. Firmware 0.9.10 through 0.9.12 use +TAG v1 and remain compatible. The SDK selects TAG v2 only when CONFIG advertises +`tag_ver_max >= 2`, requires the exact boot-scoped acknowledgement, and otherwise +stays on v1. The current signed fleet image is 0.9.12; 0.9.13 is not called +qualified until its signed bytes pass physical capture and HIL. ## Physical validation for this release candidate -The release candidate was exercised on one left and one right deployed glove running +The v1 path was exercised on one left and one right deployed glove running firmware 0.9.10/schema 6 over USB on macOS. The measured default delivery was about 250 tactile packets/s, 500 IMU packets/s, and 125 magnetometer packets/s per hand, with no capture-window sequence gaps, malformed frames, or host queue overflow in @@ -52,8 +55,10 @@ mutation through `GET ZERO`. - The two gloves do not share a hardware clock or trigger. - A nominal 500 IMU packets/s is transport cadence, not proof of 500 fresh physical sensor measurements per second. -- Supported firmware USB frames do not include an end-to-end payload CRC. -- Multi-hour recording, slow-storage stress, and device-clock rollover remain target - deployment qualification items. +- TAG v1 does not include an end-to-end payload CRC. TAG v2 adds CRC-32 over each + header and payload, but it is not cryptographic authentication. +- A TAG v1 long soak must cross its 71.6-minute u32 rollover. TAG v2 removes that + rollover but still needs a multi-hour dual-glove and slow-storage qualification. +- The canonical TAG v2 vectors are synthetic contract vectors, not physical evidence. Run `oglo doctor` on every host/glove combination before collecting a dataset. diff --git a/docs/08_release_hil.md b/docs/08_release_hil.md new file mode 100644 index 0000000..e5ecf45 --- /dev/null +++ b/docs/08_release_hil.md @@ -0,0 +1,100 @@ +# 0.9.13 release HIL and 72-hour soak + +`oglo hil` is the observation-only release gate for one specifically named left/right +pair. It does **not** contain a flash command, does not replace calibration and does +not select whichever two USB ports happen to enumerate first. + +The updater/factory station must install the candidate first. The HIL gate then +requires both CONFIG identities and the exact candidate version before it toggles +modem lines or starts a stream. A 0.9.10 or 0.9.12 unit therefore fails preflight with +an instruction to flash it separately; the runner does not silently change it. + +## 1. Prove the command without opening USB + +```bash +oglo hil \ + --left OGLO-L-00028 \ + --right OGLO-R-00028 \ + --firmware 0.9.13 \ + --output hil-results \ + --dry-run +``` + +The dry run validates the logical serial formats and binds the SDK parser to the +canonical `spec/TAG_V2.json` vectors. It writes the exact planned steps, JSON report, +Markdown report and SHA-256 manifest while opening no serial port. + +## 2. Run the bounded bench gate + +After installing the same 0.9.13 candidate on both named units: + +```bash +oglo hil \ + --left OGLO-L-00028 \ + --right OGLO-R-00028 \ + --firmware 0.9.13 \ + --output hil-results +``` + +The gate records immutable before/after CONFIG, STATUS, GET ZERO and GET FWINFO +snapshots. It then checks: + +1. the exact USB/logical identities, hands, firmware and running-image SHA-256; +2. `00 -> 10 -> 00 -> 11 -> 00 -> 01 -> 00` DTR/RTS behavior, followed by a safe + `10` postcheck that proves continuous uptime and boot identity; +3. real TAG1 and negotiated TAG2 frames from tactile, IMU and magnetometer streams; +4. every TAG2 CRC, sequence, u64 device timestamp and maximum device-time gap; +5. 20 close/reopen cycles per hand; +6. a 30-second unread-host interval followed by a fresh post-backlog capture; +7. a simultaneous short two-hand capture; +8. unchanged identity, calibration fingerprint, running image and device counters. + +The modem-line implementation opens the port exclusively with both lines already +low, passes through an explicit both-lines-low boundary on each transition and only +runs on the supported native-USB VID. There is no bootloader, reboot, factory-reset, +ZERO, SET or firmware-update command in this path. + +## 3. Start the real 72-hour gate + +The long run needs an additional confirmation containing both serials in left/right +order. This prevents a copied command from starting against a replacement unit. + +```bash +oglo hil \ + --left OGLO-L-00028 \ + --right OGLO-R-00028 \ + --firmware 0.9.13 \ + --output hil-results \ + --soak 72h \ + --window 30s \ + --confirm-soak OGLO-L-00028,OGLO-R-00028 +``` + +The runner refuses to start if the estimated artifacts would cross a 100 GiB free +disk reserve. It writes and fsyncs a rolling `soak-windows.jsonl` sidecar containing +per-hand rates, counts, missing/duplicate/backward sequences, CRC/structure failures, +u64 timestamp regressions, device-time maximum gaps and host-read maximum gaps. Raw +TAG2 bytes are retained by default; use `--no-soak-raw` only when the sidecar and +before/after evidence are sufficient for the release decision. + +If either hand fails, the peer capture is cancelled instead of continuing for the +remaining 72 hours. A passing unit test or short HIL run is not a substitute for the +completed 72-hour artifact. + +## Evidence + +Each run gets a new timestamped directory with: + +- `hil-report.json` and `hil-report.md`; +- an exact read-only copy of `TAG_V2.json` and its SHA-256/parser binding; +- read-only `before/left.json`, `before/right.json`, `after/left.json` and + `after/right.json` snapshots; +- real TAG capture files and per-capture summaries, reparsed from disk against the + same canonical TAG2 contract; +- reconnect and soak sidecars; +- best-effort bounded kernel USB logs where the host OS permits them; +- `manifest.sha256`, which covers every other evidence file and intentionally does + not hash itself. + +Preserve the complete directory as one release artifact. Never copy a firmware +binary, signing key or device credential into this evidence directory. diff --git a/docs/README.md b/docs/README.md index 6aa784e..d608657 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,9 @@ issues, pull requests, tags, releases, and documentation updates belong there. 5. [Recording and replay](04_recording.md) - episode format, two hands 6. [Troubleshooting](05_troubleshooting.md) - start with `oglo doctor` 7. [Test your own glove pair](07_acceptance.md) - guided public-API acceptance and reports +8. [0.9.13 release HIL](08_release_hil.md) - named-pair DTR/TAG/reconnect/stall gate and confirmed 72-hour soak The public wire-level contract needed by SDK users is documented in the -[data reference](02_data_reference.md) and locked by the captured vectors under -[`spec/vectors/`](../spec/vectors/). +[data reference](02_data_reference.md). TAG v1 is locked by the captured vectors +under [`spec/vectors/`](../spec/vectors/); the canonical TAG v2 contract and +synthetic vectors live in [`spec/TAG_V2.json`](../spec/TAG_V2.json). diff --git a/pyproject.toml b/pyproject.toml index f5541a8..a3f06b4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "oglo" -version = "0.1.0rc3" +version = "0.1.0rc4" description = "Python SDK for the OGLO five-finger tactile glove" readme = "README.md" requires-python = ">=3.10" @@ -52,6 +52,9 @@ exclude = [ [tool.hatch.build.targets.wheel] packages = ["src/oglo"] +[tool.hatch.build.targets.wheel.force-include] +"spec/TAG_V2.json" = "oglo/spec/TAG_V2.json" + [tool.pytest.ini_options] testpaths = ["tests"] pythonpath = ["src", "tests"] diff --git a/spec/TAG_V2.json b/spec/TAG_V2.json new file mode 100644 index 0000000..6d8e7c1 --- /dev/null +++ b/spec/TAG_V2.json @@ -0,0 +1,71 @@ +{ + "schema_version": 1, + "name": "oglo-tag-v2", + "status": "implementation-contract-not-hardware-captured", + "version": 2, + "frame": { + "magic_hex": "a55b", + "header_format": "<2sBHIQ", + "header_len": 17, + "payload_length": "payload_bytes_only", + "crc32": { + "algorithm": "CRC-32/ISO-HDLC", + "field_format": "`. CONFIG may expose +the same value only as exactly 32 lowercase hexadecimal characters. JSON integers +and uppercase strings are invalid. The SDK assembles a split ACK before decoding binary data, +compares it with CONFIG and any pre-pause session identity, captures it at stream +start, and clears it before every new CONFIG exchange. + +The host drains bytes buffered before sending `STREAM TAG2 ON`. The first complete +response after that command must be the exact ACK; no diagnostic-line whitelist is +part of the protocol. An unknown line, `#ERR`, malformed `#STREAM TAG2`, binary byte, +or timeout fails closed. Bytes following the ACK newline are the first binary frame +bytes and must be preserved. Firmware must write the ACK as one checked complete +response before enabling TAG2. It must not depend on RTS; the host asserts DTR and +keeps RTS low. + +That means a CONFIG `boot_id` proves which boot a newly started stream belongs to, +but cannot by itself prove that the device did not reboot in the middle of an open +binary stream. Mid-stream reboot detection needs one of these firmware decisions: + +1. include a boot/session identifier in every frame or a periodic authenticated + sideband record, or +2. terminate/re-enumerate USB on reboot so the host must run CONFIG negotiation again. + +The ACK bytes and boot-id width are locked, but still need real firmware captures +before 0.9.13 is called qualified. + +## Test vectors + +`TAG_V2.json` locks negotiation, identity encoding, CRC parameters, all three +modality layouts, endian order, timestamps beyond multiple u32 epochs, and expected +values. It is deliberately labelled `implementation-contract-not-hardware-captured`. +Final release evidence must add bytes +captured from the tagged 0.9.13 firmware artifact rather than relabeling synthetic +vectors as hardware truth. diff --git a/src/oglo/__init__.py b/src/oglo/__init__.py index 1360575..d3e88bf 100644 --- a/src/oglo/__init__.py +++ b/src/oglo/__init__.py @@ -30,11 +30,12 @@ from ._status import DeviceStatus, StatusError from ._record import RecordError, record from ._replay import Episode, ReplayError, replay -from ._usb import (DisconnectedError, NoGloveFound, PortBusyError, UsbError, - find_port, list_candidates, open_serial) +from ._usb import (DisconnectedError, NoGloveFound, PortBusyError, + SessionChangedError, UsbError, find_port, list_candidates, + open_serial) from ._usb import UsbTransport as _UsbTransport -__version__ = "0.1.0rc3" +__version__ = "0.1.0rc4" __all__ = [ "connect", @@ -58,6 +59,7 @@ "PortBusyError", "NoGloveFound", "DisconnectedError", + "SessionChangedError", "RecordError", "ReplayError", ] diff --git a/src/oglo/_config.py b/src/oglo/_config.py index 0157299..2576d16 100644 --- a/src/oglo/_config.py +++ b/src/oglo/_config.py @@ -1,4 +1,4 @@ -"""Validate the single supported OGLO contract and expose its runtime state. +"""Validate the supported OGLO schema and expose its runtime capabilities. This SDK intentionally starts at firmware 0.9.10. Older firmware and schemas are rejected at connect time instead of entering a compatibility mode whose semantics differ. @@ -12,6 +12,7 @@ from dataclasses import dataclass, field from typing import Any, Dict, List, Optional, Tuple +from ._tag_contract import canonical_boot_id from ._wire import NUM_COLS, NUM_FINGERS, ROWS_PER_FINGER, TAXELS MIN_FIRMWARE = (0, 9, 10) @@ -55,6 +56,15 @@ class Info: #: the SDK knows about it. raw: Dict[str, Any] = field(default_factory=dict) + #: Highest USB TAG framing version the firmware says it can emit. Missing on + #: 0.9.10-0.9.12 and therefore explicitly defaults to v1. These additive fields + #: follow ``raw`` so the established positional constructor remains compatible. + tag_ver_max: int = 1 + + #: Optional boot/session identity reported by CONFIG. The only accepted wire + #: representation is exactly 32 lowercase hexadecimal characters. + boot_id: Optional[str] = None + @property def is_left(self) -> bool: return self.side == "left" @@ -67,6 +77,7 @@ class Capabilities: values_per_sample: int imu_len: int has_mag: bool + tag_ver_max: int = 1 def parse_config(cfg: Dict[str, Any], *, transport: str = "usb") -> Tuple[Info, Capabilities]: @@ -153,6 +164,11 @@ def parse_config(cfg: Dict[str, Any], *, transport: str = "usb") -> Tuple[Info, if device_dropped < 0: raise ConfigError("device drop counter cannot be negative") + tag_ver_max = _optional_config_int(cfg, "tag_ver_max", default=1) + if not 1 <= tag_ver_max <= 255: + raise ConfigError(f"tag_ver_max={tag_ver_max}; expected 1..255") + boot_id = _optional_boot_id(cfg) + info = Info( serial=serial, side=side, @@ -167,12 +183,15 @@ def parse_config(cfg: Dict[str, Any], *, transport: str = "usb") -> Tuple[Info, stream_thr=stream_thr, imu_period_ms=None, device_dropped=device_dropped, + tag_ver_max=tag_ver_max, + boot_id=boot_id, raw=dict(cfg), ) caps = Capabilities( values_per_sample=vps, imu_len=imu_len, has_mag=info.has_mag, + tag_ver_max=tag_ver_max, ) return info, caps @@ -186,6 +205,23 @@ def _config_int(cfg: Dict[str, Any], name: str) -> int: return value +def _optional_config_int(cfg: Dict[str, Any], name: str, *, default: int) -> int: + if name not in cfg: + return default + return _config_int(cfg, name) + + +def _optional_boot_id(cfg: Dict[str, Any]) -> Optional[str]: + """Validate CONFIG boot identity against the exact TAG2 ACK representation.""" + if "boot_id" not in cfg: + return None + value = cfg["boot_id"] + try: + return canonical_boot_id(value) + except (TypeError, ValueError) as exc: + raise ConfigError(str(exc)) from exc + + def _config_bool(cfg: Dict[str, Any], name: str) -> bool: if name not in cfg: raise ConfigError(f"config is missing {name}") diff --git a/src/oglo/_frame.py b/src/oglo/_frame.py index 5dce66a..ecd1c33 100644 --- a/src/oglo/_frame.py +++ b/src/oglo/_frame.py @@ -65,7 +65,7 @@ class Frame: """ seq: int - #: Device microseconds since its own power-on. **Never align two gloves on this.** + #: Low u32 of device microseconds. **Never align two gloves on this.** t_us: int #: Host receive-boundary monotonic seconds. It relates devices approximately, #: but USB/BLE buffering means it is not hardware-synchronised sample time. @@ -75,8 +75,8 @@ class Frame: #: device discarded itself, which is exposed by ``glove.status()``. dropped: int = 0 - #: Device time unwrapped across the 32-bit micros() rollover. Its epoch is - #: deliberately arbitrary; use ordering/differences, not the absolute origin. + #: Native TAG v2 u64 time, or TAG v1 time unwrapped across u32 rollover. Its + #: origin is device-local; use ordering/differences, not cross-glove alignment. device_time_us: Optional[int] = None #: Host monotonic timestamp at the USB-read/BLE-notify boundary, in nanoseconds. #: Samples decoded from the same transport batch intentionally share it. diff --git a/src/oglo/_record.py b/src/oglo/_record.py index d2621f0..6659e19 100644 --- a/src/oglo/_record.py +++ b/src/oglo/_record.py @@ -39,6 +39,11 @@ from ._frame import Frame, ImuSample, MagSample SCHEMA = 2 +# A recording must not wait forever on a transport that remains open but emits +# no usable sensor frames. This is deliberately far above normal USB jitter and +# the fitted stream periods, while short enough to stop a bad episode before a +# field operator assumes it is still collecting data. +RECORDING_STREAM_SILENCE_S = 3.0 class RecordError(RuntimeError): @@ -224,6 +229,9 @@ def __init__(self, glove: Any, path: Path, *, chunk_samples: int = 4096) -> None # finalization let a later RAW/CLEAN/threshold/rate change retroactively # relabel earlier rows. Info is frozen, but its list/dict members are not. self.info = deepcopy(glove.info) + transport = getattr(glove, "_t", None) + self.tag_version = getattr(transport, "tag_version", None) + self.stream_boot_id = deepcopy(getattr(transport, "stream_boot_id", None)) self.dir = Path(path) self._work = self.dir / f".recording-{uuid4().hex}" chunks = self._work / "chunks" @@ -247,6 +255,9 @@ def __init__(self, glove: Any, path: Path, *, chunk_samples: int = 4096) -> None self._last_added_mono: Dict[str, Optional[float]] = { "tactile": None, "imu": None, "mag": None, } + self._last_progress_mono: Dict[str, Optional[float]] = { + "tactile": None, "imu": None, "mag": None, + } def add_tactile(self, f: Frame) -> None: self._stamp() @@ -431,6 +442,12 @@ def delta(name: str) -> Optional[int]: "channels": list(info.channels), "has_mag": info.has_mag, "transport": info.transport, + # Wire/session provenance. A native u64 timestamp is only meaningful if + # the recording says it came from TAG v2, and boot identity must be the + # value captured at stream start rather than a later CONFIG observation. + "tag_version": self.tag_version if info.transport == "usb" else None, + "tag_ver_max": info.tag_ver_max, + "boot_id": self.stream_boot_id, # The calibration IN FORCE AT CAPTURE TIME. `stream_thr` is mutable on the # device, so asking the board later returns today's value, not the one # this data was taken under. Without it the counts cannot be interpreted. @@ -583,32 +600,61 @@ def publish(*, complete: bool, error: Optional[str] = None, raise add = {"tactile": rec.add_tactile, "imu": rec.add_imu, "mag": rec.add_mag} - def drain_once() -> bool: + def drain_once(progress_at: float) -> bool: if hasattr(glove, "read_batch"): ready = glove.read_batch().as_dict() else: ready = glove._drain_ready() for name, items in ready.items(): + if items: + # This timestamp is recorder-local progress, separate from + # the sample's preserved host receive timestamp. Custom + # adapters may use another monotonic epoch in their sample. + rec._last_progress_mono[name] = progress_at fn = add[name] for item in items: fn(item) return any(ready.values()) + required_streams = ["tactile", "imu"] + ( + ["mag"] if glove.info.has_mag else [] + ) + + def raise_on_silent_stream(now: float) -> None: + """Fail closed if any fitted modality stops making progress.""" + start = rec._started_mono + if start is None: + return + stale = [] + for name in required_streams: + last = rec._last_progress_mono[name] + age = now - (last if last is not None else start) + if age >= RECORDING_STREAM_SILENCE_S: + stale.append(f"{name}:{age:.3f}s") + if stale: + raise RecordError( + "recording stream stalled for at least " + f"{RECORDING_STREAM_SILENCE_S:g}s: " + ", ".join(stale) + ) + deadline = None if seconds is None else time.monotonic() + seconds stop_reason = "duration" if seconds is not None else "requested" try: - while deadline is None or time.monotonic() < deadline: + loop_now = time.monotonic() + while deadline is None or loop_now < deadline: # Take everything each stream has ready, not one from each in turn. # One-each throttles every stream to the slowest: the IMU produces # twice what tactile does, so half of it would be lost to queue # overflow and the episode would come back with three equal counts. - if not drain_once(): + if not drain_once(loop_now): time.sleep(0.0005) + loop_now = time.monotonic() + raise_on_silent_stream(loop_now) # A busy host can be descheduled across the deadline while USB bytes # accumulate. Do one final non-blocking transport read before freezing # the capture clock; otherwise the deadline check wins without a poll # and a healthy buffered tail is misreported as every modality stopping. - drain_once() + drain_once(loop_now) except KeyboardInterrupt: stop_reason = "keyboard_interrupt" except BaseException as exc: diff --git a/src/oglo/_replay.py b/src/oglo/_replay.py index cea608a..d4ec55d 100644 --- a/src/oglo/_replay.py +++ b/src/oglo/_replay.py @@ -23,6 +23,7 @@ from ._config import MIN_FIRMWARE, Info, _fw_at_least from ._frame import Frame, ImuSample, MagSample +from ._tag_contract import canonical_boot_id from ._wire import classify_seq @@ -276,6 +277,28 @@ def _schema2_info(meta: Dict[str, Any]) -> Info: rate_hz = _json_int(meta, "rate_hz", 1, 1000) device_dropped = _json_int(meta, "device_dropped_at_connect", 0) + # Added with TAG v2; old schema-2 episodes remain valid and explicitly mean v1. + tag_ver_max_value = meta.get("tag_ver_max", 1) + if type(tag_ver_max_value) is not int or not 1 <= tag_ver_max_value <= 255: + raise ReplayError("meta.json tag_ver_max must be a JSON integer in 1..255") + tag_ver_max = tag_ver_max_value + boot_id = meta.get("boot_id") + if boot_id is not None: + try: + boot_id = canonical_boot_id(boot_id) + except (TypeError, ValueError) as exc: + raise ReplayError(f"meta.json {exc}") from exc + tag_version = meta.get("tag_version") + if tag_version is not None: + if type(tag_version) is not int or not 1 <= tag_version <= 2: + raise ReplayError("meta.json tag_version must be null or a supported JSON integer") + if tag_version > tag_ver_max: + raise ReplayError("meta.json tag_version cannot exceed tag_ver_max") + if tag_version == 2 and boot_id is None: + raise ReplayError("meta.json TAG2 provenance requires a canonical boot_id") + if tag_version == 2 and transport != "usb": + raise ReplayError("meta.json TAG2 provenance is impossible over a non-USB transport") + imu_period_value = _required(meta, "imu_period_ms") if imu_period_value is None: imu_period_ms = None @@ -310,6 +333,8 @@ def _schema2_info(meta: Dict[str, Any]) -> Info: stream_thr=stream_thr, imu_period_ms=imu_period_ms, device_dropped=device_dropped, + tag_ver_max=tag_ver_max, + boot_id=boot_id, raw=dict(meta), ) diff --git a/src/oglo/_stream.py b/src/oglo/_stream.py index 10d0286..2a9141e 100644 --- a/src/oglo/_stream.py +++ b/src/oglo/_stream.py @@ -244,14 +244,11 @@ def pump(self) -> int: def _prepare(self, p: Any, fallback_received_ns: int) -> List[_Prepared]: received_ns = int(getattr(p, "host_received_ns", None) or fallback_received_ns) if isinstance(p, w.TactilePacket): - raw = p.t_us & 0xFFFFFFFF - return [_Prepared("tactile", p, raw, self._clock.unwrap(raw), received_ns)] + return [self._prepare_usb("tactile", p, received_ns)] if isinstance(p, w.ImuPacket): - raw = p.t_us & 0xFFFFFFFF - return [_Prepared("imu", p, raw, self._clock.unwrap(raw), received_ns)] + return [self._prepare_usb("imu", p, received_ns)] if isinstance(p, w.MagPacket): - raw = p.t_us & 0xFFFFFFFF - return [_Prepared("mag", p, raw, self._clock.unwrap(raw), received_ns)] + return [self._prepare_usb("mag", p, received_ns)] if isinstance(p, w.BleSample): tactile_raw = p.t_us & 0xFFFFFFFF tactile_us = self._clock.unwrap(tactile_raw) @@ -275,6 +272,14 @@ def _prepare(self, p: Any, fallback_received_ns: int) -> List[_Prepared]: self.unrouted += 1 return [] + def _prepare_usb(self, kind: str, packet: Any, received_ns: int) -> _Prepared: + raw = int(packet.t_us) & 0xFFFFFFFF + full = getattr(packet, "device_time_us", None) + # TAG v2 carries the real u64 device clock. TAG v1 leaves this field absent, + # retaining the tested host-side u32 unwrap and cross-modality reorder logic. + device_us = int(full) if full is not None else self._clock.unwrap(raw) + return _Prepared(kind, packet, raw, device_us, received_ns) + def _route(self, sample: _Prepared, host_t_ns: int) -> None: p = sample.packet host_t = host_t_ns / 1_000_000_000.0 diff --git a/src/oglo/_tag_contract.py b/src/oglo/_tag_contract.py new file mode 100644 index 0000000..2ed8b0c --- /dev/null +++ b/src/oglo/_tag_contract.py @@ -0,0 +1,91 @@ +"""Versioned USB TAG framing constants. + +Keep the bytes and commands in one small module: changing a magic value in a parser +or a fake without changing firmware produces a stream that looks valid in tests and +can never exist on a board. TAG v2 is additive; v1 remains the explicit fallback +for firmware whose CONFIG omits ``tag_ver_max`` or reports 1. +""" + +from __future__ import annotations + +import struct +from dataclasses import dataclass + + +@dataclass(frozen=True) +class TagContract: + version: int + magic: bytes + header: struct.Struct + start_command: str + stop_command: str + mode_name: str + crc: struct.Struct | None = None + + +# Firmware 0.9.10-0.9.12: magic, type u8, payload_len u16, seq u32, +# timestamp_us u32. Little endian, packed, no implicit alignment. +TAG_V1 = TagContract( + version=1, + magic=b"\xa5\x5a", + header=struct.Struct("<2sBHII"), + start_command="STREAM TAG ON", + stop_command="STREAM TAG OFF", + mode_name="tagged", +) + +# Firmware contract approved for 0.9.13 implementation: the distinct magic makes +# an accidental v1/v2 decoder mismatch fail closed. The header widens timestamp +# width and every frame ends with a little-endian CRC over header plus payload. +TAG_V2 = TagContract( + version=2, + magic=b"\xa5\x5b", + header=struct.Struct("<2sBHIQ"), + start_command="STREAM TAG2 ON", + stop_command="STREAM TAG2 OFF", + mode_name="tagged_v2", + crc=struct.Struct(" str: + """Validate the one wire representation used by CONFIG and the TAG2 ACK. + + This intentionally does not normalize integers or uppercase text. JSON numbers + cannot carry a uint128 portably through JavaScript, and accepting a second text + form would make CONFIG and the exact start acknowledgement different contracts. + """ + if ( + isinstance(value, str) + and len(value) == BOOT_ID_HEX_CHARS + and all(character in "0123456789abcdef" for character in value) + ): + return value + raise ValueError("boot_id must be exactly 32 lowercase hexadecimal characters") + + +def parse_tag2_ack(line: bytes) -> str: + """Parse the exact firmware ACK and return a canonical boot identity.""" + if not line.startswith(TAG2_ACK_PREFIX): + raise ValueError("TAG2 start reply has the wrong prefix") + value = line[len(TAG2_ACK_PREFIX):] + try: + text = value.decode("ascii") + except UnicodeDecodeError as exc: + raise ValueError("TAG2 boot_id is not ASCII") from exc + return canonical_boot_id(text) + + +def tag_contract(version: int) -> TagContract: + """Return the exact supported contract; never guess a future layout.""" + if version == TAG_V1.version: + return TAG_V1 + if version == TAG_V2.version: + return TAG_V2 + raise ValueError(f"unsupported TAG version {version}") diff --git a/src/oglo/_usb.py b/src/oglo/_usb.py index 947f4e4..b45a1c3 100644 --- a/src/oglo/_usb.py +++ b/src/oglo/_usb.py @@ -13,6 +13,7 @@ from __future__ import annotations +import math import os import subprocess import time @@ -22,6 +23,7 @@ from . import _wire as w from ._config import Capabilities, Info, parse_config from ._status import DeviceStatus, parse_status +from ._tag_contract import SDK_TAG_VERSION_MAX, TAG_V2, parse_tag2_ack, tag_contract #: Supported firmware 0.9.10+ uses TinyUSB on the Seeed XIAO module with #: ``OGLO`` / ``OpenGraphLabs`` descriptors. Discovery keys on the stable VID and @@ -35,7 +37,7 @@ # A previous process may have died in any mode (the browser viewer still uses BIN). # Stop all producers before asking for text; this is deliberately idempotent. -_HANDSHAKE_STOP = "STREAM BIN OFF\nSTREAM TAXEL OFF\nSTREAM TAG OFF" +_HANDSHAKE_STOP = "STREAM BIN OFF\nSTREAM TAXEL OFF\nSTREAM TAG OFF\nSTREAM TAG2 OFF" _CONFIG_PREFIX = "#CONFIG " _STATUS_PREFIX = "#STATUS " @@ -44,7 +46,7 @@ class SerialLike(Protocol): """The slice of pyserial this module uses. A fake only has to provide this.""" def read(self, size: int = 1) -> bytes: ... - def write(self, data: bytes) -> Optional[int]: ... + def write(self, data: bytes) -> int: ... def flush(self) -> None: ... def reset_input_buffer(self) -> None: ... def close(self) -> None: ... @@ -73,6 +75,10 @@ class DisconnectedError(UsbError): """ +class SessionChangedError(UsbError): + """The board rebooted or changed boot identity inside one logical session.""" + + class PortBusyError(UsbError): """The port exists but something else owns it. @@ -197,7 +203,8 @@ def open_serial(device: str, baud: int = 115200, *, settle: float = 0.8) -> Seri Asserting it is safe. The auto-reset circuit that rule was written for belongs to the UART bridge, not to native USB; verified by reading `uptime_ms` across a reopen (127174 -> 131043 ms, still counting). **RTS stays low**, because the two - together are what a bridge decodes as a reset request. + together are what a bridge decodes as a reset request. Firmware streaming and + TAG2 acknowledgement are deliberately independent of RTS; the SDK keeps it low. """ import serial as pyserial @@ -258,6 +265,8 @@ def __init__(self, serial_like: SerialLike, *, owns_port: bool = True) -> None: self._caps: Optional[Capabilities] = None self._info: Optional[Info] = None self._streaming = False + self._tag_version = 1 + self._stream_boot_id: Optional[str] = None self._last_seq: Dict[int, Optional[int]] = { w.TAG_TACTILE: None, w.TAG_IMU: None, w.TAG_MAG: None } @@ -266,8 +275,17 @@ def __init__(self, serial_like: SerialLike, *, owns_port: bool = True) -> None: # -- commands --------------------------------------------------------------- def send(self, command: str) -> None: + payload = (command.rstrip("\n") + "\n").encode() try: - self._s.write((command.rstrip("\n") + "\n").encode()) + written = self._s.write(payload) + # pyserial's contract is the number of bytes accepted. Treat a custom + # serial adapter returning None, zero, or a short count as an unknown + # command boundary: retrying could concatenate a second command onto a + # prefix the firmware already received. + if type(written) is not int or written != len(payload): + raise OSError( + f"short serial write: accepted {written!r} of {len(payload)} bytes" + ) self._s.flush() except Exception as exc: raise DisconnectedError( @@ -304,7 +322,15 @@ def read_config( output drained before a text reply is findable, and one that just enumerated may not have run `setup()` yet. """ + # A reconnect is a new observation boundary. Never carry a boot identity + # across it if this CONFIG attempt fails or the board has rebooted. + self._stream_boot_id = None + self._config = None + self._info = None + self._caps = None + self._tag_version = 1 self.send(_HANDSHAKE_STOP) + self._streaming = False if drain: time.sleep(drain) # let a stopped stream finish draining before we read text self._s.reset_input_buffer() @@ -359,19 +385,112 @@ def caps(self) -> Capabilities: raise UsbError("read_config() first") return self._caps - def start(self, *, reset_counters: bool = True) -> str: - """Begin the supported firmware-0.9.10+ tagged stream.""" + @property + def tag_version(self) -> int: + """TAG framing selected for the current/most recently started stream.""" + return self._tag_version + + @property + def stream_boot_id(self) -> Optional[str]: + """CONFIG boot identity captured at stream start, when firmware provides it.""" + return self._stream_boot_id + + def start(self, *, reset_counters: bool = True, ack_timeout: float = 2.0) -> str: + """Begin the highest mutually supported tagged stream. + + A missing/1 ``tag_ver_max`` is an explicit v1 selection. Future values are + capped at the newest contract this SDK actually knows rather than guessed. + """ + previous_boot_id = self._stream_boot_id if not reset_counters else None self._buf = b"" self._last_seq = {k: None for k in self._last_seq} if reset_counters: self.dropped = StreamCounters() self._s.reset_input_buffer() - self.send("STREAM TAG ON") - self._streaming = True - return "tagged" + selected = min(self.caps.tag_ver_max, SDK_TAG_VERSION_MAX) + contract = tag_contract(selected) + self._tag_version = selected + if contract.version == TAG_V2.version: + try: + self.send(contract.start_command) + self._streaming = True + ack_boot_id = self._read_tag2_ack(timeout=ack_timeout) + expected_ids = [ + value for value in (self.info.boot_id, previous_boot_id) if value is not None + ] + if any(value != ack_boot_id for value in expected_ids): + expected = ", ".join(expected_ids) + raise SessionChangedError( + "TAG2 boot identity changed before stream start/resume: " + f"expected {expected}, ACK reported {ack_boot_id}. " + "Discard this capture boundary and reconnect." + ) + self._stream_boot_id = ack_boot_id + except BaseException: + self._abort_tag2_start() + raise + else: + self.send(contract.start_command) + self._streaming = True + self._stream_boot_id = self.info.boot_id + return contract.mode_name + + def _read_tag2_ack(self, *, timeout: float) -> str: + """Read one split-safe TAG2 ACK and retain following binary bytes. + + ``start()`` clears all input already buffered before it sends the command. + The first complete response after that boundary must be the exact ACK; host + code therefore does not need to know or whitelist firmware diagnostic logs. + """ + if ( + isinstance(timeout, bool) + or not isinstance(timeout, (int, float)) + or not math.isfinite(float(timeout)) + or timeout <= 0 + ): + raise ValueError("ack_timeout must be a finite positive number of seconds") + deadline = time.monotonic() + float(timeout) + pending = bytearray() + while time.monotonic() < deadline: + chunk = self._read(8192) + if chunk: + pending += chunk + newline = pending.find(b"\n") + if newline >= 0: + line = bytes(pending[:newline]).removesuffix(b"\r") + trailing = bytes(pending[newline + 1:]) + try: + boot_id = parse_tag2_ack(line) + except ValueError as exc: + raise UsbError( + f"malformed TAG2 start ACK: {line[:96]!r} ({exc})" + ) from exc + self._buf = trailing + return boot_id + # Prefix + 32 hex + optional CRLF. Anything longer without a + # newline cannot become the exact response later. + if len(pending) > len(b"#STREAM TAG2 on boot_id=") + 32 + 2: + raise UsbError("malformed TAG2 start ACK: response is too long") + else: + time.sleep(0.005) + raise UsbError(f"no TAG2 start ACK from the board within {timeout:g}s") + + def _abort_tag2_start(self) -> None: + """Best-effort rollback that preserves the original ACK/session error.""" + try: + self.send(TAG_V2.stop_command) + except BaseException: + pass + self._streaming = False + self._stream_boot_id = None + self._buf = b"" + try: + self._s.reset_input_buffer() + except BaseException: + pass def stop(self) -> None: - self.send("STREAM TAG OFF") + self.send(tag_contract(self._tag_version).stop_command) self._streaming = False def drain(self, settle: float = 0.2) -> None: @@ -402,7 +521,9 @@ def poll(self, size: int = 8192) -> List[Any]: self._buf += chunk if not self._buf: return [] - packets, self._buf, malformed = w.iter_tagged_diagnostic(self._buf) + packets, self._buf, malformed = w.iter_tagged_version_diagnostic( + self._buf, self._tag_version + ) self.dropped.malformed_usb += malformed if received_ns is not None: packets = [replace(p, host_received_ns=received_ns) for p in packets] diff --git a/src/oglo/_wire.py b/src/oglo/_wire.py index 92cbff2..46b842e 100644 --- a/src/oglo/_wire.py +++ b/src/oglo/_wire.py @@ -4,21 +4,22 @@ here is a function of its arguments, which is what makes the golden vectors in `spec/vectors/` possible: the same bytes must decode to the same values forever. -The public contract is documented in `docs/02_data_reference.md` and locked by the -captured vectors under `spec/vectors/`. The implementation was also read back from -the current firmware source (`oglo_rdr02_tia.ino`, FW 0.9.11) rather than inferred -from prose alone. - -There is one supported wire contract: firmware 0.9.10+, schema 6. USB is the tagged -stream with packed12 tactile payloads; BLE is the packed schema-6 notification. +The public contract is documented in `docs/02_data_reference.md` and locked by +vectors under `spec/vectors/`. TAG v1 was read back from firmware rather than +inferred from prose. TAG v2 has a distinct magic, a 64-bit timestamp, and a CRC; +its canonical synthetic vectors live in `spec/TAG_V2.json` until physical release +evidence is captured separately. """ from __future__ import annotations import struct +import zlib from dataclasses import dataclass from typing import Iterator, List, Optional, Sequence, Tuple +from ._tag_contract import TAG_V1, TAG_V2, TagContract, tag_contract + # --- constants, all confirmed against the firmware source --------------------- NUM_FINGERS = 5 @@ -34,9 +35,13 @@ GYRO_LSB_PER_DPS = 16.4 MAG_LSB_PER_GAUSS = 6842.0 -# Tagged USB stream (STREAM TAG ON). -TAG_MAGIC = b"\xa5\x5a" -TAG_HDR_LEN = 13 +# Tagged USB stream. Preserve the original aliases for downstream code that imports +# the v1 constants while exposing an unambiguous v2 contract alongside them. +TAG_MAGIC = TAG_V1.magic +TAG_HDR_LEN = TAG_V1.header.size +TAG_V2_MAGIC = TAG_V2.magic +TAG_V2_HDR_LEN = TAG_V2.header.size +TAG_V2_CRC_LEN = TAG_V2.crc.size if TAG_V2.crc is not None else 0 TAG_TACTILE, TAG_IMU, TAG_MAG = 1, 2, 3 #: 80 taxels x 12 bits, the only tactile payload supported by firmware 0.9.10+. @@ -106,6 +111,8 @@ class TactilePacket: counts: List[int] # length 80, order finger,row,col #: Host monotonic time at the transport receive boundary, not decoder time. host_received_ns: Optional[int] = None + #: Present only for TAG v2. ``t_us`` remains its low u32 for API compatibility. + device_time_us: Optional[int] = None @dataclass(frozen=True) @@ -116,6 +123,7 @@ class ImuPacket: gyro: Tuple[float, float, float] # deg/s raw: Tuple[int, int, int, int, int, int] host_received_ns: Optional[int] = None + device_time_us: Optional[int] = None @dataclass(frozen=True) @@ -125,6 +133,7 @@ class MagPacket: field: Tuple[float, float, float] # gauss raw: Tuple[int, int, int] host_received_ns: Optional[int] = None + device_time_us: Optional[int] = None @dataclass(frozen=True) @@ -170,31 +179,72 @@ def iter_tagged_diagnostic(buf: bytes) -> Tuple[List[object], bytes, int]: frame. The returned count lets transports make that silent resynchronisation visible without changing the long-standing two-value :func:`iter_tagged` API. """ + return _iter_tagged_diagnostic(buf, TAG_V1) + + +def iter_tagged_v2(buf: bytes) -> Tuple[List[object], bytes]: + """Decode every whole TAG v2 packet in ``buf``. + + TAG v2 is deliberately a separate entry point: callers must negotiate the + version from CONFIG and cannot make a byte stream look valid by guessing. + """ + packets, remainder, _malformed = iter_tagged_v2_diagnostic(buf) + return packets, remainder + + +def iter_tagged_v2_diagnostic(buf: bytes) -> Tuple[List[object], bytes, int]: + """Decode TAG v2 packets and count structurally invalid v2 headers.""" + return _iter_tagged_diagnostic(buf, TAG_V2) + + +def iter_tagged_version_diagnostic( + buf: bytes, version: int +) -> Tuple[List[object], bytes, int]: + """Decode an already-negotiated TAG version; unsupported versions fail closed.""" + return _iter_tagged_diagnostic(buf, tag_contract(version)) + + +def _iter_tagged_diagnostic( + buf: bytes, contract: TagContract +) -> Tuple[List[object], bytes, int]: packets: List[object] = [] malformed = 0 i = 0 n = len(buf) while True: - j = buf.find(TAG_MAGIC, i) + j = buf.find(contract.magic, i) if j < 0: # Keep one byte: the magic may straddle this read and the next. return packets, buf[max(i, n - 1):], malformed - if n - j < TAG_HDR_LEN: + if n - j < contract.header.size: return packets, buf[j:], malformed - ptype = buf[j + 2] - plen, seq, t_us = struct.unpack_from(" n: + payload_end = j + contract.header.size + plen + frame_end = payload_end + (contract.crc.size if contract.crc is not None else 0) + if frame_end > n: return packets, buf[j:], malformed - payload = buf[j + TAG_HDR_LEN:end] - pkt = _decode_tagged(ptype, seq, t_us, payload) + payload = buf[j + contract.header.size:payload_end] + if contract.crc is not None: + (expected_crc,) = contract.crc.unpack_from(buf, payload_end) + observed_crc = zlib.crc32(buf[j:payload_end]) + if observed_crc != expected_crc: + malformed += 1 + i = j + 2 + continue + pkt = _decode_tagged( + ptype, + seq, + timestamp_us & 0xFFFFFFFF, + payload, + device_time_us=timestamp_us if contract.version == 2 else None, + ) if pkt is not None: packets.append(pkt) - i = end + i = frame_end def _tag_len_ok(ptype: int, plen: int) -> bool: @@ -207,9 +257,18 @@ def _tag_len_ok(ptype: int, plen: int) -> bool: return False -def _decode_tagged(ptype: int, seq: int, t_us: int, payload: bytes): +def _decode_tagged( + ptype: int, + seq: int, + t_us: int, + payload: bytes, + *, + device_time_us: Optional[int] = None, +): if ptype == TAG_TACTILE: - return TactilePacket(seq=seq, t_us=t_us, counts=unpack12(payload)) + return TactilePacket( + seq=seq, t_us=t_us, counts=unpack12(payload), device_time_us=device_time_us + ) if ptype == TAG_IMU: raw = struct.unpack_from("<6h", payload, 0) return ImuPacket( @@ -218,6 +277,7 @@ def _decode_tagged(ptype: int, seq: int, t_us: int, payload: bytes): accel=tuple(v / ACCEL_LSB_PER_G for v in raw[:3]), gyro=tuple(v / GYRO_LSB_PER_DPS for v in raw[3:]), raw=raw, + device_time_us=device_time_us, ) if ptype == TAG_MAG: raw = struct.unpack_from("<3h", payload, 0) @@ -226,6 +286,7 @@ def _decode_tagged(ptype: int, seq: int, t_us: int, payload: bytes): t_us=t_us, field=tuple(v / MAG_LSB_PER_GAUSS for v in raw), raw=raw, + device_time_us=device_time_us, ) return None diff --git a/src/oglo/cli.py b/src/oglo/cli.py index fafdddd..61faaeb 100644 --- a/src/oglo/cli.py +++ b/src/oglo/cli.py @@ -120,6 +120,35 @@ def _cmd_acceptance(args: argparse.Namespace) -> int: return 2 if report.failed else 0 +def _cmd_hil(args: argparse.Namespace) -> int: + from pathlib import Path + + from .hil import HilConfig, run_hil + + config = HilConfig( + left_serial=args.left, + right_serial=args.right, + output_root=Path(args.output), + expected_firmware=args.firmware, + tag_seconds=args.tag_seconds, + reconnect_cycles=args.reconnect_cycles, + reconnect_seconds=args.reconnect_seconds, + stall_seconds=args.stall, + recovery_seconds=args.recovery, + short_seconds=args.short, + soak_seconds=args.soak, + window_seconds=args.window, + confirm_soak=args.confirm_soak, + dry_run=args.dry_run, + store_soak_raw=not args.no_soak_raw, + tag2_spec=Path(args.tag2_spec) if args.tag2_spec else None, + ) + report = run_hil(config) + print(f"HIL result: {report.result}") + print(f"Evidence: {report.run_dir}") + return 0 if report.result in ("pass", "dry-run") else 2 + + def main(argv: Optional[List[str]] = None) -> int: p = argparse.ArgumentParser(prog="oglo", description="OGLO tactile glove") sub = p.add_subparsers(dest="cmd", required=True) @@ -213,6 +242,68 @@ def main(argv: Optional[List[str]] = None) -> int: ) a.set_defaults(func=_cmd_acceptance) + h = sub.add_parser( + "hil", + help="run the observation-only 0.9.13 release HIL/soak gate for an exact pair", + ) + h.add_argument("--left", required=True, help="exact logical serial, e.g. OGLO-L-00028") + h.add_argument("--right", required=True, help="exact logical serial, e.g. OGLO-R-00028") + h.add_argument( + "--firmware", default="0.9.13", help="exact candidate firmware required (default: 0.9.13)" + ) + h.add_argument( + "--output", default="hil-results", help="root for a new evidence directory" + ) + h.add_argument( + "--tag-seconds", type=parse_duration, default=3.0, + help="per-version, per-hand TAG capture window (default: 3s)", + ) + h.add_argument( + "--reconnect-cycles", type=int, default=20, + help="close/reopen cycles per hand (default: 20)", + ) + h.add_argument( + "--reconnect-seconds", type=parse_duration, default=0.5, + help="fresh TAG2 capture per reconnect (default: 500ms)", + ) + h.add_argument( + "--stall", type=parse_duration, default=30.0, + help="intentional no-read interval per hand (default: 30s)", + ) + h.add_argument( + "--recovery", type=parse_duration, default=3.0, + help="fresh post-stall capture (default: 3s)", + ) + h.add_argument( + "--short", type=parse_duration, default=10.0, + help="simultaneous two-hand acceptance capture (default: 10s)", + ) + h.add_argument( + "--soak", type=parse_duration, default=None, + help="optional simultaneous soak, e.g. 72h; omitted by default", + ) + h.add_argument( + "--window", type=parse_duration, default=30.0, + help="rolling soak sidecar window (default: 30s)", + ) + h.add_argument( + "--confirm-soak", default=None, + help="for >=1h, must equal LEFT_SERIAL,RIGHT_SERIAL exactly", + ) + h.add_argument( + "--no-soak-raw", action="store_true", + help="retain metrics but do not save the long raw TAG2 byte streams", + ) + h.add_argument( + "--dry-run", action="store_true", + help="validate guardrails and write the plan without opening any serial port", + ) + h.add_argument( + "--tag2-spec", default=None, + help="canonical TAG_V2.json (normally resolved from this source checkout)", + ) + h.set_defaults(func=_cmd_hil) + args = p.parse_args(argv) try: return args.func(args) diff --git a/src/oglo/hil.py b/src/oglo/hil.py new file mode 100644 index 0000000..8f87cc0 --- /dev/null +++ b/src/oglo/hil.py @@ -0,0 +1,1994 @@ +"""Release HIL and long-soak evidence for a named physical OGLO pair. + +This module deliberately contains no flashing primitive. Installing a candidate is +an independent, explicit factory/updater action; this runner only observes the two +logical serials named by the operator. Keeping those boundaries separate prevents a +test command from turning into a fleet mutation because a different board happened +to enumerate first. + +The public CLI writes content-addressed JSON, Markdown, raw TAG captures and rolling +JSONL sidecars. Its low-level helpers accept serial factories so modem-line, CRC and +soak behaviour can be exercised with fake serial ports in the normal unit suite. +""" + +from __future__ import annotations + +import hashlib +import json +import math +import os +import platform +import re +import shutil +import subprocess +import sys +import threading +import time +from concurrent.futures import ThreadPoolExecutor, as_completed +from dataclasses import asdict, dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any, Callable, Dict, Iterable, List, Mapping, Optional, Protocol, Sequence, Tuple + +from . import _wire as wire +from ._tag_contract import ( + BOOT_ID_BYTES, + BOOT_ID_HEX_CHARS, + BOOT_ID_SCOPE, + TAG2_ACK_PREFIX, + TAG_V2, + parse_tag2_ack, +) + + +PASS, WARN, FAIL = "pass", "warn", "fail" +_VERDICT_ORDER = {PASS: 0, WARN: 1, FAIL: 2} +_LOGICAL_SERIAL = re.compile(r"^OGLO-([LR])-([0-9]{5})$") +_STOP_ALL = b"STREAM BIN OFF\nSTREAM TAXEL OFF\nSTREAM TAG OFF\nSTREAM TAG2 OFF\n" +_STATUS_COUNTERS = ("deadline_misses", "tag_dropped", "tag_short_writes") +RELEASE_SOAK_SECONDS = 72 * 60 * 60 + + +def utc_now() -> str: + return datetime.now(timezone.utc).isoformat() + + +def canonical_json(value: Any) -> bytes: + return json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=False).encode() + + +def sha256_bytes(value: bytes) -> str: + return hashlib.sha256(value).hexdigest() + + +def sha256_file(path: Path) -> str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +@dataclass(frozen=True) +class HilConfig: + left_serial: str + right_serial: str + output_root: Path = Path("hil-results") + expected_firmware: str = "0.9.13" + tag_seconds: float = 3.0 + reconnect_cycles: int = 20 + reconnect_seconds: float = 0.5 + stall_seconds: float = 30.0 + recovery_seconds: float = 3.0 + short_seconds: float = 10.0 + soak_seconds: Optional[float] = None + window_seconds: float = 30.0 + confirm_soak: Optional[str] = None + dry_run: bool = False + store_soak_raw: bool = True + min_free_gib: float = 100.0 + tag2_spec: Optional[Path] = None + + @property + def expected_by_side(self) -> Dict[str, str]: + return {"left": self.left_serial, "right": self.right_serial} + + @property + def soak_confirmation(self) -> str: + return f"{self.left_serial},{self.right_serial}" + + +@dataclass(frozen=True) +class Target: + side: str + logical_serial: str + port: str + usb_serial: Optional[str] + vid: Optional[int] + pid: Optional[int] + product: Optional[str] + manufacturer: Optional[str] + + def as_dict(self) -> Dict[str, Any]: + return asdict(self) + + +@dataclass +class Check: + name: str + verdict: str + detail: str = "" + measurements: Dict[str, Any] = field(default_factory=dict) + + +@dataclass +class HilReport: + run_dir: Path + config: HilConfig + started_at: str = field(default_factory=utc_now) + finished_at: Optional[str] = None + checks: List[Check] = field(default_factory=list) + targets: Dict[str, Dict[str, Any]] = field(default_factory=dict) + snapshots: Dict[str, Dict[str, Any]] = field(default_factory=dict) + artifacts: Dict[str, str] = field(default_factory=dict) + error: Optional[str] = None + + def add( + self, + name: str, + verdict: str, + detail: str = "", + measurements: Optional[Mapping[str, Any]] = None, + ) -> None: + if verdict not in _VERDICT_ORDER: + raise ValueError(f"unknown HIL verdict {verdict!r}") + self.checks.append( + Check(name, verdict, detail, _jsonable(dict(measurements or {}))) + ) + + @property + def result(self) -> str: + if any(item.verdict == FAIL for item in self.checks): + return FAIL + if self.config.dry_run: + return "dry-run" + if not self.checks: + return FAIL + return max((item.verdict for item in self.checks), key=_VERDICT_ORDER.__getitem__) + + def as_dict(self) -> Dict[str, Any]: + config = asdict(self.config) + config["output_root"] = str(self.config.output_root) + return { + "schema": 1, + "kind": "oglo-release-hil", + "result": self.result, + "started_at": self.started_at, + "finished_at": self.finished_at, + "host": { + "platform": platform.platform(), + "python": platform.python_version(), + }, + "flash_performed": False, + "config": _jsonable(config), + "targets": _jsonable(self.targets), + "snapshots": _jsonable(self.snapshots), + "checks": [_jsonable(asdict(item)) for item in self.checks], + "artifacts": dict(self.artifacts), + "error": self.error, + } + + +class SerialLike(Protocol): + timeout: float + dtr: bool + rts: bool + + def read(self, size: int = 1) -> bytes: ... + def write(self, data: bytes) -> int: ... + def flush(self) -> None: ... + def reset_input_buffer(self) -> None: ... + def close(self) -> None: ... + + +SerialFactory = Callable[[Target, bool, bool], SerialLike] +CandidateProvider = Callable[[], Sequence[Any]] + + +def validate_config(config: HilConfig) -> None: + expected = ((config.left_serial, "L"), (config.right_serial, "R")) + for value, side in expected: + match = _LOGICAL_SERIAL.fullmatch(value) + if match is None or match.group(1) != side: + label = "left" if side == "L" else "right" + raise ValueError( + f"{label} serial must be exact OGLO-{side}-NNNNN form, got {value!r}" + ) + if config.left_serial == config.right_serial: + raise ValueError("left and right expected serials must be distinct") + if not re.fullmatch(r"[0-9]+\.[0-9]+\.[0-9]+", config.expected_firmware): + raise ValueError("expected firmware must be an exact numeric x.y.z version") + for name in ( + "tag_seconds", + "reconnect_seconds", + "stall_seconds", + "recovery_seconds", + "short_seconds", + "window_seconds", + ): + value = getattr(config, name) + if isinstance(value, bool) or not isinstance(value, (int, float)) or value <= 0: + raise ValueError(f"{name} must be greater than zero") + if isinstance(config.reconnect_cycles, bool) or config.reconnect_cycles < 1: + raise ValueError("reconnect_cycles must be at least one") + if config.min_free_gib < 100.0: + raise ValueError("the long-soak disk reserve cannot be lowered below 100 GiB") + if config.soak_seconds is not None: + if ( + isinstance(config.soak_seconds, bool) + or not isinstance(config.soak_seconds, (int, float)) + or not math.isfinite(float(config.soak_seconds)) + or config.soak_seconds <= 0 + ): + raise ValueError("soak_seconds must be a finite number greater than zero") + if config.soak_seconds >= 3600 and config.confirm_soak != config.soak_confirmation: + raise ValueError( + "a soak of one hour or longer needs --confirm-soak " + f"{config.soak_confirmation!r}" + ) + + +def _release_soak_gate( + config: HilConfig, soak: Optional[Mapping[str, Any]] +) -> Tuple[str, str, Dict[str, Any]]: + """Classify only the explicit 72-hour release gate, not a diagnostic soak.""" + release_duration_requested = ( + config.soak_seconds is not None + and float(config.soak_seconds) >= RELEASE_SOAK_SECONDS + ) + release_confirmation_present = config.confirm_soak == config.soak_confirmation + measurements = { + "requested_seconds": config.soak_seconds, + "minimum_release_seconds": RELEASE_SOAK_SECONDS, + "exact_pair_confirmation": release_confirmation_present, + } + if ( + release_duration_requested + and release_confirmation_present + and isinstance(soak, Mapping) + and soak.get("ok") is True + ): + return ( + PASS, + "at least 259200 seconds requested with the exact pair confirmation and passed", + measurements, + ) + if release_duration_requested: + return ( + FAIL, + "the full release-duration soak was requested but did not produce passing evidence", + measurements, + ) + if config.soak_seconds is None: + detail = "not requested; pass --soak 72h with exact confirmation" + else: + detail = ( + f"diagnostic soak was {float(config.soak_seconds):g}s; " + f"release pass requires at least {RELEASE_SOAK_SECONDS}s with exact confirmation" + ) + return WARN, detail, measurements + + +def validate_tag2_spec(path: Path) -> Dict[str, Any]: + """Bind source specification, SDK parser and all canonical vectors exactly.""" + try: + raw = path.read_bytes() + except OSError as exc: + raise ValueError(f"cannot read canonical TAG2 spec {path}: {exc}") from exc + try: + spec = json.loads(raw) + except ValueError as exc: + raise ValueError(f"canonical TAG2 spec is not JSON: {exc}") from exc + frame = spec.get("frame") if isinstance(spec, dict) else None + negotiation = spec.get("negotiation") if isinstance(spec, dict) else None + boot_id = spec.get("boot_id") if isinstance(spec, dict) else None + expected_contract = { + "version": TAG_V2.version, + "magic_hex": TAG_V2.magic.hex(), + "header_format": TAG_V2.header.format, + "header_len": TAG_V2.header.size, + "crc_field_format": TAG_V2.crc.format if TAG_V2.crc is not None else None, + "start_command": TAG_V2.start_command, + "start_ack_prefix": TAG2_ACK_PREFIX.decode("ascii"), + "stop_command": TAG_V2.stop_command, + "boot_id_hex_chars": BOOT_ID_HEX_CHARS, + "boot_id_bytes": BOOT_ID_BYTES, + "boot_id_scope": BOOT_ID_SCOPE, + } + observed_contract = { + "version": spec.get("version"), + "magic_hex": frame.get("magic_hex") if isinstance(frame, dict) else None, + "header_format": frame.get("header_format") if isinstance(frame, dict) else None, + "header_len": frame.get("header_len") if isinstance(frame, dict) else None, + "crc_field_format": ( + frame.get("crc32", {}).get("field_format") if isinstance(frame, dict) else None + ), + "start_command": ( + negotiation.get("start_command") if isinstance(negotiation, dict) else None + ), + "start_ack_prefix": ( + negotiation.get("start_ack_prefix") if isinstance(negotiation, dict) else None + ), + "stop_command": ( + negotiation.get("stop_command") if isinstance(negotiation, dict) else None + ), + "boot_id_hex_chars": ( + len("0" * BOOT_ID_HEX_CHARS) + if isinstance(boot_id, dict) + and boot_id.get("encoding") + == f"{BOOT_ID_HEX_CHARS} lowercase hexadecimal characters" + else None + ), + "boot_id_bytes": boot_id.get("bytes") if isinstance(boot_id, dict) else None, + "boot_id_scope": boot_id.get("scope") if isinstance(boot_id, dict) else None, + } + if observed_contract != expected_contract: + raise ValueError( + f"TAG2 spec/parser mismatch: expected {expected_contract}, got {observed_contract}" + ) + crc = frame.get("crc32", {}) + expected_crc = { + "algorithm": "CRC-32/ISO-HDLC", + "field_len": 4, + "coverage": "header_and_payload", + "polynomial_reflected_hex": "edb88320", + "init_hex": "ffffffff", + "xorout_hex": "ffffffff", + "reference": "zlib.crc32", + } + for key, value in expected_crc.items(): + if crc.get(key) != value: + raise ValueError(f"TAG2 spec CRC {key}={crc.get(key)!r}, expected {value!r}") + if not isinstance(boot_id, dict) or set(boot_id) != {"encoding", "bytes", "scope"}: + raise ValueError("TAG2 spec boot_id contract must bind encoding, bytes, and scope exactly") + + vectors = spec.get("vectors") + if not isinstance(vectors, list) or len(vectors) != 3: + raise ValueError("TAG2 spec must contain tactile, IMU and magnetometer vectors") + decoded = [] + for vector in vectors: + if not isinstance(vector, dict): + raise ValueError("TAG2 vector must be an object") + try: + frame_bytes = bytes.fromhex(vector["frame_hex"]) + except (KeyError, TypeError, ValueError) as exc: + raise ValueError("TAG2 vector frame_hex is invalid") from exc + packets, remainder, malformed = wire.iter_tagged_v2_diagnostic(frame_bytes) + if malformed or remainder or len(packets) != 1: + raise ValueError( + f"TAG2 vector {vector.get('name')!r} does not decode exactly once" + ) + packet = packets[0] + expected = vector.get("expected", {}) + kind = { + wire.TactilePacket: "tactile", + wire.ImuPacket: "imu", + wire.MagPacket: "mag", + }.get(type(packet)) + if ( + kind != vector.get("name") + or kind != expected.get("type") + or packet.seq != expected.get("seq") + or packet.device_time_us != expected.get("timestamp_us") + or packet.t_us != expected.get("t_us") + ): + raise ValueError(f"TAG2 vector {vector.get('name')!r} decoded values drifted") + if kind == "tactile" and packet.counts != expected.get("counts"): + raise ValueError("TAG2 tactile vector counts drifted") + if kind in ("imu", "mag") and list(packet.raw) != expected.get("raw"): + raise ValueError(f"TAG2 {kind} vector raw values drifted") + decoded.append(kind) + if set(decoded) != {"tactile", "imu", "mag"}: + raise ValueError(f"TAG2 canonical vectors incomplete: {decoded}") + return { + "path": str(path.resolve()), + "sha256": sha256_bytes(raw), + "bytes": len(raw), + "vectors": decoded, + "parser_contract": expected_contract, + } + + +def resolve_tag2_spec(config: HilConfig) -> Path: + candidates = ( + [Path(config.tag2_spec)] + if config.tag2_spec is not None + else [ + Path.cwd() / "spec" / "TAG_V2.json", + Path(__file__).resolve().parents[2] / "spec" / "TAG_V2.json", + Path(__file__).resolve().parent / "spec" / "TAG_V2.json", + ] + ) + for candidate in candidates: + if candidate.is_file(): + return candidate + raise ValueError( + "canonical spec/TAG_V2.json was not found; run from the source checkout or pass --tag2-spec" + ) + + +def bind_raw_tag2_capture( + result: Dict[str, Any], contract_evidence: Mapping[str, Any] +) -> Dict[str, Any]: + """Reparse saved board bytes and bind them to the exact canonical spec hash.""" + if result.get("tag_version") != 2: + raise ValueError("only TAG2 captures can be bound to the TAG2 spec") + if result.get("crc_checked") is not True: + raise ValueError("TAG2 capture was not CRC checked") + binding = { + "spec_sha256": contract_evidence["sha256"], + "parser_contract": contract_evidence["parser_contract"], + } + result["tag2_contract_spec_sha256"] = contract_evidence["sha256"] + result["tag2_contract_binding_sha256"] = sha256_bytes(canonical_json(binding)) + raw_value = result.get("raw_path") + if raw_value: + raw_path = Path(str(raw_value)) + data = raw_path.read_bytes() + if result.get("raw_sha256") != sha256_bytes(data): + raise ValueError("TAG2 raw SHA-256 changed before contract binding") + monitor = StreamMonitor(2, started_ns=0) + monitor.feed(data, observed_ns=1) + replayed = monitor.cumulative(now_ns=max(1, int(float(result["elapsed_s"]) * 1e9))) + for field in ( + "counts", + "missing", + "duplicates", + "backwards", + "timestamp_regressions", + "malformed_crc_or_structure", + "trailing_bytes", + ): + if replayed[field] != result[field]: + raise ValueError( + f"saved TAG2 bytes do not reproduce live {field}: " + f"{replayed[field]!r} != {result[field]!r}" + ) + result["saved_raw_reparsed_against_contract"] = True + else: + result["saved_raw_reparsed_against_contract"] = False + return result + + +def _new_run_dir(root: Path) -> Path: + root.mkdir(parents=True, exist_ok=True) + stem = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S.%fZ") + for suffix in range(1000): + path = root / (stem if suffix == 0 else f"{stem}-{suffix}") + try: + path.mkdir() + return path + except FileExistsError: + continue + raise RuntimeError("could not allocate a unique HIL result directory") + + +def _write_json(path: Path, value: Any, *, seal: bool = False) -> str: + path.parent.mkdir(parents=True, exist_ok=True) + payload = json.dumps(_jsonable(value), indent=2, sort_keys=True, ensure_ascii=False) + "\n" + path.write_text(payload, encoding="utf-8") + if seal: + path.chmod(0o444) + return sha256_bytes(payload.encode()) + + +def _jsonable(value: Any) -> Any: + if isinstance(value, Path): + return str(value) + if isinstance(value, Mapping): + return {str(key): _jsonable(item) for key, item in value.items()} + if isinstance(value, (list, tuple)): + return [_jsonable(item) for item in value] + if isinstance(value, (str, int, float, bool)) or value is None: + return value + if hasattr(value, "item"): + try: + return value.item() + except Exception: + pass + return str(value) + + +def _write_all(serial: SerialLike, payload: bytes) -> None: + written = serial.write(payload) + if type(written) is not int or written != len(payload): + raise OSError(f"short serial write: accepted {written!r} of {len(payload)} bytes") + serial.flush() + + +def _set_lines(serial: SerialLike, dtr: bool, rts: bool) -> List[str]: + """Move through an explicit all-low boundary; never emit a reset recipe helper.""" + applied = [] + serial.dtr = False + applied.append("dtr=0") + serial.rts = False + applied.append("rts=0") + if dtr: + serial.dtr = True + applied.append("dtr=1") + if rts: + serial.rts = True + applied.append("rts=1") + return applied + + +def _read_for(serial: SerialLike, seconds: float, *, sleep: Callable[[float], None] = time.sleep) -> bytes: + deadline = time.monotonic() + seconds + out = bytearray() + while time.monotonic() < deadline: + chunk = serial.read(8192) + if chunk: + out += chunk + else: + sleep(0.002) + return bytes(out) + + +def _find_prefixed_json(data: bytes, prefix: bytes) -> Optional[Dict[str, Any]]: + found = None + for raw_line in data.splitlines(): + line = raw_line.strip() + if not line.startswith(prefix): + continue + try: + value = json.loads(line[len(prefix):].decode("utf-8")) + except (UnicodeDecodeError, ValueError): + continue + if isinstance(value, dict): + found = value + return found + + +def run_line_matrix( + target: Target, + *, + serial_factory: SerialFactory, + candidate_provider: CandidateProvider, + settle_seconds: float = 0.12, + response_seconds: float = 0.20, + sleep: Callable[[float], None] = time.sleep, +) -> Dict[str, Any]: + """Prove DTR gating and RTS independence without any flash/reset command. + + The exact observed sequence is 00 -> 10 -> 00 -> 11 -> 00 -> 01 -> 00. Each + transition first drives both lines low, and the runner is allowed only for the + supported native-USB VID. Uptime and USB descriptor continuity turn an accidental + reset/re-enumeration into a failed result instead of a hidden side effect. + """ + if target.vid != 0x2886: + raise RuntimeError( + f"refusing modem-line matrix on non-native/unknown VID {target.vid!r}" + ) + sequence = [(False, False), (True, False), (False, False), (True, True), + (False, False), (False, True), (False, False)] + observations: List[Dict[str, Any]] = [] + serial = serial_factory(target, False, False) + uptimes: List[int] = [] + postcheck: Optional[Dict[str, Any]] = None + try: + for dtr, rts in sequence: + applied = _set_lines(serial, dtr, rts) + sleep(settle_seconds) + serial.reset_input_buffer() + _write_all(serial, b"GET STATUS\n") + if dtr: + _write_all(serial, b"GET CONFIG\n") + response = _read_for(serial, response_seconds, sleep=sleep) + status = _find_prefixed_json(response, b"#STATUS ") + config = _find_prefixed_json(response, b"#CONFIG ") + visible = list(candidate_provider()) + descriptor_present = any( + getattr(item, "serial_number", None) == target.usb_serial + if target.usb_serial + else getattr(item, "device", None) == target.port + for item in visible + ) + if status is not None and isinstance(status.get("uptime_ms"), int): + uptimes.append(status["uptime_ms"]) + observations.append( + { + "state": f"{int(dtr)}{int(rts)}", + "dtr": dtr, + "rts": rts, + "applied": applied, + "response_bytes": len(response), + "status": status, + "config": config, + "descriptor_present": descriptor_present, + } + ) + + # Required sequence ends at 00. Reassert the normal safe host state once to + # prove that a reset/re-enumeration caused specifically by the final 01->00 + # edge was not hidden by a fast descriptor return. + applied = _set_lines(serial, True, False) + sleep(settle_seconds) + serial.reset_input_buffer() + _write_all(serial, b"GET STATUS\nGET CONFIG\n") + response = _read_for(serial, response_seconds, sleep=sleep) + status = _find_prefixed_json(response, b"#STATUS ") + config = _find_prefixed_json(response, b"#CONFIG ") + visible = list(candidate_provider()) + descriptor_present = any( + getattr(item, "serial_number", None) == target.usb_serial + if target.usb_serial + else getattr(item, "device", None) == target.port + for item in visible + ) + postcheck = { + "state": "10", + "label": "postcheck_after_final_01_to_00", + "dtr": True, + "rts": False, + "applied": applied, + "response_bytes": len(response), + "status": status, + "config": config, + "descriptor_present": descriptor_present, + } + if status is not None and isinstance(status.get("uptime_ms"), int): + uptimes.append(status["uptime_ms"]) + finally: + try: + _set_lines(serial, False, False) + finally: + serial.close() + + failures: List[str] = [] + for item in observations: + should_reply = item["dtr"] + if should_reply and item["status"] is None: + failures.append(f"state {item['state']} did not return STATUS with DTR high") + if not should_reply and item["response_bytes"]: + failures.append(f"state {item['state']} emitted {item['response_bytes']} B with DTR low") + if not item["descriptor_present"]: + failures.append(f"state {item['state']} lost the USB descriptor") + if any(after < before for before, after in zip(uptimes, uptimes[1:])): + failures.append(f"uptime moved backward: {uptimes}") + high_configs = [item["config"] for item in observations if item["dtr"]] + if not high_configs or any(item is None for item in high_configs): + failures.append("DTR-high matrix observations did not return CONFIG") + if postcheck is None or postcheck["status"] is None or postcheck["config"] is None: + failures.append("final safe 10 postcheck did not return STATUS and CONFIG") + elif not postcheck["descriptor_present"]: + failures.append("final safe 10 postcheck lost the USB descriptor") + elif high_configs: + first_boot_id = high_configs[0].get("boot_id") if high_configs[0] else None + final_boot_id = postcheck["config"].get("boot_id") + if not first_boot_id or final_boot_id != first_boot_id: + failures.append(f"boot_id changed across matrix: {first_boot_id}->{final_boot_id}") + if len(uptimes) >= 2 and uptimes[-1] < uptimes[0]: + failures.append(f"final uptime {uptimes[-1]} precedes initial {uptimes[0]}") + return { + "ok": not failures, + "sequence": [item["state"] for item in observations], + "observations": observations, + "postcheck_10": postcheck, + "actual_transition_states": [item["state"] for item in observations] + ["10", "00"], + "uptimes_ms": uptimes, + "failures": failures, + } + + +class StreamMonitor: + """Incremental TAG parser with cumulative and resettable rolling statistics.""" + + _NAMES = { + wire.TactilePacket: "tactile", + wire.ImuPacket: "imu", + wire.MagPacket: "mag", + } + + def __init__(self, version: int, *, started_ns: Optional[int] = None) -> None: + if version not in (1, 2): + raise ValueError("TAG monitor version must be 1 or 2") + self.version = version + self.buffer = b"" + self.started_ns = time.monotonic_ns() if started_ns is None else started_ns + self.window_started_ns = self.started_ns + self.counts = {name: 0 for name in self._NAMES.values()} + self.window_counts = dict(self.counts) + self.malformed = 0 + self.window_malformed = 0 + self.missing = {name: 0 for name in self.counts} + self.window_missing = dict(self.missing) + self.duplicates = {name: 0 for name in self.counts} + self.window_duplicates = dict(self.duplicates) + self.backwards = {name: 0 for name in self.counts} + self.window_backwards = dict(self.backwards) + self.max_gap_us = {name: 0 for name in self.counts} + self.window_max_gap_us = dict(self.max_gap_us) + self.first_seq: Dict[str, Optional[int]] = {name: None for name in self.counts} + self.first_device_us: Dict[str, Optional[int]] = {name: None for name in self.counts} + self.last_seq: Dict[str, Optional[int]] = {name: None for name in self.counts} + self.last_device_us: Dict[str, Optional[int]] = {name: None for name in self.counts} + self.first_observed_ns: Optional[int] = None + self.timestamp_regressions = {name: 0 for name in self.counts} + self.window_timestamp_regressions = dict(self.timestamp_regressions) + self.last_observed_ns: Optional[int] = None + self.max_host_read_gap_ms = 0.0 + self.window_max_host_read_gap_ms = 0.0 + self.bytes_seen = 0 + + def feed(self, chunk: bytes, *, observed_ns: Optional[int] = None) -> int: + if not chunk: + return 0 + self.bytes_seen += len(chunk) + self.buffer += chunk + packets, self.buffer, malformed = wire.iter_tagged_version_diagnostic( + self.buffer, self.version + ) + if packets: + now_ns = time.monotonic_ns() if observed_ns is None else observed_ns + if self.first_observed_ns is None: + self.first_observed_ns = now_ns + if self.last_observed_ns is not None: + gap_ms = max(0.0, (now_ns - self.last_observed_ns) / 1e6) + self.max_host_read_gap_ms = max(self.max_host_read_gap_ms, gap_ms) + self.window_max_host_read_gap_ms = max( + self.window_max_host_read_gap_ms, gap_ms + ) + self.last_observed_ns = now_ns + self.malformed += malformed + self.window_malformed += malformed + for packet in packets: + name = self._NAMES.get(type(packet)) + if name is None: + continue + self.counts[name] += 1 + self.window_counts[name] += 1 + current_us = packet.device_time_us if packet.device_time_us is not None else packet.t_us + if self.first_seq[name] is None: + self.first_seq[name] = packet.seq + self.first_device_us[name] = int(current_us) + transition = wire.classify_seq(self.last_seq[name], packet.seq) + if transition.kind in ("first", "forward", "wrap"): + self.last_seq[name] = packet.seq + if transition.missing: + self.missing[name] += transition.missing + self.window_missing[name] += transition.missing + elif transition.kind == "duplicate": + self.duplicates[name] += 1 + self.window_duplicates[name] += 1 + elif transition.kind == "backward": + self.backwards[name] += 1 + self.window_backwards[name] += 1 + + previous_us = self.last_device_us[name] + if previous_us is not None: + if self.version == 1: + delta = (int(current_us) - int(previous_us)) & 0xFFFFFFFF + if delta >= 0x80000000: + self.timestamp_regressions[name] += 1 + self.window_timestamp_regressions[name] += 1 + delta = 0 + else: + delta = int(current_us) - int(previous_us) + if delta < 0: + self.timestamp_regressions[name] += 1 + self.window_timestamp_regressions[name] += 1 + delta = 0 + self.max_gap_us[name] = max(self.max_gap_us[name], delta) + self.window_max_gap_us[name] = max(self.window_max_gap_us[name], delta) + self.last_device_us[name] = int(current_us) + return len(packets) + + def _stats(self, *, elapsed_s: float, window: bool) -> Dict[str, Any]: + counts = self.window_counts if window else self.counts + return { + "elapsed_s": elapsed_s, + "counts": dict(counts), + "rates_hz": { + name: (count / elapsed_s if elapsed_s > 0 else 0.0) + for name, count in counts.items() + }, + "max_gap_us": dict(self.window_max_gap_us if window else self.max_gap_us), + "missing": dict(self.window_missing if window else self.missing), + "duplicates": dict(self.window_duplicates if window else self.duplicates), + "backwards": dict(self.window_backwards if window else self.backwards), + "timestamp_regressions": dict( + self.window_timestamp_regressions if window else self.timestamp_regressions + ), + "malformed_crc_or_structure": self.window_malformed if window else self.malformed, + "max_host_read_gap_ms": ( + self.window_max_host_read_gap_ms if window else self.max_host_read_gap_ms + ), + "bytes_seen": self.bytes_seen, + "trailing_bytes": len(self.buffer), + "crc_checked": self.version == 2, + } + + def cumulative(self, *, now_ns: Optional[int] = None) -> Dict[str, Any]: + now = time.monotonic_ns() if now_ns is None else now_ns + return self._stats(elapsed_s=max(0.0, (now - self.started_ns) / 1e9), window=False) + + def roll_window(self, *, now_ns: Optional[int] = None) -> Dict[str, Any]: + now = time.monotonic_ns() if now_ns is None else now_ns + elapsed = max(0.0, (now - self.window_started_ns) / 1e9) + out = self._stats(elapsed_s=elapsed, window=True) + self.window_started_ns = now + self.window_counts = {name: 0 for name in self.window_counts} + self.window_missing = {name: 0 for name in self.window_missing} + self.window_duplicates = {name: 0 for name in self.window_duplicates} + self.window_backwards = {name: 0 for name in self.window_backwards} + self.window_timestamp_regressions = { + name: 0 for name in self.window_timestamp_regressions + } + self.window_max_gap_us = {name: 0 for name in self.window_max_gap_us} + self.window_malformed = 0 + self.window_max_host_read_gap_ms = 0.0 + return out + + +def _stream_summary_ok( + summary: Mapping[str, Any], + *, + has_mag: bool, + expected_tactile_hz: Optional[float] = None, +) -> Tuple[bool, List[str]]: + failures = [] + required = ("tactile", "imu", "mag") if has_mag else ("tactile", "imu") + for name in required: + if int(summary["counts"].get(name, 0)) <= 0: + failures.append(f"no {name} frames") + for field in ("missing", "duplicates", "backwards", "timestamp_regressions"): + bad = {key: value for key, value in summary[field].items() if value} + if bad: + failures.append(f"{field}={bad}") + if summary["malformed_crc_or_structure"]: + failures.append( + f"malformed_crc_or_structure={summary['malformed_crc_or_structure']}" + ) + elapsed = float(summary.get("elapsed_s", 0.0)) + if elapsed >= 1.0 and expected_tactile_hz: + expected_rates = { + "tactile": float(expected_tactile_hz), + "imu": 500.0, + "mag": 125.0, + } + for name in required: + observed = float(summary["rates_hz"].get(name, 0.0)) + expected = expected_rates[name] + if not 0.80 * expected <= observed <= 1.20 * expected: + failures.append( + f"{name} rate={observed:.1f} Hz outside {0.8 * expected:.1f}..{1.2 * expected:.1f}" + ) + max_gap = int(summary["max_gap_us"].get(name, 0)) + if max_gap > int(5_000_000 / expected): + failures.append( + f"{name} device-time max gap={max_gap} us exceeds five periods" + ) + return not failures, failures + + +def _read_ack(serial: SerialLike, timeout: float) -> Tuple[str, bytes]: + deadline = time.monotonic() + timeout + pending = bytearray() + while time.monotonic() < deadline: + chunk = serial.read(8192) + if chunk: + pending += chunk + newline = pending.find(b"\n") + if newline >= 0: + line = bytes(pending[:newline]).removesuffix(b"\r") + return parse_tag2_ack(line), bytes(pending[newline + 1:]) + if len(pending) > 96: + raise RuntimeError(f"TAG2 ACK exceeded the exact line boundary: {pending[:96]!r}") + else: + time.sleep(0.002) + raise TimeoutError(f"no exact TAG2 ACK within {timeout:g}s") + + +def _quiet(serial: SerialLike, *, settle: float = 0.20) -> None: + _write_all(serial, _STOP_ALL) + time.sleep(settle) + serial.reset_input_buffer() + + +def _query_json( + serial: SerialLike, + command: str, + prefix: bytes, + *, + timeout: float = 3.0, +) -> Dict[str, Any]: + serial.reset_input_buffer() + _write_all(serial, command.rstrip().encode() + b"\n") + deadline = time.monotonic() + timeout + data = bytearray() + while time.monotonic() < deadline: + chunk = serial.read(8192) + if chunk: + data += chunk + value = _find_prefixed_json(bytes(data), prefix) + if value is not None: + return value + data[:] = data[-65536:] + else: + time.sleep(0.002) + raise TimeoutError(f"no {prefix.decode(errors='replace').strip()} reply to {command}") + + +def capture_tag_stream( + target: Target, + *, + version: int, + seconds: float, + serial_factory: SerialFactory, + raw_path: Optional[Path] = None, + window_seconds: Optional[float] = None, + window_callback: Optional[Callable[[Dict[str, Any]], None]] = None, + stall_before_read: float = 0.0, + cancel_event: Optional[threading.Event] = None, +) -> Dict[str, Any]: + """Capture one negotiated wire version from one exact target. + + ``stall_before_read`` intentionally leaves the CDC host unread, then discards the + host/kernel backlog before measuring fresh post-stall packets. This distinguishes + recovery from merely draining old bytes. + """ + if version not in (1, 2): + raise ValueError("version must be 1 or 2") + if stall_before_read and version != 2: + raise ValueError("stalled-reader recovery requires TAG2 u64 device timestamps") + serial = serial_factory(target, True, False) + raw_handle = None + started_ns = time.monotonic_ns() + try: + _quiet(serial) + config = _query_json(serial, "GET CONFIG", b"#CONFIG ") + if config.get("serial") != target.logical_serial or config.get("side") != target.side: + raise RuntimeError( + f"{target.port} identity changed: {config.get('serial')}/{config.get('side')}" + ) + if version == 2 and int(config.get("tag_ver_max", 1)) < 2: + raise RuntimeError(f"{target.logical_serial} does not advertise TAG2") + pre_stall_status = ( + _query_json(serial, "GET STATUS", b"#STATUS ") if stall_before_read else None + ) + serial.reset_input_buffer() + command = b"STREAM TAG2 ON\n" if version == 2 else b"STREAM TAG ON\n" + _write_all(serial, command) + initial = b"" + ack_boot_id = None + if version == 2: + ack_boot_id, initial = _read_ack(serial, timeout=2.0) + if ack_boot_id != config.get("boot_id"): + raise RuntimeError( + f"TAG2 ACK boot_id={ack_boot_id} does not match CONFIG {config.get('boot_id')}" + ) + stall_evidence: Optional[Dict[str, Any]] = None + post_reset_ns: Optional[int] = None + if stall_before_read: + pre_monitor = StreamMonitor(2) + if initial: + pre_monitor.feed(initial) + boundary_deadline = time.monotonic() + 2.0 + while pre_monitor.last_device_us["tactile"] is None: + if time.monotonic() >= boundary_deadline: + raise TimeoutError("no valid tactile TAG2 frame before the host-read stall") + chunk = serial.read(8192) + if chunk: + pre_monitor.feed(chunk) + else: + time.sleep(0.001) + if pre_monitor.malformed: + raise RuntimeError( + "malformed TAG2 bytes before the stalled-reader boundary: " + f"{pre_monitor.malformed}" + ) + stall_started_ns = time.monotonic_ns() + time.sleep(stall_before_read) + serial.reset_input_buffer() + post_reset_ns = time.monotonic_ns() + actual_stall_s = max(0.0, (post_reset_ns - stall_started_ns) / 1e9) + stall_evidence = { + "pre_stall_boundary": { + "seq": dict(pre_monitor.last_seq), + "device_time_us": dict(pre_monitor.last_device_us), + "boot_id": ack_boot_id, + "status": pre_stall_status, + "observed_monotonic_ns": pre_monitor.last_observed_ns, + }, + "requested_unread_stall_s": stall_before_read, + "actual_unread_stall_s": actual_stall_s, + "host_input_reset_after_stall": True, + } + initial = b"" + + freshness_probe_buffer = b"" + first_fresh_tactile: Optional[Dict[str, int]] = None + first_fresh_observed_ns: Optional[int] = None + stale_tactile_frames = 0 + freshness_tolerance_s: Optional[float] = None + min_advance_us: Optional[int] = None + if stall_evidence is not None: + rate_hz = float(config.get("rate_hz", 0) or 0) + # Five tactile periods cover an in-flight USB transaction; the 0.5% + # term covers ordinary independent host/device clock drift on long + # diagnostic stalls. + freshness_tolerance_s = max( + 0.050, + (5.0 / rate_hz) if rate_hz > 0 else 0.0, + 0.005 * float(stall_evidence["actual_unread_stall_s"]), + ) + min_advance_us = max( + 0, + int( + ( + float(stall_evidence["actual_unread_stall_s"]) + - freshness_tolerance_s + ) + * 1_000_000 + ), + ) + + capture_started_ns = time.monotonic_ns() + monitor = StreamMonitor(version, started_ns=capture_started_ns) + if raw_path is not None: + raw_path.parent.mkdir(parents=True, exist_ok=True) + raw_handle = raw_path.open("wb") + if initial: + if raw_handle is not None: + raw_handle.write(initial) + monitor.feed(initial) + + deadline = time.monotonic() + seconds + next_window = ( + time.monotonic() + window_seconds if window_seconds is not None else None + ) + while time.monotonic() < deadline and not ( + cancel_event is not None and cancel_event.is_set() + ): + chunk = serial.read(8192) + if chunk: + if raw_handle is not None: + raw_handle.write(chunk) + observed_ns = time.monotonic_ns() + monitor.feed(chunk, observed_ns=observed_ns) + if stall_evidence is not None and first_fresh_tactile is None: + freshness_probe_buffer += chunk + packets, freshness_probe_buffer, _ = wire.iter_tagged_v2_diagnostic( + freshness_probe_buffer + ) + pre_device_us = stall_evidence["pre_stall_boundary"][ + "device_time_us" + ]["tactile"] + assert min_advance_us is not None and pre_device_us is not None + for packet in packets: + if not isinstance(packet, wire.TactilePacket): + continue + device_us = packet.device_time_us + if device_us is None: + continue + advance_us = int(device_us) - int(pre_device_us) + if advance_us < min_advance_us: + stale_tactile_frames += 1 + continue + first_fresh_tactile = { + "seq": int(packet.seq), + "device_time_us": int(device_us), + } + first_fresh_observed_ns = observed_ns + break + if cancel_event is not None and ( + monitor.malformed + or any(monitor.missing.values()) + or any(monitor.duplicates.values()) + or any(monitor.backwards.values()) + or any(monitor.timestamp_regressions.values()) + ): + cancel_event.set() + else: + time.sleep(0.001) + now = time.monotonic() + if next_window is not None and now >= next_window: + window = monitor.roll_window() + if raw_handle is not None: + raw_handle.flush() + os.fsync(raw_handle.fileno()) + if window_callback is not None: + window_callback(window) + if cancel_event is not None: + window_ok, _ = _stream_summary_ok( + window, + has_mag=bool(config.get("has_mag")), + expected_tactile_hz=float(config.get("rate_hz", 0) or 0), + ) + if not window_ok: + cancel_event.set() + next_window += float(window_seconds) + if window_seconds is not None: + window = monitor.roll_window() + if any(window["counts"].values()) and window_callback is not None: + window_callback(window) + + _write_all(serial, b"STREAM TAG2 OFF\n" if version == 2 else b"STREAM TAG OFF\n") + if raw_handle is not None: + raw_handle.flush() + os.fsync(raw_handle.fileno()) + raw_handle.close() + raw_handle = None + summary = monitor.cumulative() + ok, failures = _stream_summary_ok( + summary, + has_mag=bool(config.get("has_mag")), + expected_tactile_hz=float(config.get("rate_hz", 0) or 0), + ) + if stall_evidence is not None: + assert post_reset_ns is not None + post_config = _query_json(serial, "GET CONFIG", b"#CONFIG ") + post_status = _query_json(serial, "GET STATUS", b"#STATUS ") + first_latency_s = ( + max(0.0, (first_fresh_observed_ns - post_reset_ns) / 1e9) + if first_fresh_observed_ns is not None + else None + ) + pre_seq = stall_evidence["pre_stall_boundary"]["seq"]["tactile"] + post_seq = first_fresh_tactile["seq"] if first_fresh_tactile else None + pre_device_us = stall_evidence["pre_stall_boundary"]["device_time_us"][ + "tactile" + ] + post_device_us = ( + first_fresh_tactile["device_time_us"] if first_fresh_tactile else None + ) + device_advance_us = ( + int(post_device_us) - int(pre_device_us) + if post_device_us is not None and pre_device_us is not None + else None + ) + assert freshness_tolerance_s is not None and min_advance_us is not None + max_advance_us = int( + ( + float(stall_evidence["actual_unread_stall_s"]) + + (first_latency_s or 0.0) + + freshness_tolerance_s + ) + * 1_000_000 + ) + seq_transition = ( + wire.classify_seq(int(pre_seq), int(post_seq)).kind + if pre_seq is not None and post_seq is not None + else "missing" + ) + stale_backlog = stale_tactile_frames > 0 + excessive_advance = ( + device_advance_us is not None and device_advance_us > max_advance_us + ) + post_boot_id = post_config.get("boot_id") + same_boot = post_boot_id == ack_boot_id == config.get("boot_id") + status_uptime_ok = ( + isinstance(pre_stall_status, dict) + and isinstance(pre_stall_status.get("uptime_ms"), int) + and isinstance(post_status.get("uptime_ms"), int) + and post_status["uptime_ms"] >= pre_stall_status["uptime_ms"] + ) + stall_evidence.update( + { + "post_stall_first_valid": { + "seq": dict(monitor.first_seq), + "device_time_us": dict(monitor.first_device_us), + }, + "post_stall_first_fresh_tactile": first_fresh_tactile, + "first_fresh_frame_latency_after_input_reset_s": first_latency_s, + "post_stall_config_boot_id": post_boot_id, + "post_stall_status": post_status, + "boot_identity_unchanged": same_boot, + "status_uptime_non_decreasing": status_uptime_ok, + "tactile_seq_transition": seq_transition, + "tactile_device_time_advance_us": device_advance_us, + "expected_device_time_advance_min_us": min_advance_us, + "expected_device_time_advance_max_us": max_advance_us, + "freshness_tolerance_s": freshness_tolerance_s, + "stale_device_backlog_detected": stale_backlog, + "stale_tactile_frames_before_fresh": stale_tactile_frames, + "excessive_device_time_advance_detected": excessive_advance, + "freshness_basis": ( + "host input was reset after the measured unread interval; the first " + "valid post-reset tactile TAG2 u64 timestamp is compared with the " + "last valid pre-stall tactile timestamp" + ), + } + ) + if first_latency_s is None: + failures.append("no fresh valid TAG2 frame arrived after the host input reset") + if seq_transition not in ("forward", "wrap"): + failures.append(f"post-stall tactile sequence transition is {seq_transition}") + if not same_boot: + failures.append( + "boot_id changed across stalled-reader recovery: " + f"{config.get('boot_id')}->{post_boot_id}" + ) + if not status_uptime_ok: + failures.append("device STATUS uptime reset or was unavailable across the stall") + if excessive_advance: + failures.append( + "post-stall tactile u64 advancement exceeds the measured stall window: " + f"advance={device_advance_us}, maximum={max_advance_us} us" + ) + ok = not failures + cancelled = bool(cancel_event is not None and cancel_event.is_set()) + if cancelled: + ok = False + failures.append("peer capture failed or the soak was cancelled") + return { + "ok": ok, + "failures": failures, + "serial": target.logical_serial, + "side": target.side, + "port": target.port, + "tag_version": version, + "config_boot_id": config.get("boot_id"), + "ack_boot_id": ack_boot_id, + "stall_before_read_s": stall_before_read, + "stalled_reader_recovery": stall_evidence, + "cancelled": cancelled, + "capture_started_at": datetime.fromtimestamp( + time.time() - ((time.monotonic_ns() - capture_started_ns) / 1e9), + timezone.utc, + ).isoformat(), + **summary, + "raw_path": str(raw_path) if raw_path is not None else None, + "raw_sha256": sha256_file(raw_path) if raw_path is not None else None, + "total_operation_s": (time.monotonic_ns() - started_ns) / 1e9, + } + finally: + if raw_handle is not None: + raw_handle.close() + try: + _write_all(serial, b"STREAM TAG OFF\nSTREAM TAG2 OFF\n") + except BaseException: + pass + serial.close() + + +class ActualBackend: + """Physical implementation. All methods are observation-only.""" + + def __init__( + self, + *, + serial_factory: Optional[SerialFactory] = None, + candidate_provider: Optional[CandidateProvider] = None, + ) -> None: + from ._usb import list_candidates + + self.serial_factory = serial_factory or self._open_serial + self.candidate_provider = candidate_provider or list_candidates + + @staticmethod + def _open_serial(target: Target, dtr: bool, rts: bool) -> SerialLike: + import serial as pyserial + + handle = pyserial.Serial() + handle.port = target.port + handle.baudrate = 115200 + handle.timeout = 0.05 + if hasattr(handle, "exclusive"): + handle.exclusive = True + # Set both line states while the pyserial object is still closed. POSIX open + # applies these stored values atomically; setting them only after open would + # transiently expose pyserial defaults and invalidate the tested transition. + handle.dtr = dtr + handle.rts = rts + handle.open() + time.sleep(0.8) + return handle + + def discover(self, config: HilConfig) -> Tuple[Dict[str, Target], List[Dict[str, Any]]]: + from . import connect + + expected = config.expected_by_side + found: Dict[str, Target] = {} + seen: List[Dict[str, Any]] = [] + candidates = list(self.candidate_provider()) + for candidate in candidates: + try: + with connect(port=candidate.device, timeout=6.0) as glove: + info = glove.info + row = { + "port": candidate.device, + "usb_serial": candidate.serial_number, + "serial": info.serial, + "side": info.side, + "firmware": info.fw_rev, + } + seen.append(row) + if info.side not in expected or info.serial != expected[info.side]: + continue + if info.side in found: + raise RuntimeError( + f"duplicate {info.side} target {info.serial} on two USB ports" + ) + found[info.side] = Target( + side=info.side, + logical_serial=info.serial, + port=candidate.device, + usb_serial=candidate.serial_number, + vid=candidate.vid, + pid=candidate.pid, + product=candidate.product, + manufacturer=candidate.manufacturer, + ) + except Exception as exc: + seen.append( + { + "port": getattr(candidate, "device", None), + "usb_serial": getattr(candidate, "serial_number", None), + "probe_error": f"{type(exc).__name__}: {exc}", + } + ) + missing = [side for side in ("left", "right") if side not in found] + if missing: + raise RuntimeError( + f"exact expected pair not found; missing {missing}, observed {seen}" + ) + return found, seen + + def snapshot(self, target: Target) -> Dict[str, Any]: + from . import connect + + with connect(port=target.port, timeout=8.0) as glove: + if glove.info.serial != target.logical_serial or glove.info.side != target.side: + raise RuntimeError("logical identity changed before snapshot") + status = glove.status() + zero_line = glove.send("GET ZERO", expect="#TZERO ", timeout=5.0) + zero = json.loads(zero_line.removeprefix("#TZERO ")) + fw_line = glove.send("GET FWINFO", expect="#", timeout=5.0) + fwinfo = None + fwinfo_error = None + if fw_line.startswith("#FWINFO "): + fwinfo = json.loads(fw_line.removeprefix("#FWINFO ")) + else: + fwinfo_error = fw_line + config = dict(glove.info.raw) + return { + "observed_at": utc_now(), + "target": target.as_dict(), + "config": config, + "status": asdict(status), + "zero": zero, + "calibration_sha256": sha256_bytes(canonical_json(zero)), + "fwinfo": fwinfo, + "fwinfo_error": fwinfo_error, + "running_image_sha256": ( + fwinfo.get("running_image_sha256") if isinstance(fwinfo, dict) else None + ), + } + + def line_matrix(self, target: Target) -> Dict[str, Any]: + return run_line_matrix( + target, + serial_factory=self.serial_factory, + candidate_provider=self.candidate_provider, + ) + + def capture( + self, + target: Target, + *, + version: int, + seconds: float, + raw_path: Optional[Path], + stall_before_read: float = 0.0, + ) -> Dict[str, Any]: + return capture_tag_stream( + target, + version=version, + seconds=seconds, + serial_factory=self.serial_factory, + raw_path=raw_path, + stall_before_read=stall_before_read, + ) + + def dual_capture( + self, + targets: Mapping[str, Target], + *, + seconds: float, + output_dir: Path, + label: str, + ) -> Dict[str, Any]: + output_dir.mkdir(parents=True, exist_ok=True) + results: Dict[str, Any] = {} + with ThreadPoolExecutor(max_workers=2) as pool: + futures = { + pool.submit( + self.capture, + target, + version=2, + seconds=seconds, + raw_path=output_dir / f"{label}-{side}-tag2.bin", + ): side + for side, target in targets.items() + } + for future in as_completed(futures): + side = futures[future] + results[side] = future.result() + return { + "ok": all(value["ok"] for value in results.values()), + "devices": results, + } + + def reconnect( + self, + target: Target, + *, + cycles: int, + seconds: float, + ) -> Dict[str, Any]: + observations = [] + previous_uptime = None + previous_boot_id = None + failures = [] + for index in range(cycles): + capture = self.capture( + target, version=2, seconds=seconds, raw_path=None + ) + snapshot = self.snapshot(target) + uptime = snapshot["status"]["uptime_ms"] + boot_id = snapshot["config"].get("boot_id") + if previous_uptime is not None and uptime < previous_uptime: + failures.append(f"cycle {index + 1}: uptime reset {previous_uptime}->{uptime}") + if previous_boot_id is not None and boot_id != previous_boot_id: + failures.append(f"cycle {index + 1}: boot_id changed") + if not capture["ok"]: + failures.append(f"cycle {index + 1}: {capture['failures']}") + observations.append( + { + "cycle": index + 1, + "uptime_ms": uptime, + "boot_id": boot_id, + "stream": capture, + } + ) + previous_uptime = uptime + previous_boot_id = boot_id + return {"ok": not failures, "failures": failures, "cycles": observations} + + def soak( + self, + targets: Mapping[str, Target], + *, + seconds: float, + window_seconds: float, + output_dir: Path, + store_raw: bool, + min_free_gib: float = 100.0, + ) -> Dict[str, Any]: + output_dir.mkdir(parents=True, exist_ok=True) + disk = shutil.disk_usage(output_dir) + raw_estimate = int(seconds * 2 * 128 * 1024) if store_raw else 0 + sidecar_estimate = int(max(1.0, seconds / window_seconds) * 2 * 4096) + estimated_bytes = raw_estimate + sidecar_estimate + reserve_bytes = int(min_free_gib * 1024**3) + if disk.free < reserve_bytes + estimated_bytes: + raise RuntimeError( + f"refusing long soak: free disk would cross the {min_free_gib:g} GiB reserve; " + f"free={disk.free}, estimated={estimated_bytes}, reserve={reserve_bytes}" + ) + sidecar = output_dir / "soak-windows.jsonl" + sidecar.touch(exist_ok=False) + lock = threading.Lock() + cancel_event = threading.Event() + + def append_window(side: str, target: Target, window: Dict[str, Any]) -> None: + row = { + "observed_at": utc_now(), + "side": side, + "serial": target.logical_serial, + **window, + } + payload = json.dumps(row, sort_keys=True) + "\n" + with lock: + with sidecar.open("a", encoding="utf-8") as handle: + handle.write(payload) + handle.flush() + os.fsync(handle.fileno()) + + def worker(side: str, target: Target) -> Dict[str, Any]: + try: + return capture_tag_stream( + target, + version=2, + seconds=seconds, + serial_factory=self.serial_factory, + raw_path=(output_dir / f"soak-{side}-tag2.bin") if store_raw else None, + window_seconds=window_seconds, + window_callback=lambda value: append_window(side, target, value), + cancel_event=cancel_event, + ) + except BaseException: + cancel_event.set() + raise + + results: Dict[str, Any] = {} + with ThreadPoolExecutor(max_workers=2) as pool: + futures = { + pool.submit(worker, side, target): side for side, target in targets.items() + } + for future in as_completed(futures): + results[futures[future]] = future.result() + return { + "ok": all(value["ok"] for value in results.values()), + "devices": results, + "window_sidecar": str(sidecar), + "window_sidecar_sha256": sha256_file(sidecar), + "disk_free_before_bytes": disk.free, + "estimated_artifact_bytes": estimated_bytes, + "required_reserve_bytes": reserve_bytes, + } + + +def _snapshot_ok(snapshot: Mapping[str, Any], config: HilConfig, side: str) -> Tuple[bool, List[str]]: + failures = [] + raw = snapshot["config"] + status = snapshot["status"] + fwinfo = snapshot.get("fwinfo") + if raw.get("serial") != config.expected_by_side[side]: + failures.append(f"serial={raw.get('serial')!r}") + if raw.get("side") != side: + failures.append(f"side={raw.get('side')!r}") + if raw.get("fw_rev") != config.expected_firmware: + failures.append( + f"firmware={raw.get('fw_rev')!r}, expected {config.expected_firmware!r}; flash separately" + ) + if int(raw.get("tag_ver_max", 1)) < 2: + failures.append("TAG2 capability is absent") + if not raw.get("boot_id"): + failures.append("boot_id is absent") + if not raw.get("zero_valid"): + failures.append("calibration is not valid") + if not status.get("imu_ok") or not status.get("sensor_ok"): + failures.append("IMU/sensor status is not healthy") + if raw.get("has_mag") and not status.get("mag_ok"): + failures.append("magnetometer status is not healthy") + if status.get("error_flags") != 0: + failures.append(f"error_flags={status.get('error_flags')}") + if not isinstance(fwinfo, dict): + failures.append(f"GET FWINFO unavailable: {snapshot.get('fwinfo_error')!r}") + elif fwinfo.get("running_image_sha256") is None: + failures.append("running image SHA-256 is absent") + return not failures, failures + + +def _compare_snapshots(before: Mapping[str, Any], after: Mapping[str, Any]) -> Tuple[bool, List[str]]: + failures = [] + for field in ("serial", "side", "device_id"): + if before["config"].get(field) != after["config"].get(field): + failures.append(f"CONFIG {field} changed") + if before.get("calibration_sha256") != after.get("calibration_sha256"): + failures.append("calibration fingerprint changed") + if before.get("running_image_sha256") != after.get("running_image_sha256"): + failures.append("running firmware SHA-256 changed during observation-only HIL") + if after["status"].get("uptime_ms", 0) < before["status"].get("uptime_ms", 0): + failures.append("uptime reset") + if after["config"].get("boot_id") != before["config"].get("boot_id"): + failures.append("boot_id changed") + if after["status"].get("error_flags") != 0: + failures.append(f"final error_flags={after['status'].get('error_flags')}") + for counter in _STATUS_COUNTERS: + delta = int(after["status"].get(counter, 0)) - int(before["status"].get(counter, 0)) + if delta != 0: + failures.append(f"{counter} delta={delta}") + return not failures, failures + + +def capture_kernel_usb_logs(started_at: str, finished_at: str, output: Path) -> Dict[str, Any]: + """Best-effort bounded host USB log evidence; never needs administrator rights.""" + command: Optional[List[str]] = None + if sys.platform == "darwin": + command = [ + "/usr/bin/log", "show", "--style", "compact", "--start", started_at, + "--end", finished_at, "--predicate", + 'process == "kernel" AND (eventMessage CONTAINS[c] "USB" OR eventMessage CONTAINS[c] "CDC")', + ] + elif sys.platform.startswith("linux"): + command = ["journalctl", "-k", "--since", started_at, "--until", finished_at, + "--no-pager", "--output=short-iso"] + if command is None: + output.write_text("kernel USB log capture unsupported on this platform\n", encoding="utf-8") + return {"available": False, "reason": "unsupported platform", "path": str(output)} + try: + completed = subprocess.run(command, capture_output=True, timeout=45) + data = completed.stdout + completed.stderr + truncated = len(data) > 4 * 1024 * 1024 + if truncated: + data = data[-4 * 1024 * 1024:] + output.write_bytes(data) + return { + "available": completed.returncode == 0, + "returncode": completed.returncode, + "truncated_to_last_4mib": truncated, + "bytes": len(data), + "path": str(output), + "sha256": sha256_bytes(data), + } + except Exception as exc: + text = f"{type(exc).__name__}: {exc}\n" + output.write_text(text, encoding="utf-8") + return {"available": False, "reason": text.strip(), "path": str(output)} + + +def _attempt(report: HilReport, name: str, fn: Callable[[], Any]) -> Any: + try: + value = fn() + except BaseException as exc: + if isinstance(exc, (KeyboardInterrupt, SystemExit)): + raise + report.add(name, FAIL, f"{type(exc).__name__}: {exc}") + return None + ok = bool(value.get("ok", True)) if isinstance(value, Mapping) else True + detail = "" if ok else "; ".join(str(item) for item in value.get("failures", [])) + report.add(name, PASS if ok else FAIL, detail, value if isinstance(value, Mapping) else {}) + return value + + +def _finalize(report: HilReport) -> HilReport: + report.finished_at = utc_now() + report_path = report.run_dir / "hil-report.json" + markdown_path = report.run_dir / "hil-report.md" + manifest_path = report.run_dir / "manifest.sha256" + report.artifacts.update( + { + "report_json": str(report_path), + "report_markdown": str(markdown_path), + "sha256_manifest": str(manifest_path), + } + ) + _write_json(report_path, report.as_dict()) + markdown_path.write_text(_markdown(report.as_dict()), encoding="utf-8") + rows = [] + for path in sorted(item for item in report.run_dir.rglob("*") if item.is_file()): + if path == manifest_path: + continue + rows.append(f"{sha256_file(path)} {path.relative_to(report.run_dir)}") + manifest_path.write_text("\n".join(rows) + "\n", encoding="utf-8") + for path in (report_path, markdown_path, manifest_path): + path.chmod(0o444) + return report + + +def run_hil(config: HilConfig, *, backend: Optional[Any] = None) -> HilReport: + """Run the observation-only release gate and seal evidence before interrupts escape.""" + validate_config(config) + run_dir = _new_run_dir(Path(config.output_root)) + report = HilReport(run_dir=run_dir, config=config) + try: + return _run_hil_steps(config, report=report, backend=backend) + except (KeyboardInterrupt, SystemExit) as exc: + detail = f"{type(exc).__name__}: HIL execution did not complete" + if isinstance(exc, SystemExit): + detail += f" (code={exc.code!r})" + report.add("HIL execution interrupted", FAIL, detail) + report.error = detail + finalized = False + finalize_error: Optional[BaseException] = None + try: + _finalize(report) + finalized = True + except BaseException as seal_exc: + # Preserve the operator's original interrupt. The attached error makes + # the rare failure-to-seal state observable to direct API callers. + finalize_error = seal_exc + for name, value in ( + ("hil_report_dir", report.run_dir), + ("hil_report_path", report.run_dir / "hil-report.json"), + ("hil_report_finalized", finalized), + ("hil_report_finalize_error", finalize_error), + ): + try: + setattr(exc, name, value) + except Exception: + pass + raise + + +def _run_hil_steps( + config: HilConfig, *, report: HilReport, backend: Optional[Any] = None +) -> HilReport: + """Execute HIL steps using the wrapper-owned report and evidence directory.""" + run_dir = report.run_dir + try: + spec_path = resolve_tag2_spec(config) + contract_evidence = validate_tag2_spec(spec_path) + frozen_spec = run_dir / "contracts" / "TAG_V2.json" + frozen_spec.parent.mkdir(parents=True, exist_ok=True) + frozen_spec.write_bytes(spec_path.read_bytes()) + frozen_spec.chmod(0o444) + report.artifacts["tag2_contract"] = ( + f"{frozen_spec} sha256={contract_evidence['sha256']}" + ) + report.add( + "canonical TAG2 spec/parser/vectors", + PASS, + measurements=contract_evidence, + ) + except Exception as exc: + report.add( + "canonical TAG2 spec/parser/vectors", + FAIL, + f"{type(exc).__name__}: {exc}", + ) + report.error = "cannot test hardware against an unbound wire contract" + return _finalize(report) + if config.dry_run: + report.add( + "dry-run plan", + PASS, + "no serial port opened and no firmware/calibration write exists in this runner", + { + "expected_pair": config.expected_by_side, + "steps": [ + "exact logical serial discovery", + "sealed before CONFIG/STATUS/ZERO/FWINFO", + "DTR/RTS 00-10-00-11-00-01-00", + "real TAG1 and TAG2/CRC captures", + f"{config.reconnect_cycles} close/reopen cycles per hand", + f"{config.stall_seconds:g}s host-read stall per hand", + "simultaneous short two-hand capture", + "optional diagnostic soak plus explicit >=72h release gate", + "sealed after snapshot and SHA-256 manifest", + ], + "flash_performed": False, + }, + ) + return _finalize(report) + + backend = backend or ActualBackend() + targets: Optional[Dict[str, Target]] = None + observed = None + try: + targets, observed = backend.discover(config) + report.targets = {side: target.as_dict() for side, target in targets.items()} + report.add( + "discover exact named pair", PASS, measurements={"observed": observed} + ) + except Exception as exc: + report.add("discover exact named pair", FAIL, f"{type(exc).__name__}: {exc}") + report.error = "cannot safely continue without the exact named pair" + return _finalize(report) + + before: Dict[str, Any] = {} + preflight_ready = True + for side, target in targets.items(): + snapshot = _attempt(report, f"{side}: immutable before snapshot", lambda t=target: backend.snapshot(t)) + if snapshot is None: + preflight_ready = False + continue + before[side] = snapshot + ok, failures = _snapshot_ok(snapshot, config, side) + report.add( + f"{side}: candidate identity/health", + PASS if ok else FAIL, + "; ".join(failures), + { + "firmware": snapshot["config"].get("fw_rev"), + "running_image_sha256": snapshot.get("running_image_sha256"), + "calibration_sha256": snapshot.get("calibration_sha256"), + }, + ) + preflight_ready = preflight_ready and ok + path = run_dir / "before" / f"{side}.json" + digest = _write_json(path, snapshot, seal=True) + report.artifacts[f"before_{side}"] = f"{path} sha256={digest}" + report.snapshots["before"] = before + + if not preflight_ready or set(before) != {"left", "right"}: + report.error = ( + "candidate preflight failed; flash/repair the named units separately, then rerun HIL" + ) + return _finalize(report) + + for side, target in targets.items(): + _attempt(report, f"{side}: DTR/RTS matrix", lambda t=target: backend.line_matrix(t)) + + captures_dir = run_dir / "captures" + for side, target in targets.items(): + for version in (1, 2): + result = _attempt( + report, + f"{side}: real TAG{version} modality/CRC capture", + lambda t=target, v=version, s=side: backend.capture( + t, + version=v, + seconds=config.tag_seconds, + raw_path=captures_dir / f"{s}-tag{v}.bin", + ), + ) + if isinstance(result, Mapping): + if version == 2: + try: + bind_raw_tag2_capture(result, contract_evidence) + except Exception as exc: + report.add( + f"{side}: bind real TAG2 bytes to canonical spec", + FAIL, + f"{type(exc).__name__}: {exc}", + ) + else: + report.add( + f"{side}: bind real TAG2 bytes to canonical spec", + PASS, + measurements={ + "raw_sha256": result.get("raw_sha256"), + "spec_sha256": contract_evidence["sha256"], + "binding_sha256": result.get( + "tag2_contract_binding_sha256" + ), + }, + ) + _write_json(captures_dir / f"{side}-tag{version}.json", result) + + for side, target in targets.items(): + value = _attempt( + report, + f"{side}: repeated disconnect/reconnect", + lambda t=target: backend.reconnect( + t, cycles=config.reconnect_cycles, seconds=config.reconnect_seconds + ), + ) + if value is not None: + _write_json(run_dir / "reconnect" / f"{side}.json", value) + + for side, target in targets.items(): + value = _attempt( + report, + f"{side}: host-read stall and fresh recovery", + lambda t=target, s=side: backend.capture( + t, + version=2, + seconds=config.recovery_seconds, + raw_path=captures_dir / f"{s}-post-stall-tag2.bin", + stall_before_read=config.stall_seconds, + ), + ) + if value is not None: + try: + bind_raw_tag2_capture(value, contract_evidence) + except Exception as exc: + report.add( + f"{side}: bind post-stall TAG2 bytes to canonical spec", + FAIL, + f"{type(exc).__name__}: {exc}", + ) + _write_json(captures_dir / f"{side}-post-stall-tag2.json", value) + + short = _attempt( + report, + "simultaneous short two-hand acceptance", + lambda: { + **backend.dual_capture( + targets, + seconds=config.short_seconds, + output_dir=captures_dir, + label="short", + ), + }, + ) + if isinstance(short, Mapping): + short_devices = short.get("devices", {}) + for value in short_devices.values(): + if isinstance(value, dict): + try: + bind_raw_tag2_capture(value, contract_evidence) + except Exception as exc: + report.add( + "bind simultaneous TAG2 bytes to canonical spec", + FAIL, + f"{type(exc).__name__}: {exc}", + ) + _write_json(captures_dir / "short-pair.json", short) + + soak: Optional[Mapping[str, Any]] = None + if config.soak_seconds is not None: + soak = _attempt( + report, + "requested dual-hand diagnostic soak", + lambda: backend.soak( + targets, + seconds=config.soak_seconds, + window_seconds=config.window_seconds, + output_dir=run_dir / "soak", + store_raw=config.store_soak_raw, + min_free_gib=config.min_free_gib, + ), + ) + if isinstance(soak, Mapping): + for value in soak.get("devices", {}).values(): + if isinstance(value, dict): + try: + bind_raw_tag2_capture(value, contract_evidence) + except Exception as exc: + report.add( + "bind soak TAG2 bytes to canonical spec", + FAIL, + f"{type(exc).__name__}: {exc}", + ) + _write_json(run_dir / "soak" / "soak-summary.json", soak) + + soak_verdict, soak_detail, soak_measurements = _release_soak_gate(config, soak) + report.add("72h dual-hand soak", soak_verdict, soak_detail, soak_measurements) + + after: Dict[str, Any] = {} + for side, target in targets.items(): + snapshot = _attempt(report, f"{side}: immutable after snapshot", lambda t=target: backend.snapshot(t)) + if snapshot is None: + continue + after[side] = snapshot + path = run_dir / "after" / f"{side}.json" + digest = _write_json(path, snapshot, seal=True) + report.artifacts[f"after_{side}"] = f"{path} sha256={digest}" + if side in before: + ok, failures = _compare_snapshots(before[side], snapshot) + report.add( + f"{side}: before/after identity calibration health counters", + PASS if ok else FAIL, + "; ".join(failures), + ) + report.snapshots["after"] = after + + finished_for_logs = utc_now() + log_result = capture_kernel_usb_logs( + report.started_at, finished_for_logs, run_dir / "kernel-usb.log" + ) + report.add( + "host kernel USB logs", + PASS if log_result.get("available") else WARN, + "captured" if log_result.get("available") else str(log_result.get("reason", "unavailable")), + log_result, + ) + return _finalize(report) + + +def _markdown(data: Mapping[str, Any]) -> str: + lines = [ + "# OGLO release HIL evidence", + "", + f"- Result: **{str(data['result']).upper()}**", + f"- Started: `{data['started_at']}`", + f"- Finished: `{data.get('finished_at')}`", + f"- Expected firmware: `{data['config']['expected_firmware']}`", + f"- Flash performed by this runner: `{data['flash_performed']}`", + "", + "## Checks", + "", + "| Result | Check | Detail |", + "|---|---|---|", + ] + for check in data["checks"]: + detail = str(check.get("detail", "")).replace("|", "\\|").replace("\n", " ") + name = str(check["name"]).replace("|", "\\|") + lines.append(f"| {str(check['verdict']).upper()} | {name} | {detail} |") + lines += [ + "", + "## Evidence boundary", + "", + "This runner never flashes firmware and never changes calibration. Before/after JSON", + "files are read-only and every artifact is covered by `manifest.sha256`.", + "", + ] + return "\n".join(lines) + + +__all__ = [ + "ActualBackend", + "HilConfig", + "HilReport", + "StreamMonitor", + "Target", + "capture_tag_stream", + "run_hil", + "run_line_matrix", + "validate_config", +] diff --git a/tests/fake_serial.py b/tests/fake_serial.py index a96ca3d..574ef2a 100644 --- a/tests/fake_serial.py +++ b/tests/fake_serial.py @@ -11,6 +11,7 @@ import json import struct import time +import zlib from typing import Callable, Dict, List, Optional from oglo import _wire as w @@ -27,12 +28,24 @@ } COUNTS = [550 + (i % 17) for i in range(w.TAXELS)] +BOOT_A = "0123456789abcdef0123456789abcdef" +BOOT_B = "fedcba9876543210fedcba9876543210" def tag(ptype: int, seq: int, t_us: int, payload: bytes) -> bytes: return w.TAG_MAGIC + bytes([ptype]) + struct.pack(" bytes: + body = ( + w.TAG_V2_MAGIC + + bytes([ptype]) + + struct.pack(" bytes: """Tactile at 250 Hz with IMU at 500 and mag at 125, the shipping ratio.""" out = bytearray() @@ -54,6 +67,33 @@ def tagged_burst(n_tactile: int = 4, *, start_seq: int = 0) -> bytes: ) return bytes(out) + +def tagged_v2_burst( + n_tactile: int = 4, *, start_seq: int = 0, start_time_us: Optional[int] = None +) -> bytes: + """TAG v2 shipping-ratio burst with an independently controlled u64 clock.""" + out = bytearray() + first_time = start_seq * 4000 if start_time_us is None else start_time_us + for k in range(n_tactile): + seq = (start_seq + k) & 0xFFFFFFFF + timestamp_us = first_time + k * 4000 + out += tag2(w.TAG_TACTILE, seq, timestamp_us, w.pack12(COUNTS)) + for j in range(2): + out += tag2( + w.TAG_IMU, + (seq * 2 + j) & 0xFFFFFFFF, + timestamp_us + 2000 * j, + struct.pack("<6h", 777, -531, -3982, -5, -8, 1), + ) + if seq % 2 == 0: + out += tag2( + w.TAG_MAG, + (seq // 2) & 0xFFFFFFFF, + timestamp_us, + struct.pack("<3h", 3142, 678, -1107), + ) + return bytes(out) + class FakeSerial: """Answers commands the way a board does; hands back bytes in ragged chunks.""" @@ -88,9 +128,21 @@ def __init__( # already queued at STREAM ON. Starting every synthetic refill at zero made # the fake manufacture duplicate/backward packets and hid bugs whenever the # recorder did not treat those anomalies as data-integrity failures. + initial_v2, _ = w.iter_tagged_v2(stream) initial, _ = w.iter_tagged(stream) + initial = initial_v2 or initial tactile_seqs = [p.seq for p in initial if isinstance(p, w.TactilePacket)] self._next_tactile_seq = ((tactile_seqs[-1] + 1) & 0xFFFFFFFF) if tactile_seqs else 0 + tactile_times = [ + p.device_time_us if p.device_time_us is not None else p.t_us + for p in initial + if isinstance(p, w.TactilePacket) + ] + self._next_tactile_time_us = (tactile_times[-1] + 4000) if tactile_times else 0 + self._tag_version = 1 + self.tag2_boot_id = (config or {}).get("boot_id") or ("0" * 32) + self.emit_tag2_ack = True + self.tag2_prelude = b"" self._next_refill: Optional[float] = None self.commands: List[str] = [] self.closed = False @@ -163,7 +215,15 @@ def _refill(self) -> bytes: else: bursts = 1 n = self._burst_tactile * bursts - out = tagged_burst(n, start_seq=self._next_tactile_seq) + if self._tag_version == 2: + out = tagged_v2_burst( + n, + start_seq=self._next_tactile_seq, + start_time_us=self._next_tactile_time_us, + ) + self._next_tactile_time_us += n * 4000 + else: + out = tagged_burst(n, start_seq=self._next_tactile_seq) self._next_tactile_seq = (self._next_tactile_seq + n) & 0xFFFFFFFF return out @@ -186,6 +246,7 @@ def _handle(self, cmd: str) -> None: self._out += b"#STATUS " + json.dumps(self.status).encode() + b"\r\n" return if up == "STREAM TAG ON": + self._tag_version = 1 self._streaming = True self._out += self._stream if self._burst_secs > 0: @@ -196,6 +257,19 @@ def _handle(self, cmd: str) -> None: if up == "STREAM TAG OFF": self._streaming = False return + if up == "STREAM TAG2 ON": + self._tag_version = 2 + self._streaming = True + self._out += self.tag2_prelude + if self.emit_tag2_ack: + self._out += f"#STREAM TAG2 on boot_id={self.tag2_boot_id}\r\n".encode() + self._out += self._stream + if self._burst_secs > 0: + self._next_refill = time.monotonic() + self._burst_secs + return + if up == "STREAM TAG2 OFF": + self._streaming = False + return # Reply with the firmware's ACTUAL strings, not a generic #OK. A fake that # answers differently from the board tests the fake. if up.startswith("SET THR "): diff --git a/tests/test_config.py b/tests/test_config.py index 92af5d8..aba393d 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -6,6 +6,7 @@ import pytest +from fake_serial import BOOT_A from oglo._config import ( Capabilities, ConfigError, @@ -32,6 +33,34 @@ def test_a_current_board_parses_to_the_right_capabilities(): assert info.serial == "OGLO-L-TEST01" and info.side == "left" and info.is_left assert (info.rate_hz, info.has_mag, info.zero_valid, info.stream_clean) == (250, True, True, True) assert (caps.values_per_sample, caps.imu_len, caps.has_mag) == (80, 25, True) + assert info.tag_ver_max == caps.tag_ver_max == 1 + assert info.boot_id is None + + +def test_tag_v2_capability_and_boot_identity_are_parsed_without_guessing(): + info, caps = parse_config({**CFG_V6, "tag_ver_max": 2, "boot_id": BOOT_A}) + assert info.tag_ver_max == caps.tag_ver_max == 2 + assert info.boot_id == BOOT_A + + +@pytest.mark.parametrize( + "boot_id", + [0, (1 << 128) - 1, BOOT_A.upper(), True, -1, "", "g" * 32], +) +def test_boot_identity_rejects_every_noncanonical_config_encoding(boot_id): + with pytest.raises(ConfigError, match="32 lowercase hexadecimal"): + parse_config({**CFG_V6, "tag_ver_max": 2, "boot_id": boot_id}) + + +def test_info_additive_tag_fields_do_not_move_the_existing_raw_positional_argument(): + raw = {"future": 7} + info = Info( + "OGLO-L-TEST01", "left", "RDR02", "0.9.10", 250, + ["pinky", "ring", "middle", "index", "thumb"], True, "usb", + True, True, 80, None, 0, raw, + ) + assert info.raw is raw + assert info.tag_ver_max == 1 and info.boot_id is None def test_the_left_hand_finger_order_comes_from_the_board(): @@ -75,6 +104,8 @@ def test_an_unknown_finger_name_says_what_the_board_actually_has(): ({**CFG_V6, "has_mag": "false"}, "has_mag must be a JSON boolean"), ({**CFG_V6, "zero_valid": 1}, "zero_valid must be a JSON boolean"), ({**CFG_V6, "samples_per_packet": 4}, "samples_per_packet"), + ({**CFG_V6, "tag_ver_max": 0}, "tag_ver_max"), + ({**CFG_V6, "tag_ver_max": "2"}, "tag_ver_max must be a JSON integer"), ], ) def test_configs_the_sdk_cannot_work_with_are_rejected_clearly(cfg, msg): diff --git a/tests/test_device.py b/tests/test_device.py index 4575948..166c704 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -394,6 +394,36 @@ def test_usb_device_time_unwraps_the_32_bit_rollover_without_inventing_host_spac assert first.host_received_ns == second.host_received_ns +def test_v1_unwrapper_can_cross_multiple_rollovers_when_each_observation_is_unambiguous(): + from oglo._stream import DeviceTimeUnwrapper + + clock = DeviceTimeUnwrapper() + raw = [0xFFFFFFF0, 0x20, 0x70000020, 0xE0000020, 0x40000020] + unwrapped = [clock.unwrap(value) for value in raw] + assert all(b > a for a, b in zip(unwrapped, unwrapped[1:])) + assert unwrapped[-1] - unwrapped[0] == (1 << 32) + 0x40000030 + + +def test_tag_v2_uses_the_wire_u64_clock_across_multiple_v1_rollover_epochs(): + from fake_serial import BOOT_A, tagged_v2_burst + + start = (3 << 32) - 4000 + config = { + **CFG_V6, + "fw_rev": "0.9.13", + "tag_ver_max": 2, + "boot_id": BOOT_A, + } + g, _ = make( + cfg=config, + stream=tagged_v2_burst(3, start_time_us=start), + chunk=8192, + ) + tactile = g.read_batch(timeout=1.0).tactile[:3] + assert [frame.t_us for frame in tactile] == [0xFFFFF060, 0, 4000] + assert [frame.device_time_us for frame in tactile] == [start, 3 << 32, (3 << 32) + 4000] + + def test_ble_imu_uses_the_signed_imu_capture_offset_not_the_tactile_time(): from oglo import _wire as w diff --git a/tests/test_documentation.py b/tests/test_documentation.py index 24dc51d..23fed16 100644 --- a/tests/test_documentation.py +++ b/tests/test_documentation.py @@ -20,16 +20,16 @@ def test_current_docs_match_the_release_and_firmware_floor(): text = "\n".join(path.read_text() for path in CURRENT_DOCS) - assert oglo.__version__ == "0.1.0rc3" + assert oglo.__version__ == "0.1.0rc4" assert MIN_FIRMWARE == (0, 9, 10) - assert "0.1.0rc2" not in text + assert "0.1.0rc3" not in text assert "0.9.9" not in text assert "pair_id" not in text assert "allow_unpaired" not in text assert "allow-unpaired" not in text - assert "0.9.11" in text - assert "oglo-0.1.0rc3-py3-none-any.whl" in text - assert "@v0.1.0rc3" in text + assert "0.9.12" in text + assert "oglo-0.1.0rc4-py3-none-any.whl" in text + assert "@v0.1.0rc4" in text def test_current_markdown_relative_links_resolve(): diff --git a/tests/test_hil.py b/tests/test_hil.py new file mode 100644 index 0000000..1d38744 --- /dev/null +++ b/tests/test_hil.py @@ -0,0 +1,584 @@ +"""The release HIL runner is safe and useful before it ever touches a board.""" + +from __future__ import annotations + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest + +from fake_serial import BOOT_A, CFG_V6, FakeSerial, tagged_burst, tagged_v2_burst +from oglo import cli +from oglo.hil import ( + FAIL, + PASS, + RELEASE_SOAK_SECONDS, + WARN, + HilConfig, + HilReport, + StreamMonitor, + Target, + _release_soak_gate, + capture_tag_stream, + run_hil, + run_line_matrix, + sha256_file, + validate_config, + validate_tag2_spec, +) + + +LEFT = "OGLO-L-00028" +RIGHT = "OGLO-R-00028" + + +def _config(tmp_path: Path, **changes) -> HilConfig: + values = { + "left_serial": LEFT, + "right_serial": RIGHT, + "output_root": tmp_path, + "tag_seconds": 0.01, + "reconnect_cycles": 1, + "reconnect_seconds": 0.01, + "stall_seconds": 0.01, + "recovery_seconds": 0.01, + "short_seconds": 0.01, + "window_seconds": 0.01, + } + values.update(changes) + return HilConfig(**values) + + +@pytest.mark.parametrize( + ("changes", "message"), + [ + ({"left_serial": "OGLO-R-00028"}, "left serial"), + ({"right_serial": "OGLO-R-28"}, "right serial"), + ({"expected_firmware": "latest"}, "numeric"), + ({"min_free_gib": 99.9}, "100 GiB"), + ({"soak_seconds": True}, "finite number"), + ({"soak_seconds": float("nan")}, "finite number"), + ], +) +def test_hil_target_and_resource_guardrails_fail_before_discovery(tmp_path, changes, message): + with pytest.raises(ValueError, match=message): + validate_config(_config(tmp_path, **changes)) + + +def test_long_soak_needs_exact_pair_confirmation(tmp_path): + with pytest.raises(ValueError, match="confirm-soak"): + validate_config(_config(tmp_path, soak_seconds=72 * 3600)) + + validate_config( + _config( + tmp_path, + soak_seconds=72 * 3600, + confirm_soak=f"{LEFT},{RIGHT}", + ) + ) + + +def test_short_diagnostic_soak_cannot_satisfy_the_explicit_72h_gate(tmp_path): + config = _config(tmp_path, soak_seconds=RELEASE_SOAK_SECONDS - 1) + verdict, detail, measurements = _release_soak_gate(config, {"ok": True}) + report = HilReport(run_dir=tmp_path, config=config) + report.add("diagnostic dual-hand soak", PASS) + report.add("72h dual-hand soak", verdict, detail, measurements) + + assert verdict == WARN + assert report.result == WARN + assert measurements["minimum_release_seconds"] == 259200 + assert "diagnostic soak" in detail + + +def test_72h_gate_needs_duration_confirmation_and_a_passing_soak(tmp_path): + confirmed = _config( + tmp_path, + soak_seconds=RELEASE_SOAK_SECONDS, + confirm_soak=f"{LEFT},{RIGHT}", + ) + assert _release_soak_gate(confirmed, {"ok": True})[0] == PASS + assert _release_soak_gate(confirmed, {"ok": False})[0] == FAIL + + +@pytest.mark.parametrize( + ("field", "value"), + [ + ("start_ack_prefix", "#STREAM TAG2 boot="), + ("boot_id.bytes", 8), + ("boot_id.scope", "one stream"), + ], +) +def test_canonical_spec_binds_full_ack_and_boot_identity_contract(tmp_path, field, value): + source = Path(__file__).resolve().parents[1] / "spec" / "TAG_V2.json" + spec = json.loads(source.read_text()) + if field == "start_ack_prefix": + spec["negotiation"][field] = value + else: + _, child = field.split(".") + spec["boot_id"][child] = value + changed = tmp_path / "TAG_V2.json" + changed.write_text(json.dumps(spec)) + + with pytest.raises(ValueError, match="spec/parser mismatch"): + validate_tag2_spec(changed) + + +def test_dry_run_opens_no_hardware_and_writes_verifiable_evidence(tmp_path): + class ForbiddenBackend: + def __getattribute__(self, name): + raise AssertionError(f"dry-run touched backend.{name}") + + report = run_hil(_config(tmp_path, dry_run=True), backend=ForbiddenBackend()) + assert report.result == "dry-run" + assert report.as_dict()["flash_performed"] is False + + raw = json.loads((report.run_dir / "hil-report.json").read_text()) + assert raw["result"] == "dry-run" + assert raw["config"]["left_serial"] == LEFT + manifest = (report.run_dir / "manifest.sha256").read_text().splitlines() + assert manifest + for row in manifest: + digest, relative = row.split(" ", 1) + assert sha256_file(report.run_dir / relative) == digest + + +def test_dry_run_does_not_hide_a_broken_canonical_contract(tmp_path): + bad_spec = tmp_path / "TAG_V2.json" + bad_spec.write_text("{}\n", encoding="utf-8") + report = run_hil( + _config(tmp_path / "results", dry_run=True, tag2_spec=bad_spec) + ) + assert report.result == FAIL + assert "unbound wire contract" in report.error + + +@pytest.mark.parametrize( + ("raised", "expected"), + [ + (KeyboardInterrupt(), KeyboardInterrupt), + (SystemExit(17), SystemExit), + ], +) +def test_interrupts_seal_failed_hil_evidence_before_reraising(tmp_path, raised, expected): + class InterruptedBackend: + def discover(self, config): + raise raised + + with pytest.raises(expected) as caught: + run_hil(_config(tmp_path), backend=InterruptedBackend()) + + error = caught.value + assert error is raised + assert error.hil_report_finalized is True + report_path = Path(error.hil_report_path) + manifest_path = report_path.with_name("manifest.sha256") + raw = json.loads(report_path.read_text()) + assert raw["result"] == FAIL + assert raw["finished_at"] is not None + assert raw["error"].startswith(type(raised).__name__) + assert any(item["name"] == "HIL execution interrupted" for item in raw["checks"]) + assert manifest_path.exists() + assert report_path.stat().st_mode & 0o222 == 0 + assert manifest_path.stat().st_mode & 0o222 == 0 + + +def test_hil_cli_dry_run_requires_exact_serials_and_reports_directory(tmp_path, capsys): + result = cli.main( + [ + "hil", + "--left", + LEFT, + "--right", + RIGHT, + "--output", + str(tmp_path), + "--dry-run", + ] + ) + assert result == 0 + output = capsys.readouterr().out + assert "HIL result: dry-run" in output and "Evidence:" in output + + +def test_incremental_monitors_cover_tag1_tag2_crc_loss_and_u64(): + v1 = StreamMonitor(1, started_ns=0) + payload = tagged_burst(8) + for offset in range(0, len(payload), 7): + v1.feed(payload[offset:offset + 7]) + v1_summary = v1.cumulative(now_ns=1_000_000_000) + assert v1_summary["counts"] == {"tactile": 8, "imu": 16, "mag": 4} + assert v1_summary["malformed_crc_or_structure"] == 0 + assert v1_summary["crc_checked"] is False + + original = tagged_v2_burst(8, start_time_us=0x1_0000_0000 + 123) + corrupted = bytearray(original) + corrupted[20] ^= 0x01 # first frame payload; its CRC must reject that whole frame + v2 = StreamMonitor(2, started_ns=0) + for offset in range(0, len(corrupted), 11): + v2.feed(bytes(corrupted[offset:offset + 11])) + v2_summary = v2.cumulative(now_ns=1_000_000_000) + assert v2_summary["counts"]["tactile"] == 7 + assert v2_summary["counts"]["imu"] == 16 + assert v2_summary["counts"]["mag"] == 4 + assert v2_summary["malformed_crc_or_structure"] == 1 + assert v2_summary["crc_checked"] is True + assert v2.last_device_us["tactile"] > 0xFFFFFFFF + + +class _LineSerial: + def __init__(self) -> None: + self.timeout = 0.0 + self.dtr = False + self.rts = False + self.closed = False + self.out = bytearray() + self.uptime = 1000 + + def write(self, data: bytes) -> int: + for command in data.splitlines(): + if command == b"GET STATUS" and self.dtr: + self.uptime += 10 + status = { + "uptime_ms": self.uptime, + "seq": 1, + "imu_ok": True, + "imu": {"ok": True, "mag_ok": True}, + "sensor_ok": True, + "error_flags": 0, + "deadline_misses": 0, + "tag_dropped": 0, + "tag_short_writes": 0, + } + self.out += b"#STATUS " + json.dumps(status).encode() + b"\n" + if command == b"GET CONFIG" and self.dtr: + self.out += b"#CONFIG " + json.dumps( + {"serial": LEFT, "side": "left", "boot_id": BOOT_A} + ).encode() + b"\n" + return len(data) + + def flush(self) -> None: + pass + + def read(self, size: int = 1) -> bytes: + out = bytes(self.out[:size]) + del self.out[:size] + return out + + def reset_input_buffer(self) -> None: + self.out.clear() + + def close(self) -> None: + self.closed = True + + +def test_dtr_rts_matrix_proves_dtr_gate_and_rts_independence_without_reenumeration(): + serial = _LineSerial() + target = Target( + side="left", + logical_serial=LEFT, + port="/dev/fake-left", + usb_serial="USBLEFT", + vid=0x2886, + pid=1, + product="OGLO", + manufacturer="OpenGraphLabs", + ) + result = run_line_matrix( + target, + serial_factory=lambda *_: serial, + candidate_provider=lambda: [ + SimpleNamespace(device=target.port, serial_number=target.usb_serial) + ], + settle_seconds=0.0, + response_seconds=0.001, + sleep=lambda _: None, + ) + assert result["ok"] + assert result["sequence"] == ["00", "10", "00", "11", "00", "01", "00"] + assert [item["response_bytes"] > 0 for item in result["observations"]] == [ + False, True, False, True, False, False, False + ] + assert result["uptimes_ms"] == sorted(result["uptimes_ms"]) + assert result["postcheck_10"]["config"]["boot_id"] == BOOT_A + assert result["actual_transition_states"] == [ + "00", "10", "00", "11", "00", "01", "00", "10", "00" + ] + assert serial.closed + + +def test_real_capture_helper_negotiates_exact_tag2_ack_and_checks_every_crc(tmp_path): + target = Target( + side="left", + logical_serial=LEFT, + port="/dev/fake-left", + usb_serial="USBLEFT", + vid=0x2886, + pid=1, + product="OGLO", + manufacturer="OpenGraphLabs", + ) + cfg = { + **CFG_V6, + "serial": LEFT, + "fw_rev": "0.9.13", + "tag_ver_max": 2, + "boot_id": BOOT_A, + } + + def factory(*_): + serial = FakeSerial( + cfg, + stream=tagged_v2_burst(4), + chunk=17, + hz=1000.0, + ) + serial.timeout = 0.0 + serial.dtr = True + serial.rts = False + return serial + + raw_path = tmp_path / "left-tag2.bin" + result = capture_tag_stream( + target, + version=2, + seconds=0.04, + serial_factory=factory, + raw_path=raw_path, + ) + assert result["ok"], result["failures"] + assert result["ack_boot_id"] == result["config_boot_id"] == BOOT_A + assert result["crc_checked"] is True + assert all(result["counts"][name] > 0 for name in ("tactile", "imu", "mag")) + assert result["malformed_crc_or_structure"] == 0 + assert result["raw_sha256"] == sha256_file(raw_path) + + +def test_stalled_reader_records_both_boundaries_and_waits_past_stale_backlog(tmp_path): + target = Target( + "left", LEFT, "/dev/fake-left", "USBLEFT", 0x2886, 1, "OGLO", "OGL" + ) + cfg = { + **CFG_V6, + "serial": LEFT, + "fw_rev": "0.9.13", + "tag_ver_max": 2, + "boot_id": BOOT_A, + } + + def factory(*_): + serial = FakeSerial( + cfg, stream=tagged_v2_burst(4), chunk=17, hz=1000.0 + ) + serial.timeout = 0.0 + return serial + + result = capture_tag_stream( + target, + version=2, + seconds=0.04, + stall_before_read=0.08, + serial_factory=factory, + raw_path=tmp_path / "recovery.bin", + ) + + recovery = result["stalled_reader_recovery"] + assert result["ok"], result["failures"] + assert recovery["pre_stall_boundary"]["seq"]["tactile"] is not None + assert recovery["pre_stall_boundary"]["device_time_us"]["tactile"] is not None + assert recovery["pre_stall_boundary"]["boot_id"] == BOOT_A + assert recovery["pre_stall_boundary"]["status"]["uptime_ms"] > 0 + assert recovery["host_input_reset_after_stall"] is True + assert recovery["post_stall_first_fresh_tactile"] is not None + assert recovery["first_fresh_frame_latency_after_input_reset_s"] is not None + assert recovery["boot_identity_unchanged"] is True + assert recovery["tactile_seq_transition"] in ("forward", "wrap") + assert recovery["tactile_device_time_advance_us"] >= recovery[ + "expected_device_time_advance_min_us" + ] + assert recovery["stale_device_backlog_detected"] is True + assert recovery["stale_tactile_frames_before_fresh"] > 0 + + +def test_stalled_reader_never_calls_old_backlog_a_fresh_recovery(tmp_path): + target = Target( + "left", LEFT, "/dev/fake-left", "USBLEFT", 0x2886, 1, "OGLO", "OGL" + ) + cfg = { + **CFG_V6, + "serial": LEFT, + "fw_rev": "0.9.13", + "tag_ver_max": 2, + "boot_id": BOOT_A, + } + + class StaleOnlySerial(FakeSerial): + def _refill(self): + return tagged_v2_burst(1, start_seq=1, start_time_us=4_000) + + def factory(*_): + serial = StaleOnlySerial(cfg, stream=tagged_v2_burst(1), chunk=8192) + serial.timeout = 0.0 + return serial + + result = capture_tag_stream( + target, + version=2, + seconds=0.02, + stall_before_read=0.08, + serial_factory=factory, + ) + + recovery = result["stalled_reader_recovery"] + assert result["ok"] is False + assert recovery["post_stall_first_fresh_tactile"] is None + assert recovery["stale_device_backlog_detected"] is True + assert "no fresh valid TAG2 frame" in "; ".join(result["failures"]) + + +def _snapshot(side: str, firmware: str) -> dict: + serial = LEFT if side == "left" else RIGHT + config = { + **CFG_V6, + "serial": serial, + "side": side, + "fw_rev": firmware, + "tag_ver_max": 2, + "boot_id": BOOT_A, + } + status = { + "uptime_ms": 1000, + "seq": 1, + "imu_ok": True, + "mag_ok": True, + "sensor_ok": True, + "error_flags": 0, + "deadline_misses": 0, + "tag_dropped": 0, + "tag_short_writes": 0, + "raw": {}, + } + zero = {"valid": True, "count": 80, "baseline": [1] * 80, "noise": [1] * 80} + return { + "config": config, + "status": status, + "zero": zero, + "calibration_sha256": "a" * 64, + "fwinfo": {"running_image_sha256": "b" * 64}, + "fwinfo_error": None, + "running_image_sha256": "b" * 64, + } + + +def test_preflight_fails_closed_before_modem_lines_when_candidate_is_not_expected(tmp_path): + targets = { + side: Target(side, LEFT if side == "left" else RIGHT, f"/dev/{side}", side, 0x2886, 1, "OGLO", "OGL") + for side in ("left", "right") + } + + class Backend: + line_calls = 0 + + def discover(self, config): + return targets, [] + + def snapshot(self, target): + return _snapshot(target.side, "0.9.12") + + def line_matrix(self, target): + self.line_calls += 1 + return {"ok": True} + + backend = Backend() + report = run_hil(_config(tmp_path), backend=backend) + assert report.result == FAIL + assert "preflight failed" in report.error + assert backend.line_calls == 0 + assert (report.run_dir / "manifest.sha256").exists() + + +def test_full_fake_backend_binds_every_saved_tag2_capture_and_preserves_snapshots(tmp_path): + targets = { + side: Target( + side, + LEFT if side == "left" else RIGHT, + f"/dev/{side}", + side, + 0x2886, + 1, + "OGLO", + "OGL", + ) + for side in ("left", "right") + } + + class Backend: + def discover(self, config): + return targets, [target.as_dict() for target in targets.values()] + + def snapshot(self, target): + return _snapshot(target.side, "0.9.13") + + def line_matrix(self, target): + return {"ok": True, "sequence": ["00", "10", "00", "11", "00", "01", "00"]} + + @staticmethod + def _factory(target, version): + cfg = { + **CFG_V6, + "serial": target.logical_serial, + "side": target.side, + "fw_rev": "0.9.13", + "tag_ver_max": 2, + "boot_id": BOOT_A, + } + stream = tagged_v2_burst(4) if version == 2 else tagged_burst(4) + + def factory(*_): + serial = FakeSerial(cfg, stream=stream, chunk=31, hz=1000.0) + serial.timeout = 0.0 + serial.dtr = True + serial.rts = False + return serial + + return factory + + def capture(self, target, *, version, seconds, raw_path, stall_before_read=0.0): + return capture_tag_stream( + target, + version=version, + seconds=max(0.02, seconds), + serial_factory=self._factory(target, version), + raw_path=raw_path, + stall_before_read=stall_before_read, + ) + + def reconnect(self, target, *, cycles, seconds): + return {"ok": True, "failures": [], "cycles": [{"cycle": 1}]} + + def dual_capture(self, targets, *, seconds, output_dir, label): + devices = { + side: self.capture( + target, + version=2, + seconds=seconds, + raw_path=output_dir / f"{label}-{side}-tag2.bin", + ) + for side, target in targets.items() + } + return {"ok": all(item["ok"] for item in devices.values()), "devices": devices} + + report = run_hil(_config(tmp_path), backend=Backend()) + failures = [item for item in report.checks if item.verdict == FAIL] + assert not failures, failures + assert report.result == "warn" # the explicitly omitted 72-hour gate remains visible + for side in ("left", "right"): + capture = json.loads( + (report.run_dir / "captures" / f"{side}-tag2.json").read_text() + ) + assert capture["saved_raw_reparsed_against_contract"] is True + assert capture["tag2_contract_spec_sha256"] == ( + "e002287e4239dc85b547326f7da7871d62648c314f7394fed8d0b70adbcd9b0f" + ) + assert Path(report.artifacts[f"before_{side}"].split(" sha256=", 1)[0]).stat().st_mode & 0o222 == 0 diff --git a/tests/test_record_replay.py b/tests/test_record_replay.py index 78e7710..1c938fe 100644 --- a/tests/test_record_replay.py +++ b/tests/test_record_replay.py @@ -9,7 +9,7 @@ import numpy as np import pytest -from fake_serial import CFG_V6, FakeSerial, tagged_burst +from fake_serial import BOOT_A, BOOT_B, CFG_V6, FakeSerial, tagged_burst, tagged_v2_burst from oglo import record, replay from oglo._record import RecordError, Recorder, next_episode_dir from oglo._replay import ReplayError @@ -19,7 +19,8 @@ def glove(cfg=CFG_V6, n=40, *, hz=None) -> Glove: - s = FakeSerial(cfg, stream=tagged_burst(n), hz=hz) + stream = tagged_v2_burst(n) if int(cfg.get("tag_ver_max", 1)) >= 2 else tagged_burst(n) + s = FakeSerial(cfg, stream=stream, hz=hz) t = UsbTransport(s) info, caps = t.read_config(interval=0.01, drain=0) return Glove(t, info, caps) @@ -280,6 +281,93 @@ def test_the_metadata_identifies_the_board_and_the_firmware(tmp_path): assert meta.get(key) not in (None, "", []), f"{key} missing from meta.json" +def test_tag_v2_recording_pins_wire_version_and_boot_identity(tmp_path): + config = { + **CFG_V6, + "fw_rev": "0.9.13", + "tag_ver_max": 2, + "boot_id": BOOT_A, + } + meta = json.loads((recorded(tmp_path, cfg=config, seconds=0.1) / "meta.json").read_text()) + assert meta["tag_version"] == 2 + assert meta["tag_ver_max"] == 2 + assert meta["boot_id"] == BOOT_A + episode = replay(tmp_path / "ep_0001") + assert episode.info.tag_ver_max == 2 + assert episode.info.boot_id == BOOT_A + + +@pytest.mark.parametrize( + ("mutation", "message"), + [ + (lambda meta: meta.pop("boot_id"), "TAG2 provenance requires.*boot_id"), + (lambda meta: meta.__setitem__("transport", "ble"), "TAG2 provenance.*non-USB"), + ], +) +def test_replay_rejects_impossible_tag2_metadata(tmp_path, mutation, message): + config = { + **CFG_V6, + "fw_rev": "0.9.13", + "tag_ver_max": 2, + "boot_id": BOOT_A, + } + episode = recorded(tmp_path, cfg=config, seconds=0.1) + meta_path = episode / "meta.json" + meta = json.loads(meta_path.read_text()) + mutation(meta) + meta_path.write_text(json.dumps(meta)) + + with pytest.raises(ReplayError, match=message): + replay(episode) + + +def test_replay_keeps_pre_tag2_schema2_metadata_backward_compatible(tmp_path): + episode = recorded(tmp_path, seconds=0.1) + meta_path = episode / "meta.json" + meta = json.loads(meta_path.read_text()) + meta.pop("tag_version") + meta.pop("tag_ver_max") + meta.pop("boot_id") + meta_path.write_text(json.dumps(meta)) + + replayed = replay(episode) + assert replayed.info.tag_ver_max == 1 + assert replayed.info.boot_id is None + + +def test_changed_tag_v2_boot_id_fails_an_active_recording_and_seals_it_incomplete(tmp_path): + config = { + **CFG_V6, + "fw_rev": "0.9.13", + "tag_ver_max": 2, + "boot_id": BOOT_A, + } + g = glove(config, n=40, hz=250) + serial = g._t._s + original_handle = serial._handle + status_reads = 0 + + def change_boot_after_second_status(command): + nonlocal status_reads + original_handle(command) + if command.upper() == "GET STATUS": + status_reads += 1 + if status_reads == 2: + serial.tag2_boot_id = BOOT_B + + serial._handle = change_boot_after_second_status + try: + with pytest.raises(RecordError, match="boot identity changed"): + record(tmp_path, seconds=0.1, glove=g) + finally: + g.close() + + meta = json.loads((tmp_path / "ep_0001" / "meta.json").read_text()) + assert meta["complete"] is False + assert meta["stop_reason"] == "status_error" + assert "boot identity changed" in meta["error"] + + def test_metadata_contains_start_end_device_status_and_capture_counter_deltas(tmp_path): meta = json.loads((recorded(tmp_path) / "meta.json").read_text()) assert meta["complete"] is True @@ -827,6 +915,59 @@ def read_batch(self): assert int(arrays["device_time_us"][0]) == (1 << 32) - 16 +def test_open_but_silent_transport_seals_partial_episode_instead_of_hanging( + tmp_path, monkeypatch +): + """An alive serial handle with empty reads must not record forever.""" + import oglo._record as record_module + from oglo._config import parse_config + from oglo._device import SampleBatch + from oglo._status import DeviceStatus + + info, _ = parse_config(CFG_V6) + + class SilentAfterOneBatch: + dropped = {} + + def __init__(self): + self.info = info + self.calls = 0 + + def status(self): + return DeviceStatus( + uptime_ms=1, seq=1, imu_ok=True, mag_ok=True, sensor_ok=True, + error_flags=0, deadline_misses=0, tag_dropped=0, + tag_short_writes=0, + ) + + def read_batch(self): + self.calls += 1 + if self.calls == 1: + return SampleBatch( + tactile=(Frame( + seq=1, t_us=1, host_t=1.0, + counts=np.zeros((5, 4, 4), dtype=np.uint16), + ),), + imu=(ImuSample( + seq=1, t_us=1, host_t=1.0, + accel=(0, 0, 1), gyro=(0, 0, 0), + ),), + mag=(MagSample( + seq=1, t_us=1, host_t=1.0, field=(0, 0, 1), + ),), + ) + return SampleBatch() + + monkeypatch.setattr(record_module, "RECORDING_STREAM_SILENCE_S", 0.01) + with pytest.raises(RecordError, match="recording stream stalled") as caught: + record(tmp_path, seconds=None, glove=SilentAfterOneBatch()) + assert caught.value.partial_episode == tmp_path / "ep_0001" + meta = json.loads((tmp_path / "ep_0001" / "meta.json").read_text()) + assert meta["complete"] is False + assert meta["stop_reason"] == "error" + assert "recording stream stalled" in meta["error"] + + def test_final_status_failure_with_data_exposes_the_sealed_partial_path(tmp_path): from oglo._config import parse_config from oglo._device import SampleBatch diff --git a/tests/test_release_policy.py b/tests/test_release_policy.py index 24efb08..623bdb7 100644 --- a/tests/test_release_policy.py +++ b/tests/test_release_policy.py @@ -27,7 +27,7 @@ def _load_script(name: str) -> ModuleType: workflow_policy = _load_script("check_workflows") -def _project_file(tmp_path: Path, version: str = "0.1.0rc3") -> Path: +def _project_file(tmp_path: Path, version: str = "0.1.0rc4") -> Path: path = tmp_path / "pyproject.toml" path.write_text( "[build-system]\nrequires = ['hatchling']\n\n" @@ -37,7 +37,7 @@ def _project_file(tmp_path: Path, version: str = "0.1.0rc3") -> Path: return path -def _dist_files(tmp_path: Path, version: str = "0.1.0rc3") -> Path: +def _dist_files(tmp_path: Path, version: str = "0.1.0rc4") -> Path: dist = tmp_path / "dist" dist.mkdir() metadata = f"Metadata-Version: 2.4\nName: oglo\nVersion: {version}\n\n".encode() @@ -65,18 +65,18 @@ def test_release_manifest_round_trip_and_outputs(tmp_path: Path) -> None: source_sha=sha, ) - assert manifest["tag"] == "v0.1.0rc3" + assert manifest["tag"] == "v0.1.0rc4" assert manifest["source_sha"] == sha result = release_policy.verify_manifest( dist_dir=dist, project_file=project, repository="OpenGraphLabs/oglo-python", source_sha=sha, - tag="v0.1.0rc3", + tag="v0.1.0rc4", ) assert result["prerelease"] == "true" - assert result["wheel_name"] == "oglo-0.1.0rc3-py3-none-any.whl" - assert result["sdist_name"] == "oglo-0.1.0rc3.tar.gz" + assert result["wheel_name"] == "oglo-0.1.0rc4-py3-none-any.whl" + assert result["sdist_name"] == "oglo-0.1.0rc4.tar.gz" def test_release_manifest_rejects_changed_distribution_bytes(tmp_path: Path) -> None: @@ -89,7 +89,7 @@ def test_release_manifest_rejects_changed_distribution_bytes(tmp_path: Path) -> repository="OpenGraphLabs/oglo-python", source_sha=sha, ) - wheel = dist / "oglo-0.1.0rc3-py3-none-any.whl" + wheel = dist / "oglo-0.1.0rc4-py3-none-any.whl" wheel.write_bytes(wheel.read_bytes() + b"tampered") with pytest.raises(release_policy.PolicyError, match="bytes do not match"): @@ -98,7 +98,7 @@ def test_release_manifest_rejects_changed_distribution_bytes(tmp_path: Path) -> project_file=project, repository="OpenGraphLabs/oglo-python", source_sha=sha, - tag="v0.1.0rc3", + tag="v0.1.0rc4", ) @@ -106,15 +106,15 @@ def test_release_manifest_rejects_changed_distribution_bytes(tmp_path: Path) -> ("tag", "sha"), [ ("v0.1.0rc2", "a" * 40), - ("0.1.0rc3", "a" * 40), - ("v0.1.0rc3", "A" * 40), - ("v0.1.0rc3", "abc"), + ("0.1.0rc4", "a" * 40), + ("v0.1.0rc4", "A" * 40), + ("v0.1.0rc4", "abc"), ], ) def test_source_policy_rejects_tag_or_sha_mismatch(tag: str, sha: str) -> None: with pytest.raises(release_policy.PolicyError): release_policy.validate_source( - version="0.1.0rc3", + version="0.1.0rc4", tag=tag, repository="OpenGraphLabs/oglo-python", source_sha=sha, @@ -142,7 +142,7 @@ def test_manifest_rejects_forged_source_identity(tmp_path: Path) -> None: project_file=project, repository="OpenGraphLabs/oglo-python", source_sha=sha, - tag="v0.1.0rc3", + tag="v0.1.0rc4", ) diff --git a/tests/test_tag_v2_vectors.py b/tests/test_tag_v2_vectors.py new file mode 100644 index 0000000..049f747 --- /dev/null +++ b/tests/test_tag_v2_vectors.py @@ -0,0 +1,68 @@ +"""Static vectors for the approved TAG v2 wire contract. + +Unlike ``tag_*.bin``, these are not claimed to come from hardware. They pin the host +implementation while firmware is being built; a release gate must replace or +supplement them with captures from the final 0.9.13 image. +""" + +from __future__ import annotations + +import json +import struct +import zlib +from pathlib import Path + +from oglo import _wire as w + + +VECTOR_PATH = Path(__file__).resolve().parent.parent / "spec" / "TAG_V2.json" + + +def test_canonical_tag_v2_contract_and_vectors_decode_to_the_locked_values(): + document = json.loads(VECTOR_PATH.read_text()) + assert document["schema_version"] == 1 + assert document["status"] == "implementation-contract-not-hardware-captured" + assert document["frame"] == { + "magic_hex": "a55b", + "header_format": "<2sBHIQ", + "header_len": 17, + "payload_length": "payload_bytes_only", + "crc32": { + "algorithm": "CRC-32/ISO-HDLC", + "field_format": "= 2: + break + tactile = [packet for packet in packets if isinstance(packet, w.TactilePacket)] + assert [packet.device_time_us for packet in tactile[:2]] == [ + (2 << 32) - 4000, + 2 << 32, + ] + + +def test_split_tag_v2_ack_preserves_the_first_binary_frame(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2, "boot_id": BOOT_A} + stream = tagged_v2_burst(1, start_time_us=(4 << 32) + 123) + s = FakeSerial(config, stream=stream, chunk=1) + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + t.start(ack_timeout=0.2) + packets = [] + for _ in range(len(stream) + 100): + packets += t.poll() + if any(isinstance(packet, w.TactilePacket) for packet in packets): + break + tactile = next(packet for packet in packets if isinstance(packet, w.TactilePacket)) + assert tactile.seq == 0 + assert tactile.device_time_us == (4 << 32) + 123 + + +def test_buffered_text_is_drained_before_tag_v2_command_and_binary_is_preserved(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2, "boot_id": BOOT_A} + stream = tagged_v2_burst(1, start_time_us=(5 << 32) + 321) + s = FakeSerial(config, stream=stream, chunk=7) + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + s._out += b"#HB stale-before-command\r\n" + + assert t.start(ack_timeout=0.2) == "tagged_v2" + packets = [] + for _ in range(len(stream) + 100): + packets += t.poll() + if any(isinstance(packet, w.TactilePacket) for packet in packets): + break + tactile = next(packet for packet in packets if isinstance(packet, w.TactilePacket)) + assert tactile.device_time_us == (5 << 32) + 321 + + +@pytest.mark.parametrize("prelude", [ + b"#HB t_us=123 scan_us=2800\r\n", + b"#ERR busy\r\n", + b"#STREAM TAG2 on boot_id=short\r\n", + b"\xa5\x5b\x01\x00\n", +]) +def test_tag_v2_ack_rejects_any_post_command_line_before_the_exact_ack(prelude): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + s.tag2_prelude = prelude + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + + with pytest.raises(UsbError, match="malformed TAG2 start ACK"): + t.start(ack_timeout=0.2) + + +@pytest.mark.parametrize("boot_id", [BOOT_A.upper(), "0" * 31, "g" * 32]) +def test_tag_v2_ack_rejects_noncanonical_boot_identity(boot_id): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + s.tag2_boot_id = boot_id + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + with pytest.raises(UsbError, match="malformed TAG2 start ACK"): + t.start() + + +def test_tag_v2_start_rejects_config_ack_boot_identity_mismatch_and_stops_stream(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2, "boot_id": BOOT_A} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + s.tag2_boot_id = BOOT_B + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + with pytest.raises(SessionChangedError, match="boot identity changed"): + t.start() + assert s.commands[-1] == "STREAM TAG2 OFF" + assert t.stream_boot_id is None and s._streaming is False + + +def test_tag_v2_resume_rejects_changed_ack_even_without_config_boot_id(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + s.tag2_boot_id = BOOT_A + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + t.start() + t.stop() + s.tag2_boot_id = BOOT_B + with pytest.raises(SessionChangedError, match="boot identity changed"): + t.start(reset_counters=False) + + +def test_tag_v2_start_requires_the_ack_before_accepting_binary(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2} + s = FakeSerial(config, stream=b"") + s.emit_tag2_ack = False + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + with pytest.raises(UsbError, match="no TAG2 start ACK"): + t.start(ack_timeout=0.02) + + +def test_tag_v2_start_rejects_a_malformed_ack_boot_id(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + s.tag2_boot_id = "a" * 31 + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + with pytest.raises(UsbError, match="malformed TAG2 start ACK"): + t.start() + + +def test_tag_v2_start_rolls_back_even_when_ack_read_is_interrupted(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + original_read = s.read + + def interrupt(size=1): + if s._streaming: + raise KeyboardInterrupt + return original_read(size) + + s.read = interrupt + with pytest.raises(KeyboardInterrupt): + t.start() + assert s.commands[-1] == "STREAM TAG2 OFF" + assert not s._streaming and t.stream_boot_id is None + + +def test_session_changed_error_is_publicly_catchable(): + import oglo + + assert oglo.SessionChangedError is SessionChangedError + + +@pytest.mark.parametrize("timeout", [0, -1, True, float("nan"), float("inf")]) +def test_tag_v2_ack_timeout_cannot_disable_the_start_deadline(timeout): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2} + s = FakeSerial(config, stream=b"") + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + with pytest.raises(ValueError, match="finite positive"): + t.start(ack_timeout=timeout) + assert s._streaming is False + + +def test_a_future_tag_capability_is_capped_at_the_latest_known_contract(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 7} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + assert t.start() == "tagged_v2" + assert t.tag_version == 2 + + +def test_boot_identity_is_reobserved_and_never_reused_across_config_reads(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2, "boot_id": BOOT_A} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + t.start() + assert t.stream_boot_id == BOOT_A + s.config = {**config, "boot_id": BOOT_B} + s.tag2_boot_id = BOOT_B + t.read_config(interval=0.01, drain=0) + assert t.stream_boot_id is None + t.start() + assert t.stream_boot_id == BOOT_B + + +def test_failed_reconnect_config_invalidates_the_previous_boot_identity(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2, "boot_id": BOOT_A} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + t.start() + s.config = None + with pytest.raises(UsbError, match="no #CONFIG"): + t.read_config(timeout=0.05, interval=0.01, drain=0) + assert t.stream_boot_id is None + with pytest.raises(UsbError, match="read_config"): + t.start() + + +def test_stopping_tag_v2_uses_the_matching_command(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + t.start() + t.stop() + assert s.commands[-1] == "STREAM TAG2 OFF" + + # --- read loop ------------------------------------------------------------------ @@ -200,6 +453,30 @@ def test_the_read_loop_reassembles_across_any_chunk_size(chunk): assert all(p.counts[0] == 550 for p in tac) +def test_tag_v2_crc_corruption_is_counted_and_the_next_frame_is_delivered(): + first = bytearray(tagged_v2_burst(1, start_time_us=(3 << 32) + 100)) + first[17] ^= 0x01 # first tactile payload byte; leave its CRC unchanged + stream = bytes(first) + tagged_v2_burst( + 1, start_seq=1, start_time_us=(3 << 32) + 4100 + ) + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2, "boot_id": BOOT_A} + s = FakeSerial(config, stream=stream, chunk=7) + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + t.start() + + got = [] + for _ in range(4000): + got += t.poll() + if any(isinstance(packet, w.TactilePacket) and packet.seq == 1 for packet in got): + break + + tactile = [packet for packet in got if isinstance(packet, w.TactilePacket)] + assert not any(packet.seq == 0 for packet in tactile) + assert any(packet.seq == 1 for packet in tactile) + assert t.dropped.malformed_usb >= 1 + + def test_all_three_modalities_arrive_at_their_own_rates(): s = FakeSerial(CFG_V6, stream=tagged_burst(8)) t = UsbTransport(s) @@ -337,3 +614,64 @@ def dead(*a, **k): s.write = dead with pytest.raises(DisconnectedError, match="no longer reachable"): t.send("GET CONFIG") + + +@pytest.mark.parametrize("written", [None, 0, 1, True]) +def test_control_commands_reject_none_zero_boolean_or_partial_serial_writes(written): + s = FakeSerial(CFG_V6) + t = UsbTransport(s) + flushed = False + + def short_write(_payload): + return written + + def flush(): + nonlocal flushed + flushed = True + + s.write = short_write + s.flush = flush + with pytest.raises(DisconnectedError, match="no longer reachable") as caught: + t.send("GET CONFIG") + assert "short serial write" in str(caught.value.__cause__) + assert flushed is False + + +def test_short_tag_v2_on_write_rolls_back_with_the_matching_off_command(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2, "boot_id": BOOT_A} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + writes = [] + original_write = s.write + + def short_first_tag2_on(payload): + writes.append(payload) + if payload == b"STREAM TAG2 ON\n": + return len(payload) - 1 + return original_write(payload) + + s.write = short_first_tag2_on + with pytest.raises(DisconnectedError, match="no longer reachable"): + t.start() + assert writes == [b"STREAM TAG2 ON\n", b"STREAM TAG2 OFF\n"] + assert t.stream_boot_id is None and not t._streaming + + +def test_short_tag_v2_off_write_is_not_reported_as_a_successful_stop(): + config = {**CFG_V6, "fw_rev": "0.9.13", "tag_ver_max": 2, "boot_id": BOOT_A} + s = FakeSerial(config, stream=tagged_v2_burst(1)) + t = UsbTransport(s) + t.read_config(interval=0.01, drain=0) + t.start() + original_write = s.write + + def short_tag2_off(payload): + if payload == b"STREAM TAG2 OFF\n": + return 0 + return original_write(payload) + + s.write = short_tag2_off + with pytest.raises(DisconnectedError, match="no longer reachable"): + t.stop() + assert t._streaming is True diff --git a/tests/test_vectors.py b/tests/test_vectors.py index 168af08..7b194de 100644 --- a/tests/test_vectors.py +++ b/tests/test_vectors.py @@ -33,6 +33,7 @@ def jsonable(obj): return { k: jsonable(v) for k, v in asdict(obj).items() if k != "host_received_ns" # transport metadata, not part of a wire vector + and not (k == "device_time_us" and v is None) # absent from TAG v1 } if isinstance(obj, (list, tuple)): return [jsonable(v) for v in obj] diff --git a/tests/test_wire.py b/tests/test_wire.py index 742309f..9082530 100644 --- a/tests/test_wire.py +++ b/tests/test_wire.py @@ -8,6 +8,7 @@ from __future__ import annotations import struct +import zlib import pytest @@ -21,6 +22,16 @@ def tag(ptype: int, seq: int, t_us: int, payload: bytes) -> bytes: return w.TAG_MAGIC + bytes([ptype]) + struct.pack(" bytes: + body = ( + w.TAG_V2_MAGIC + + bytes([ptype]) + + struct.pack(" bytes: flags = w.BLE_FLAG_PACKED6 | (w.BLE_FLAG_PACKET_MAG if mag else 0) p = bytearray(bytes([len(samples), flags]) + struct.pack("= 1 + + +def test_tag_v2_crc_trailer_is_buffered_until_all_four_bytes_arrive(): + frame = tag2(w.TAG_MAG, 1, (3 << 32) + 2, b"\x00" * 6) + packets, remainder = w.iter_tagged_v2(frame[:-1]) + assert packets == [] and remainder == frame[:-1] + packets, remainder = w.iter_tagged_v2(remainder + frame[-1:]) + assert len(packets) == 1 and remainder == b"" + + +def test_bad_tag_v2_header_is_counted_and_resynchronises_to_the_next_v2_frame(): + bad = w.TAG_V2_MAGIC + bytes([w.TAG_TACTILE]) + struct.pack("