From 80b17c0975f671061b1978dcef630651b4e34405 Mon Sep 17 00:00:00 2001 From: Beomsoo Son Date: Sun, 9 Aug 2026 20:52:15 +0900 Subject: [PATCH 1/2] release: prepare OGLO Python SDK 0.1.0rc3 --- CHANGELOG.md | 21 +++- CONTRIBUTING.md | 6 +- README.md | 18 +-- SECURITY.md | 6 +- docs/01_quickstart.md | 13 ++- docs/02_data_reference.md | 19 ++-- docs/03_calibration.md | 6 +- docs/04_recording.md | 4 +- docs/05_troubleshooting.md | 4 +- docs/06_compatibility.md | 22 ++-- docs/07_acceptance.md | 6 +- pyproject.toml | 2 +- spec/vectors/README.md | 21 ++-- spec/vectors/tag_imu_25b.bin | Bin 25 -> 25 bytes spec/vectors/tag_imu_25b.expected.json | 32 +++--- spec/vectors/tag_mag_19b.bin | Bin 19 -> 19 bytes spec/vectors/tag_mag_19b.expected.json | 24 ++-- spec/vectors/tag_tactile_133b.bin | Bin 133 -> 133 bytes spec/vectors/tag_tactile_133b.expected.json | 20 ++-- spec/vectors/usb_frame_v6_191b.bin | Bin 191 -> 0 bytes spec/vectors/usb_frame_v6_191b.expected.json | 114 ------------------- src/oglo/__init__.py | 4 +- src/oglo/_ble.py | 8 +- src/oglo/_config.py | 10 +- src/oglo/_device.py | 6 +- src/oglo/_record.py | 2 +- src/oglo/_usb.py | 13 +-- src/oglo/_wire.py | 10 +- src/oglo/acceptance.py | 6 +- tests/fake_serial.py | 2 +- tests/test_acceptance.py | 13 ++- tests/test_capture_vectors.py | 26 ++++- tests/test_config.py | 26 ++--- tests/test_device.py | 2 +- tests/test_doctor.py | 2 +- tests/test_documentation.py | 46 ++++++++ tests/test_hardware.py | 2 +- tests/test_replay_corruption.py | 6 +- tests/test_vectors.py | 5 +- tools/capture_vectors.py | 34 ++++-- uv.lock | 2 +- 41 files changed, 277 insertions(+), 286 deletions(-) delete mode 100644 spec/vectors/usb_frame_v6_191b.bin delete mode 100644 spec/vectors/usb_frame_v6_191b.expected.json create mode 100644 tests/test_documentation.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 008bfe2..7341bd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,24 @@ All notable user-facing changes are recorded here. Versions follow ## [Unreleased] +## [0.1.0rc3] - 2026-08-09 + +### Changed + +- raised the single firmware floor to 0.9.10 for live connections, replay, hardware + vector capture, and acceptance while retaining schema 6 and accepting current + firmware 0.9.11 +- replaced the checked-in physical tagged-stream vectors with a redacted firmware + 0.9.10 capture and removed the unsupported legacy interleaved BIN capture +- synchronized README, quickstart, data, calibration, recording, troubleshooting, + compatibility, acceptance, contribution, and security documentation with the + current 0.9.10+ contract and 0.9.11 golden firmware + +### Fixed + +- made acceptance compare firmware numerically against a minimum so 0.9.11 does + not fail an exact-0.9.10 check + ## [0.1.0rc2] - 2026-08-09 ### Added @@ -55,6 +73,7 @@ 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.0rc2...HEAD +[Unreleased]: https://github.com/OpenGraphLabs/oglo-python/compare/v0.1.0rc3...HEAD +[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/CONTRIBUTING.md b/CONTRIBUTING.md index c58cef4..442ee78 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -32,9 +32,9 @@ must not mutate attached devices. Hardware-specific changes should include the firmware revision, schema, host OS, test duration, and before/after loss counters. Do not present an automated test as -physical validation unless a physical glove was actually exercised. New live-device -qualification targets firmware 0.9.10/schema 6. Historical 0.9.9 captures may remain -as decoder fixtures only and must be labelled as such. +physical validation unless a physical glove was actually exercised. Live-device +qualification requires firmware 0.9.10 or newer with schema 6. Checked-in wire +vectors must come from that same supported contract and redact real device serials. ## Hardware tests diff --git a/README.md b/README.md index b5435c7..a7446c7 100644 --- a/README.md +++ b/README.md @@ -2,17 +2,17 @@ [![CI](https://github.com/OpenGraphLabs/oglo-python/actions/workflows/ci.yml/badge.svg)](https://github.com/OpenGraphLabs/oglo-python/actions/workflows/ci.yml) [![Python 3.10+](https://img.shields.io/badge/python-3.10%2B-3776AB.svg)](https://www.python.org/) -[![Firmware 0.9.10](https://img.shields.io/badge/firmware-0.9.10-5C2D91.svg)](docs/06_compatibility.md) +[![Firmware 0.9.10+](https://img.shields.io/badge/firmware-0.9.10%2B-5C2D91.svg)](docs/06_compatibility.md) [![License: Apache-2.0](https://img.shields.io/badge/license-Apache--2.0-blue.svg)](LICENSE) 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.0rc2` is a USB-first research SDK for the supported -> live-glove baseline, firmware 0.9.10/schema 6. The decoder retains historical -> 0.9.9/schema-6 vector compatibility, but 0.9.9 is not a deployment target. BLE -> is available as an experimental transport and is not release-qualified. +> **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. This public repository is the sole canonical source for the SDK. Development, issues, pull requests, tags, and releases all belong under @@ -34,14 +34,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.0rc2-py3-none-any.whl +python3 -m pip install ./oglo-0.1.0rc3-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.0rc2" + "oglo @ git+https://github.com/OpenGraphLabs/oglo-python.git@v0.1.0rc3" ``` Python 3.10 or newer is required. @@ -56,8 +56,8 @@ 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 does not report firmware 0.9.10 and -schema 6. +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. ## Read one glove diff --git a/SECURITY.md b/SECURITY.md index da2c22c..fa4f7dc 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -4,11 +4,11 @@ | Version | Security updates | | --- | --- | -| 0.1.x with firmware 0.9.10/schema 6 | Yes | +| 0.1.x with firmware 0.9.10+/schema 6 | Yes | | development snapshots and older versions | No | -Historical 0.9.9 wire captures are retained for decoder regression tests; they do -not make a live 0.9.9 glove a supported deployment. +Firmware older than 0.9.10 is outside the supported live, replay, and vector-capture +contract. ## Reporting a vulnerability diff --git a/docs/01_quickstart.md b/docs/01_quickstart.md index 3f0d03c..1c9416f 100644 --- a/docs/01_quickstart.md +++ b/docs/01_quickstart.md @@ -6,19 +6,20 @@ Download the wheel from the matching [GitHub Release](https://github.com/OpenGraphLabs/oglo-python/releases), then: ```bash -python3 -m pip install ./oglo-0.1.0rc2-py3-none-any.whl +python3 -m pip install ./oglo-0.1.0rc3-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.0rc2" + "oglo @ git+https://github.com/OpenGraphLabs/oglo-python.git@v0.1.0rc3" ``` -Python 3.10 or newer is required. Supported live gloves run firmware 0.9.10 with -schema 6. `0.1.0rc2` can still parse historical 0.9.9/schema-6 captures, but that -parser tolerance is not deployment support; upgrade a live 0.9.9 glove before use. +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. ## Diagnose before collecting data @@ -71,7 +72,7 @@ Wear the glove, open and close the hand through its full motion range for the fi seconds, and touch nothing. Bending a finger presses the sensor by itself, so a still-hand baseline creates false contacts during later motion. -The SDK verifies the active recipe immediately after capture. Firmware 0.9.10 does +The SDK verifies the active recipe immediately after capture. Supported firmware does not expose enough information to prove that the flash write survived a power cycle; reboot and read it back when persistence is a release or factory gate. See [Calibration](03_calibration.md) before changing an externally supplied glove. diff --git a/docs/02_data_reference.md b/docs/02_data_reference.md index 50e84fc..0054d79 100644 --- a/docs/02_data_reference.md +++ b/docs/02_data_reference.md @@ -21,10 +21,10 @@ 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 live contract is firmware 0.9.10/schema 6. `0.1.0rc2` retains parser -tolerance for historical 0.9.9/schema-6 vectors and recordings, but live collection -must use 0.9.10. Other schemas and older firmware fail closed instead of inviting a -best-effort packet guess. +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. ## Identity and side @@ -37,7 +37,7 @@ specific `port=` or BLE address. one right glove, and distinct logical serials. `g.info.has_mag` means firmware successfully initialised the magnetometer at boot. -Firmware 0.9.10 cannot distinguish an intentionally absent part from one that failed +Supported firmware cannot distinguish an intentionally absent part from one that failed boot detection, and it has no runtime read-failure/freshness counter. Therefore a clean status snapshot is not proof that every magnetometer value is fresh; applications that require heading-quality data need a firmware freshness flag and a physical field @@ -166,9 +166,10 @@ scheduling still contribute unknown delay. Recordings store both, plus a wall-clock anchor, because each answers a question the others cannot. -## Integrity limit of firmware 0.9.10 +## Integrity limit of supported firmware -The 0.9.10 tagged USB frame has a magic and length but no checksum/CRC. The TinyUSB -whole-frame queue removes the known pre-0.9.9 truncation path, but the SDK cannot -mathematically prove that every plausible payload bit is intact. A future protocol +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. diff --git a/docs/03_calibration.md b/docs/03_calibration.md index 9952e4b..8b76141 100644 --- a/docs/03_calibration.md +++ b/docs/03_calibration.md @@ -67,7 +67,7 @@ capture path, carry it yourself. ## What is intended to survive a power cycle The firmware stores the zero and stream mode in device flash, and -`g.info.zero_valid` reports the active state after connection. Firmware 0.9.10 does +`g.info.zero_valid` reports the active state after connection. Supported firmware does not report the flash-write result or perform a power-cycle readback, so the SDK alone cannot prove persistence. Reboot, reconnect and compare the recipe when that is a release or factory gate. @@ -77,7 +77,7 @@ release or factory gate. `clean()` and tactile-rate changes can be confirmed by re-reading config. Firmware does not expose the applied IMU period in BLE config, so `rates(imu=...)` is also USB-only rather than returning an unverified success. -`zero()` is deliberately USB-only: firmware 0.9.10 sends the start/completion lines +`zero()` is deliberately USB-only: supported firmware sends the start/completion lines and full `GET ZERO` recipe only over serial. Without those, BLE can send the command but cannot prove capture or persistence completed, so the SDK fails immediately instead of waiting and then pretending success. @@ -86,7 +86,7 @@ Over USB, `zero()` requires the start acknowledgement, validates all 80 baseline noise values, re-reads them with `GET ZERO`, and finally verifies `zero_valid` in config before returning. -That proves the active firmware recipe is consistent. Firmware 0.9.10 does not report +That proves the active firmware recipe is consistent. Supported firmware does not report the NVS write result or re-read flash before replying, so the SDK cannot prove power- cycle persistence without an actual reboot/reconnect check. Do that as a release or factory gate; do not interpret a successful call as an atomic-flash guarantee. diff --git a/docs/04_recording.md b/docs/04_recording.md index c861654..d9206ee 100644 --- a/docs/04_recording.md +++ b/docs/04_recording.md @@ -104,7 +104,7 @@ 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. Firmware 0.9.10 has no end-to-end CRC 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; 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 @@ -118,7 +118,7 @@ for i in range(12): ``` The repository does not contain a raw current long-soak report, so unit tests are not -presented as proof of a 0.9.10 multi-hour hardware capture. Release qualification must +presented as proof of a multi-hour hardware capture. Release qualification must include two hands for more than 72 minutes to cross the device-clock rollover, plus a slow-storage stress run. diff --git a/docs/05_troubleshooting.md b/docs/05_troubleshooting.md index 350b4e1..1d2f43c 100644 --- a/docs/05_troubleshooting.md +++ b/docs/05_troubleshooting.md @@ -36,7 +36,7 @@ In order of likelihood: 3. Something else already holds the port. `doctor` lists non-glove serial devices it saw and skipped, which is often the clue. -Supported firmware 0.9.10 appears to the OS as `OGLO` from `OpenGraphLabs`. A glove +Supported firmware 0.9.10 or newer appears to the OS as `OGLO` from `OpenGraphLabs`. A glove that still appears as `XIAO_ESP32S3` from `Espressif Systems` is running an older build and must be upgraded. Discovery still proves identity with `GET CONFIG`; a different XIAO using the same VID can briefly appear as a candidate, but it is @@ -55,7 +55,7 @@ network device and serves several subscribers at once. ## The board answers nothing at all If you are writing your own serial code rather than using this SDK: **assert DTR.** -Firmware 0.9.10 uses TinyUSB, which will not transmit until the host raises DTR. +Supported firmware uses TinyUSB, which will not transmit until the host raises DTR. With DTR low the board returns literally zero bytes and looks dead. It is not. Keep RTS low. The two together are what a USB-UART bridge decodes as a reset request. diff --git a/docs/06_compatibility.md b/docs/06_compatibility.md index 585ece3..d3810b2 100644 --- a/docs/06_compatibility.md +++ b/docs/06_compatibility.md @@ -5,26 +5,24 @@ physical gloves. They are different claims. ## Supported contract -| Component | Status in 0.1.0rc2 | +| Component | Status in 0.1.0rc3 | | --- | --- | | Python | 3.10 or newer | -| Live-glove firmware | 0.9.10 | +| Minimum supported firmware | 0.9.10 | +| Current golden firmware for new flashes | 0.9.11 | | CONFIG schema | exactly 6 | | USB tagged stream | supported and hardware-validated | -| USB legacy interleaved frame | decoder and captured-vector compatibility | | BLE schema-6 notifications | experimental; parser-tested, not release-qualified | -| Firmware 0.9.9 | historical parser/vector compatibility only; upgrade before live use | -| Firmware 0.9.8 and older | rejected | +| Firmware older than 0.9.10 | rejected for connect, replay, and vector capture | -The `0.1.0rc2` parser floor remains 0.9.9 so historical schema-6 golden vectors and -recordings stay readable. That tolerance is not a live-device support claim. The -deployed fleet and physical release qualification use 0.9.10/schema 6; unknown -schemas and firmware older than 0.9.9 fail closed rather than selecting a -best-effort decoder. +`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. ## Physical validation for this release candidate -The release candidate was exercised on one left and one right glove running +The release candidate 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 @@ -54,7 +52,7 @@ 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. -- Firmware 0.9.10 USB frames do not include an end-to-end payload CRC. +- 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. diff --git a/docs/07_acceptance.md b/docs/07_acceptance.md index caf7eaa..02d2e5a 100644 --- a/docs/07_acceptance.md +++ b/docs/07_acceptance.md @@ -16,7 +16,7 @@ The default run does **not** change zero, threshold, RAW/CLEAN mode, or stream r It checks: - one left and one right glove with distinct logical serials -- firmware 0.9.10, CONFIG schema 6, USB transport, dimensions, and finger order +- firmware 0.9.10 or newer, CONFIG schema 6, USB transport, dimensions, and finger order - sensor health and existing zero state - public tactile, IMU, magnetometer, `stop()`, `start()`, and `read_batch()` paths - simultaneous two-hand rate, timestamps, sequence gaps, malformed data, and overflow @@ -93,7 +93,7 @@ For each glove the runner prints the serial and requires typing `ZERO `. that glove, touch nothing, and repeatedly open and close the hand during the sweep. The SDK validates the completion recipe, all 80 baseline/noise entries, `GET ZERO`, -and CONFIG `zero_valid`. Firmware 0.9.10 cannot prove that flash survived a power +and CONFIG `zero_valid`. Supported firmware cannot prove that flash survived a power cycle, so the report leaves that gate as `SKIP`. Unplug/replug the glove and run the safe default again to provide separate read-back evidence. @@ -124,7 +124,7 @@ It does not claim: - hardware synchronisation between the two gloves - Newton/force calibration - fused orientation or validated magnetometer axes -- payload integrity beyond what firmware 0.9.10 exposes +- payload integrity beyond what supported firmware exposes - power-cycle zero persistence unless that physical cycle was separately performed Run the default acceptance check before an important capture and attach its JSON diff --git a/pyproject.toml b/pyproject.toml index 1b50078..f5541a8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "oglo" -version = "0.1.0rc2" +version = "0.1.0rc3" description = "Python SDK for the OGLO five-finger tactile glove" readme = "README.md" requires-python = ">=3.10" diff --git a/spec/vectors/README.md b/spec/vectors/README.md index ca8abc6..7f7f636 100644 --- a/spec/vectors/README.md +++ b/spec/vectors/README.md @@ -5,21 +5,16 @@ Each `.bin` is one whole packet captured from a physical glove. Its paired reference decoder in `tools/capture_vectors.py`. The SDK decoder does not generate its own expected answers. -The currently checked-in packets were captured from physical glove -`OGLO-R-TEST04` running firmware 0.9.9. The tactile capture is an all-zero clean -frame, so it proves the real packet length/framing but does not independently excite -every packed12 nibble. `tests/test_capture_vectors.py` therefore also pins a literal -non-zero `0x123, 0xabc` pair through the independent reference decoder. - -Firmware 0.9.9 is not a supported live-glove target. These immutable captures remain -only because they are physical provenance for the schema-6 decoder; supported live -devices run firmware 0.9.10/schema 6. +The currently checked-in packets were captured on 2026-08-09 from a physical left +glove running firmware 0.9.10/schema 6. The public metadata redacts its logical +serial as `OGLO-L-GOLDEN`; raw packet bytes and decoded values are unchanged. The +tactile frame includes non-zero counts, and `tests/test_capture_vectors.py` also +pins a literal non-zero `0x123, 0xabc` pair through the independent reference +decoder so every packed12 nibble order is testable without relying on one pose. The capture tool requires tactile and IMU packets, plus magnetometer packets when the board reports `has_mag=true`. It writes nothing on an incomplete capture and removes obsolete `tag_*_b` variants only after the replacement set is ready. -`usb_frame_v6_191b.*` is a physical 0.9.9 capture of the legacy interleaved BIN -stream retained as provenance for the browser viewer and hardware repository. The -SDK runtime intentionally does not decode BIN, and `tests/test_vectors.py` therefore -does not treat that file as a supported decoder contract. +Only the supported tagged USB contract is kept here. Legacy interleaved BIN captures +and decoders are not part of this SDK. diff --git a/spec/vectors/tag_imu_25b.bin b/spec/vectors/tag_imu_25b.bin index 795261b4bfa8f6b4cfeecd6c3db40f1104febf15..9acd58ae08d3f217c480b2ac3e34805da58d0cbe 100644 GIT binary patch literal 25 hcmZ1)#l*vKkB@<&jf3B->}SOvTfTq)|NUoY003*P3J?GQ literal 25 fcmZ1)#l*t^0{_B}F|d7@_x~8fzyII=GcW)EYfTDt diff --git a/spec/vectors/tag_imu_25b.expected.json b/spec/vectors/tag_imu_25b.expected.json index e6e6926..351aa21 100644 --- a/spec/vectors/tag_imu_25b.expected.json +++ b/spec/vectors/tag_imu_25b.expected.json @@ -1,32 +1,34 @@ { "meta": { - "serial": "OGLO-R-TEST04", + "serial": "OGLO-L-GOLDEN", + "side": "left", "hw_rev": "RDR02_FLEX5_REV_D_TIA", - "fw_rev": "0.9.9", + "fw_rev": "0.9.10", "rate_hz": 250, "imu_len": 25, - "has_mag": true + "has_mag": true, + "serial_redacted": true }, "decoded": { - "seq": 0, - "t_us": 12998398, + "seq": 3806, + "t_us": 1242499206, "accel": [ - -0.998535156, - -0.023925781, - 0.048339844 + -0.408691406, + -0.220703125, + 0.889892578 ], "gyro": [ -0.12195122, - -0.548780488, - 0.0 + -0.12195122, + 0.426829268 ], "raw": [ - -4090, - -98, - 198, + -1674, + -904, + 3645, + -2, -2, - -9, - 0 + 7 ] } } diff --git a/spec/vectors/tag_mag_19b.bin b/spec/vectors/tag_mag_19b.bin index 0e425978ebbb796fdc337bc2d37e97c39ca8da51..8db17ef3f67941ce2feeef1757a3c16a01a06f27 100644 GIT binary patch literal 19 bcmZ1)#mvUAotc4QmpH%I{$H=(yio%HIlTvu literal 19 YcmZ1)#mvS40{_B}F=)Jf|3Oa?05bIkW&i*H diff --git a/spec/vectors/tag_mag_19b.expected.json b/spec/vectors/tag_mag_19b.expected.json index 9a220c3..26bf490 100644 --- a/spec/vectors/tag_mag_19b.expected.json +++ b/spec/vectors/tag_mag_19b.expected.json @@ -1,24 +1,26 @@ { "meta": { - "serial": "OGLO-R-TEST04", + "serial": "OGLO-L-GOLDEN", + "side": "left", "hw_rev": "RDR02_FLEX5_REV_D_TIA", - "fw_rev": "0.9.9", + "fw_rev": "0.9.10", "rate_hz": 250, "imu_len": 25, - "has_mag": true + "has_mag": true, + "serial_redacted": true }, "decoded": { - "seq": 0, - "t_us": 12998398, + "seq": 951, + "t_us": 1242503098, "field": [ - -0.705057001, - -0.563724057, - 1.241449868 + -0.196579947, + -0.713972523, + 1.456299328 ], "raw": [ - -4824, - -3857, - 8494 + -1345, + -4885, + 9964 ] } } diff --git a/spec/vectors/tag_tactile_133b.bin b/spec/vectors/tag_tactile_133b.bin index e1d41bc34858c360b2f3871743dad60cc942d3b1..5f05a6c58d8a2e6d64e596d34aa3e6d94bc85d83 100644 GIT binary patch literal 133 mcmZ1)#aO|R&(6T`la=3#0UwCKCkdA0Kv6+hJy>}HkOTnJoCIC~ literal 133 UcmZ1)#aO`r0*L{~!1O>60Cn&J@Bjb+ diff --git a/spec/vectors/tag_tactile_133b.expected.json b/spec/vectors/tag_tactile_133b.expected.json index 6fe3df8..109fb01 100644 --- a/spec/vectors/tag_tactile_133b.expected.json +++ b/spec/vectors/tag_tactile_133b.expected.json @@ -1,15 +1,17 @@ { "meta": { - "serial": "OGLO-R-TEST04", + "serial": "OGLO-L-GOLDEN", + "side": "left", "hw_rev": "RDR02_FLEX5_REV_D_TIA", - "fw_rev": "0.9.9", + "fw_rev": "0.9.10", "rate_hz": 250, "imu_len": 25, - "has_mag": true + "has_mag": true, + "serial_redacted": true }, "decoded": { - "seq": 0, - "t_us": 12996705, + "seq": 1903, + "t_us": 1242498553, "counts": [ 0, 0, @@ -30,6 +32,7 @@ 0, 0, 0, + 88, 0, 0, 0, @@ -52,6 +55,7 @@ 0, 0, 0, + 128, 0, 0, 0, @@ -60,6 +64,7 @@ 0, 0, 0, + 88, 0, 0, 0, @@ -85,10 +90,7 @@ 0, 0, 0, - 0, - 0, - 0, - 0, + 96, 0, 0 ] diff --git a/spec/vectors/usb_frame_v6_191b.bin b/spec/vectors/usb_frame_v6_191b.bin deleted file mode 100644 index e15241be29bec61024698fe17014a5cb8c74d863..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 191 qcmZ1#y7AgehQSWLePvYQ|FHD`UWR}FzyD`oVAOca_|Zi12rB?SC=d4l diff --git a/spec/vectors/usb_frame_v6_191b.expected.json b/spec/vectors/usb_frame_v6_191b.expected.json deleted file mode 100644 index 53e25cf..0000000 --- a/spec/vectors/usb_frame_v6_191b.expected.json +++ /dev/null @@ -1,114 +0,0 @@ -{ - "meta": { - "serial": "OGLO-R-TEST04", - "hw_rev": "RDR02_FLEX5_REV_D_TIA", - "fw_rev": "0.9.9", - "rate_hz": 250, - "imu_len": 25, - "has_mag": true - }, - "decoded": { - "t_us": 15324849, - "counts": [ - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0, - 0 - ], - "roll_deg": -25.7, - "pitch_deg": 87.05, - "accel": [ - -0.996337891, - -0.022216797, - 0.046142578 - ], - "gyro": [ - -0.12195122, - -0.548780488, - 0.0 - ], - "imu_ok": true, - "mag": [ - -0.705057001, - -0.561093248, - 1.242326805 - ], - "imu_dt_us": 1476 - } -} diff --git a/src/oglo/__init__.py b/src/oglo/__init__.py index 357cc4a..1360575 100644 --- a/src/oglo/__init__.py +++ b/src/oglo/__init__.py @@ -14,7 +14,7 @@ Two hands: `left, right = oglo.connect_pair()`. Use the host receive boundary only to relate them approximately; never align two gloves on `t_us` or `device_time_us`. -Their device-clock origins and drift are unrelated, and firmware 0.9.9 provides no +Their device-clock origins and drift are unrelated, and supported firmware provides no hardware time-synchronisation contract. """ @@ -34,7 +34,7 @@ find_port, list_candidates, open_serial) from ._usb import UsbTransport as _UsbTransport -__version__ = "0.1.0rc2" +__version__ = "0.1.0rc3" __all__ = [ "connect", diff --git a/src/oglo/_ble.py b/src/oglo/_ble.py index a9685d7..015805e 100644 --- a/src/oglo/_ble.py +++ b/src/oglo/_ble.py @@ -245,7 +245,7 @@ def _on_notify(self, _sender: Any, payload: bytearray) -> None: with self._lock: self.malformed += 1 return - # In firmware 0.9.9 the CONFIG has_mag value and every notify's packet-mag + # In supported firmware the CONFIG has_mag value and every notify's packet-mag # flag come from the same boot-time state. A mismatch is not an optional # per-sample omission: it is a corrupt/incompatible notify. Accepting it # silently produced tactile+IMU while losing the entire mag batch without a @@ -258,7 +258,7 @@ def _on_notify(self, _sender: Any, payload: bytearray) -> None: return with self._lock: for s in samples: - # Firmware 0.9.9 saturates the signed IMU age at the int16 limits. + # Supported firmware saturates the signed IMU age at the int16 limits. # At that point tactile is still fresh but the embedded IMU is not; # keeping the row is useful for a partial episode, while this # counter prevents Recorder from calling the capture complete. @@ -285,7 +285,7 @@ def _on_notify(self, _sender: Any, payload: bytearray) -> None: self._samples.append(replace(s, host_received_ns=received_ns)) def start(self, *, reset_counters: bool = True) -> str: - """Subscribe to the firmware-0.9.9 schema-6 notification.""" + """Subscribe to the supported firmware-0.9.10+ schema-6 notification.""" if self._disconnected: raise DisconnectedError("the BLE glove disconnected; reconnect before starting again") # Reset callback-owned state before enabling notifications. Doing part of @@ -395,7 +395,7 @@ def connect_ble(serial: Optional[str] = None, *, address: Optional[str] = None, matches = [] seen = [] failures = [] - # Advertisements only say OGLO LEFT/RIGHT in firmware 0.9.9. Treating that name + # Advertisements only say OGLO LEFT/RIGHT. Treating that name # as a serial selector silently picked the wrong device, so inspect CONFIG and # verify identity before returning anything. try: diff --git a/src/oglo/_config.py b/src/oglo/_config.py index e92bb12..0157299 100644 --- a/src/oglo/_config.py +++ b/src/oglo/_config.py @@ -1,6 +1,6 @@ """Validate the single supported OGLO contract and expose its runtime state. -This SDK intentionally starts at firmware 0.9.9. Older schemas are rejected at +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. The config is read from `GET CONFIG` over serial or from the config characteristic @@ -14,7 +14,7 @@ from ._wire import NUM_COLS, NUM_FINGERS, ROWS_PER_FINGER, TAXELS -MIN_FIRMWARE = (0, 9, 9) +MIN_FIRMWARE = (0, 9, 10) REQUIRED_SCHEMA = 6 REQUIRED_IMU_LEN = 25 @@ -44,7 +44,7 @@ class Info: stream_thr: int #: Applied IMU period when this SDK session set and exactly acknowledged it. - #: Firmware 0.9.9 does not expose a read-only value in CONFIG, so otherwise None. + #: Supported firmware does not expose a read-only value in CONFIG, so otherwise None. imu_period_ms: Optional[int] = None #: Frames the device itself discarded. Distinct from host-side loss and never @@ -72,7 +72,7 @@ class Capabilities: def parse_config(cfg: Dict[str, Any], *, transport: str = "usb") -> Tuple[Info, Capabilities]: """Turn a config dict into `(Info, Capabilities)`. - Firmware older than 0.9.9 and anything other than schema 6 are unsupported. + Firmware older than 0.9.10 and anything other than schema 6 are unsupported. Required fields do not get fallbacks: a truncated or incompatible config fails before streaming begins. """ @@ -207,7 +207,7 @@ def _config_string(cfg: Dict[str, Any], name: str, *, allow_empty: bool = False) def _fw_at_least(fw_rev: str, floor: Tuple[int, int, int]) -> bool: """Compare a dotted version numerically. - String comparison is wrong here and the failure is quiet: `"0.9.10" < "0.9.9"` + String comparison is wrong here and the failure is quiet: `"0.10.0" < "0.9.0"` lexicographically, so a future build would be judged older than the floor. Non-numeric suffixes (`0.7.3-tzerobtn`) are tolerated by taking the leading digits. """ diff --git a/src/oglo/_device.py b/src/oglo/_device.py index 18b14fc..65c9003 100644 --- a/src/oglo/_device.py +++ b/src/oglo/_device.py @@ -312,7 +312,7 @@ def zero(self, sweep: int = 5, *, timeout: float = 40.0) -> Dict[str, Any]: timeout = _validated_seconds(timeout, "timeout", allow_none=False, allow_zero=False) if not getattr(self._t, "replies_in_text", True): raise DeviceError( - "sweep zero requires USB in SDK 1.0: firmware 0.9.9 sends its " + "sweep zero requires USB in SDK 1.0: supported firmware sends its " "completion recipe only on the USB text channel, so BLE cannot prove " "that capture and persistence finished" ) @@ -435,7 +435,7 @@ def rates(self, *, tactile: Optional[int] = None, imu: Optional[int] = None, self._info = replace(self._info, imu_period_ms=period_ms) except Exception: if period_ms is not None: - # The command may have partially applied, but 0.9.9 has no read-only + # The command may have partially applied, but supported firmware has no read-only # IMU-period field. Unknown is safer than stale metadata. self._info = replace(self._info, imu_period_ms=None) raise @@ -629,7 +629,7 @@ def __exit__(self, *exc) -> None: def _parse_zero_recipe(line: str) -> Dict[str, Any]: - """Parse and validate the complete 80-taxel recipe printed by firmware 0.9.9.""" + """Parse and validate the complete 80-taxel recipe printed by supported firmware.""" prefix = "#TZERO " if not line.startswith(prefix): raise DeviceError(f"malformed zero reply: {line[:80]!r}") diff --git a/src/oglo/_record.py b/src/oglo/_record.py index 2bbffc6..d2621f0 100644 --- a/src/oglo/_record.py +++ b/src/oglo/_record.py @@ -745,7 +745,7 @@ def _status_issues(status: Any, *, has_mag: bool) -> List[str]: def _modality_freshness_issues(recorder: Recorder) -> List[str]: """Detect a modality that stopped while the other streams kept arriving. - Firmware 0.9.9 does not expose sensor read-failure counters. A final STATUS can + Supported firmware does not expose sensor read-failure counters. A final STATUS can therefore remain healthy after USB IMU packets stop. This host-side guard is deliberately lenient (at least three expected periods and never below 0.1 s) so scheduler/USB jitter does not reject good captures, while a sustained silent diff --git a/src/oglo/_usb.py b/src/oglo/_usb.py index c74110f..947f4e4 100644 --- a/src/oglo/_usb.py +++ b/src/oglo/_usb.py @@ -23,10 +23,9 @@ from ._config import Capabilities, Info, parse_config from ._status import DeviceStatus, parse_status -#: Firmware 0.9.9 uses TinyUSB on the Seeed XIAO module. Its descriptors are still -#: the core defaults ``XIAO_ESP32S3`` / ``Espressif Systems``; 0.9.10 changes only -#: those strings to ``OGLO`` / ``OpenGraphLabs``. Discovery therefore keys on the -#: VID and proves the actual device with strict GET CONFIG later. +#: Supported firmware 0.9.10+ uses TinyUSB on the Seeed XIAO module with +#: ``OGLO`` / ``OpenGraphLabs`` descriptors. Discovery keys on the stable VID and +#: proves the actual device and firmware contract with strict GET CONFIG later. SEEED_VID = 0x2886 GLOVE_VIDS = frozenset({SEEED_VID}) @@ -102,7 +101,7 @@ def looks_like_glove(self) -> bool: def list_candidates(*, strict: bool = True) -> List[PortCandidate]: """Serial ports that could be a glove. **Opens nothing.** - With `strict` (the default) only the firmware-0.9.9 Seeed VID is returned. With + With `strict` (the default) only the supported OGLO Seeed VID is returned. With `strict=False` anything not on the known-not-a-glove list is returned, which is the escape hatch for a board that enumerates under a VID we have not seen. """ @@ -191,7 +190,7 @@ def _owner_pid(device: str) -> Optional[int]: def open_serial(device: str, baud: int = 115200, *, settle: float = 0.8) -> SerialLike: """Open with DTR asserted and RTS low. - **DTR must be high or firmware 0.9.9 says nothing at all.** TinyUSB gates CDC + **DTR must be high or supported firmware says nothing at all.** TinyUSB gates CDC transmit on the host asserting DTR. Measured on OGLO-R-TEST04: dtr=False returns 0 bytes to `GET CONFIG`, dtr=True returns 561. @@ -361,7 +360,7 @@ def caps(self) -> Capabilities: return self._caps def start(self, *, reset_counters: bool = True) -> str: - """Begin the firmware-0.9.9 tagged stream.""" + """Begin the supported firmware-0.9.10+ tagged stream.""" self._buf = b"" self._last_seq = {k: None for k in self._last_seq} if reset_counters: diff --git a/src/oglo/_wire.py b/src/oglo/_wire.py index f46d7a7..92cbff2 100644 --- a/src/oglo/_wire.py +++ b/src/oglo/_wire.py @@ -6,10 +6,10 @@ 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 firmware source (`oglo_rdr02_tia.ino`, FW 0.9.9) rather than inferred from prose -alone. +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.9+, schema 6. USB is the tagged +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. """ @@ -39,7 +39,7 @@ TAG_HDR_LEN = 13 TAG_TACTILE, TAG_IMU, TAG_MAG = 1, 2, 3 -#: 80 taxels x 12 bits, the only tactile payload supported by firmware 0.9.9+. +#: 80 taxels x 12 bits, the only tactile payload supported by firmware 0.9.10+. TAXEL_PACKED_LEN = (TAXELS * 12 + 7) // 8 # 120 TAG_IMU_LEN = 12 # ax, ay, az, gx, gy, gz @@ -65,7 +65,7 @@ def unpack12(buf: bytes, offset: int = 0, count: int = TAXELS) -> List[int]: """Unpack `count` 12-bit values from three-bytes-per-two-values packing. The firmware writes pairs as ``a>>4 | ((a&0xF)<<4)|(b>>8) | b&0xFF`` - (``packTaxels12`` in the sketch). Firmware 0.9.9 uses this packing for both + (``packTaxels12`` in the sketch). Supported firmware uses this packing for both tagged USB tactile packets and BLE notifications. """ if count % 2: diff --git a/src/oglo/acceptance.py b/src/oglo/acceptance.py index 82d7be6..3f39931 100644 --- a/src/oglo/acceptance.py +++ b/src/oglo/acceptance.py @@ -319,8 +319,8 @@ def _check_pair(report: AcceptanceReport, gloves: Sequence[Any]) -> None: raw = dict(getattr(info, "raw", {}) or {}) fw = _version_tuple(str(info.fw_rev)) report.add( - f"{info.serial}: firmware 0.9.10", - PASS if fw == (0, 9, 10) else FAIL, + f"{info.serial}: firmware 0.9.10 or newer", + PASS if fw is not None and fw >= (0, 9, 10) else FAIL, f"reported {info.fw_rev}", ) report.add( @@ -1130,7 +1130,7 @@ def _markdown_report(data: Mapping[str, Any]) -> str: "", "A PASS proves only the checks and duration listed above. It is not proof of " "hardware synchronisation, Newton calibration, BLE qualification, or payload " - "integrity beyond what firmware 0.9.10 exposes.", + "integrity beyond what supported firmware exposes.", "", ] ) diff --git a/tests/fake_serial.py b/tests/fake_serial.py index f3382e0..6e193e2 100644 --- a/tests/fake_serial.py +++ b/tests/fake_serial.py @@ -17,7 +17,7 @@ CFG_V6 = { "device": "oglo", "schema_ver": 6, "serial": "OGLO-L-TEST01", "side": "left", - "hw_rev": "RDR02_FLEX5_REV_D_TIA", "fw_rev": "0.9.9", "rate_hz": 250, + "hw_rev": "RDR02_FLEX5_REV_D_TIA", "fw_rev": "0.9.10", "rate_hz": 250, "samples_per_packet": 3, "imu_len": 25, "has_mag": True, "values_per_sample": 80, "sample_shape": [5, 4, 4], "channels": ["pinky", "ring", "middle", "index", "thumb"], diff --git a/tests/test_acceptance.py b/tests/test_acceptance.py index f8bb6e6..49c82d2 100644 --- a/tests/test_acceptance.py +++ b/tests/test_acceptance.py @@ -108,7 +108,13 @@ def test_sample_stats_use_observed_host_timestamps_not_requested_rates(): @pytest.mark.parametrize( ("text", "value"), - [("0.9.10", (0, 9, 10)), ("0.9.10-release", (0, 9, 10)), ("0.9", None), ("x", None)], + [ + ("0.9.10", (0, 9, 10)), + ("0.9.11", (0, 9, 11)), + ("0.9.10-release", (0, 9, 10)), + ("0.9", None), + ("x", None), + ], ) def test_live_firmware_comparison_is_numeric_and_exact(text, value): assert _version_tuple(text) == value @@ -133,10 +139,11 @@ def connect_pair(): assert data["result"] == FAIL -def test_pair_contract_accepts_only_current_firmware_schema_and_usb(tmp_path): +@pytest.mark.parametrize("fw_rev", ["0.9.10", "0.9.11"]) +def test_pair_contract_accepts_supported_firmware_schema_and_usb(tmp_path, fw_rev): from oglo.acceptance import _check_pair - cfg = {**CFG_V6, "fw_rev": "0.9.10"} + cfg = {**CFG_V6, "fw_rev": fw_rev} left_info, _ = parse_config(cfg) right_info = replace( left_info, diff --git a/tests/test_capture_vectors.py b/tests/test_capture_vectors.py index 62cfde5..e9a99b9 100644 --- a/tests/test_capture_vectors.py +++ b/tests/test_capture_vectors.py @@ -40,7 +40,7 @@ def test_reference_decoder_is_an_independent_plain_data_oracle(): assert imu["gyro"] == [10.0, 0.0, -10.0] -def test_capture_config_enforces_the_099_schema6_boundary_numerically(): +def test_capture_config_enforces_the_0910_schema6_boundary_numerically(): cfg = { "fw_rev": "0.9.10", "schema_ver": 6, @@ -52,8 +52,8 @@ def test_capture_config_enforces_the_099_schema6_boundary_numerically(): "has_mag": True, } assert capture.validate_capture_config(cfg) is True - with pytest.raises(ValueError, match="older than 0.9.9"): - capture.validate_capture_config({**cfg, "fw_rev": "0.9.8"}) + with pytest.raises(ValueError, match="older than 0.9.10"): + capture.validate_capture_config({**cfg, "fw_rev": "0.9.9"}) def test_capture_fails_before_writing_when_a_required_modality_is_missing(): @@ -64,6 +64,22 @@ def test_capture_fails_before_writing_when_a_required_modality_is_missing(): assert {name.rsplit("_", 1)[0] for name in vectors} == {"tag_tactile", "tag_imu"} +def test_public_capture_metadata_redacts_the_logical_serial_by_default(): + cfg = { + "serial": "OGLO-L-00001", + "side": "left", + "hw_rev": "RDR02_FLEX5_REV_D_TIA", + "fw_rev": "0.9.10", + "rate_hz": 250, + "imu_len": 25, + "has_mag": True, + } + public = capture.capture_metadata(cfg) + assert public["serial"] == "OGLO-L-GOLDEN" + assert public["serial_redacted"] is True + assert capture.capture_metadata(cfg, include_serial=True)["serial"] == "OGLO-L-00001" + + def test_replacing_a_capture_removes_obsolete_length_variants(tmp_path): stale_bin = tmp_path / "tag_tactile_999b.bin" stale_json = tmp_path / "tag_tactile_999b.expected.json" @@ -73,7 +89,7 @@ def test_replacing_a_capture_removes_obsolete_length_variants(tmp_path): unrelated.write_text("keep") vectors = capture.collect_vectors(tactile_packet() + imu_packet(), has_mag=False) - capture.write_vector_set(vectors, {"fw_rev": "0.9.9"}, directory=tmp_path) + capture.write_vector_set(vectors, {"fw_rev": "0.9.10"}, directory=tmp_path) assert not stale_bin.exists() and not stale_json.exists() assert unrelated.read_text() == "keep" @@ -85,7 +101,7 @@ def test_replacing_a_capture_removes_obsolete_length_variants(tmp_path): def test_config_reader_carries_a_json_line_across_arbitrary_serial_reads(monkeypatch): cfg = { "device": "oglo", - "fw_rev": "0.9.9", + "fw_rev": "0.9.10", "schema_ver": 6, } encoded = b"#CONFIG " + json.dumps(cfg).encode() + b"\r\n" diff --git a/tests/test_config.py b/tests/test_config.py index 72cbf12..92af5d8 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -1,4 +1,4 @@ -"""Config parsing for the single firmware-0.9.9+/schema-6 contract.""" +"""Config parsing for the single firmware-0.9.10+/schema-6 contract.""" from __future__ import annotations @@ -16,10 +16,10 @@ taxel_index, ) -# Schema shape used by firmware 0.9.9 and newer. +# Schema shape used by firmware 0.9.10 and newer. CFG_V6 = json.loads( '{"device":"oglo","schema_ver":6,"serial":"OGLO-L-TEST01","side":"left",' - '"hw_rev":"RDR02_FLEX5_REV_D_TIA","fw_rev":"0.9.9","rate_hz":250,' + '"hw_rev":"RDR02_FLEX5_REV_D_TIA","fw_rev":"0.9.10","rate_hz":250,' '"samples_per_packet":3,"imu_len":25,"has_mag":true,"values_per_sample":80,' '"sample_shape":[5,4,4],"channels":["pinky","ring","middle","index","thumb"],' '"device_id":"oglo-test-device-01","batch":"","factory_passed":true,' @@ -61,7 +61,7 @@ def test_an_unknown_finger_name_says_what_the_board_actually_has(): [ ({}, "did not answer"), ({"device": "notoglo"}, "not an OGLO"), - ({**CFG_V6, "fw_rev": "0.9.8"}, "requires firmware 0.9.9"), + ({**CFG_V6, "fw_rev": "0.9.9"}, "requires firmware 0.9.10"), ({**CFG_V6, "schema_ver": 5}, "requires schema 6"), ({**CFG_V6, "values_per_sample": 40}, "values_per_sample"), ({**CFG_V6, "sample_shape": [80]}, "sample_shape"), @@ -99,12 +99,12 @@ def test_device_side_tag_drop_counter_is_not_silently_left_at_zero(): @pytest.mark.parametrize( "fw,floor,ok", [ - ("0.9.9", (0, 9, 9), True), - ("0.9.10", (0, 9, 9), True), - ("0.9.8", (0, 9, 9), False), - ("1.0.0", (0, 9, 9), True), - ("", (0, 9, 9), False), - ("0.9", (0, 9, 9), False), + ("0.9.10", (0, 9, 10), True), + ("0.9.11", (0, 9, 10), True), + ("0.9.9", (0, 9, 10), False), + ("1.0.0", (0, 9, 10), True), + ("", (0, 9, 10), False), + ("0.9", (0, 9, 10), False), ], ) def test_version_floor_is_numeric_not_lexicographic(fw, floor, ok): @@ -112,9 +112,9 @@ def test_version_floor_is_numeric_not_lexicographic(fw, floor, ok): def test_the_lexicographic_trap_specifically(): - """`"0.9.10" < "0.9.9"` as strings. A future build must not read as older.""" - assert "0.9.10" < "0.9.9" # the trap - assert _fw_at_least("0.9.10", (0, 9, 9)) is True # not fallen into + """`"0.10.0" < "0.9.0"` as strings. Numeric comparison must not fall into it.""" + assert "0.10.0" < "0.9.0" # the trap + assert _fw_at_least("0.10.0", (0, 9, 10)) is True # not fallen into # --- taxel addressing ---------------------------------------------------------- diff --git a/tests/test_device.py b/tests/test_device.py index 93041b3..4575948 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -360,7 +360,7 @@ def test_every_command_path_fails_deterministically_after_close(operation): def test_repr_says_which_glove_this_is(): g, _ = make() r = repr(g) - assert "OGLO-L-TEST01" in r and "left" in r and "0.9.9" in r + assert "OGLO-L-TEST01" in r and "left" in r and "0.9.10" in r def test_read_batch_is_the_public_non_resampling_fanout_api(): diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 9184510..6e315e1 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -231,7 +231,7 @@ def __init__(self, port): side = "left" if port == "/dev/a" else "right" self.info = SimpleNamespace( raw={"serial": f"OGLO-{side}"}, serial=f"OGLO-{side}", side=side, - transport="usb", hw_rev="D", fw_rev="0.9.9", rate_hz=250, + transport="usb", hw_rev="D", fw_rev="0.9.10", rate_hz=250, channels=[], has_mag=True, zero_valid=True, stream_clean=True, stream_thr=30, ) diff --git a/tests/test_documentation.py b/tests/test_documentation.py new file mode 100644 index 0000000..24dc51d --- /dev/null +++ b/tests/test_documentation.py @@ -0,0 +1,46 @@ +"""Keep current user documentation aligned with the package and firmware contract.""" + +from __future__ import annotations + +import re +from pathlib import Path + +import oglo +from oglo._config import MIN_FIRMWARE + + +ROOT = Path(__file__).resolve().parent.parent +CURRENT_DOCS = ( + ROOT / "README.md", + ROOT / "CONTRIBUTING.md", + ROOT / "SECURITY.md", + *(ROOT / "docs").glob("*.md"), +) + + +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 MIN_FIRMWARE == (0, 9, 10) + assert "0.1.0rc2" 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 + + +def test_current_markdown_relative_links_resolve(): + pattern = re.compile(r"\[[^]]*\]\(([^)]+)\)") + missing = [] + for document in CURRENT_DOCS: + for target in pattern.findall(document.read_text()): + target = target.strip().split("#", 1)[0] + if not target or "://" in target or target.startswith(("mailto:", "#")): + continue + resolved = (document.parent / target).resolve() + if not resolved.exists(): + missing.append(f"{document.relative_to(ROOT)} -> {target}") + assert not missing, "broken relative Markdown links:\n" + "\n".join(missing) diff --git a/tests/test_hardware.py b/tests/test_hardware.py index 34647c0..45ff2ff 100644 --- a/tests/test_hardware.py +++ b/tests/test_hardware.py @@ -147,7 +147,7 @@ def test_usb_discovery_identity_health_and_zero_readback(attached_pair): with oglo.connect(port=item.port.device) as glove: info = glove.info assert (info.serial, info.side, info.transport) == (item.serial, item.side, "usb") - assert _fw_at_least(info.fw_rev, (0, 9, 9)) + assert _fw_at_least(info.fw_rev, (0, 9, 10)) assert info.hw_rev and info.zero_valid and info.stream_clean assert info.rate_hz == 250 and info.has_mag assert info.channels == ( diff --git a/tests/test_replay_corruption.py b/tests/test_replay_corruption.py index 76be5fc..6005898 100644 --- a/tests/test_replay_corruption.py +++ b/tests/test_replay_corruption.py @@ -45,11 +45,11 @@ def _meta(counts=None): return { "schema": 2, "complete": True, - "sdk_version": "0.1.0rc2", + "sdk_version": "0.1.0rc3", "serial": "OGLO-L-TEST01", "side": "left", "hw_rev": "RDR02_FLEX5_REV_D_TIA", - "fw_rev": "0.9.9", + "fw_rev": "0.9.10", "transport": "usb", "rate_hz": 250, "channels": ["pinky", "ring", "middle", "index", "thumb"], @@ -223,7 +223,7 @@ def test_malformed_meta_json_and_noninteger_counts_are_replay_errors(tmp_path): ("serial", "", "serial.*non-empty JSON string"), ("hw_rev", 4, "hw_rev.*non-empty JSON string"), ("fw_rev", "", "fw_rev.*non-empty JSON string"), - ("fw_rev", "0.9.8", "firmware 0.9.9 or newer"), + ("fw_rev", "0.9.9", "firmware 0.9.10 or newer"), ("transport", "replay", "transport must be 'usb' or 'ble'"), ("rate_hz", "250", "rate_hz.*JSON integer"), ("rate_hz", 0, "rate_hz.*1..1000"), diff --git a/tests/test_vectors.py b/tests/test_vectors.py index c77333a..168af08 100644 --- a/tests/test_vectors.py +++ b/tests/test_vectors.py @@ -21,9 +21,6 @@ from oglo._config import MIN_FIRMWARE, _fw_at_least VECTORS = Path(__file__).resolve().parent.parent / "spec" / "vectors" -# Only the strict runtime contracts are decoder gates. The directory also retains a -# real 0.9.9 legacy BIN capture as provenance for browser/hardware work; this SDK -# deliberately has no BIN compatibility decoder. CASES = ( sorted([*VECTORS.glob("tag_*.bin"), *VECTORS.glob("ble_*.bin")]) if VECTORS.exists() @@ -98,7 +95,7 @@ def test_checked_in_capture_has_every_required_modality(): @pytest.mark.skipif(not CASES, reason="no golden vectors yet") def test_every_checked_in_tactile_vector_is_packed12(): - """The supported 0.9.9+ contract has a 120-byte packed12 tactile payload.""" + """The supported 0.9.10+ contract has a 120-byte packed12 tactile payload.""" tac = [p for p in CASES if p.stem.startswith("tag_tactile")] assert tac, "capture a tactile vector with tools/capture_vectors.py" for path in tac: diff --git a/tools/capture_vectors.py b/tools/capture_vectors.py index 9dfa408..1d47bb9 100644 --- a/tools/capture_vectors.py +++ b/tools/capture_vectors.py @@ -59,7 +59,7 @@ def find_ports() -> list[str]: def open_port(port: str): """Use the SDK's opener so the DTR handling stays in one place. - Firmware 0.9.9 uses TinyUSB, which gates CDC transmit on DTR. + Supported firmware uses TinyUSB, which gates CDC transmit on DTR. """ from oglo._usb import open_serial @@ -127,8 +127,8 @@ def validate_capture_config(cfg: dict) -> bool: except (KeyError, TypeError, ValueError) as exc: raise ValueError(f"invalid fw_rev={cfg.get('fw_rev')!r}") from exc version += (0,) * (3 - len(version)) - if version < (0, 9, 9): - raise ValueError(f"firmware {cfg.get('fw_rev')} is older than 0.9.9") + if version < (0, 9, 10): + raise ValueError(f"firmware {cfg.get('fw_rev')} is older than 0.9.10") if cfg.get("schema_ver") != 6: raise ValueError(f"schema_ver={cfg.get('schema_ver')!r}; expected 6") if cfg.get("values_per_sample") != TAXELS: @@ -144,6 +144,24 @@ def validate_capture_config(cfg: dict) -> bool: return bool(cfg.get("has_mag", False)) +def capture_metadata(cfg: dict, *, include_serial: bool = False) -> dict: + """Return public-safe provenance for a captured vector set. + + Logical device serials belong in private fleet records, not public decoder + fixtures. The side-qualified placeholder still catches mixed capture sets. + """ + meta = { + k: cfg.get(k) + for k in ("serial", "side", "hw_rev", "fw_rev", "rate_hz", "imu_len", "has_mag") + } + if not include_serial: + side = str(cfg.get("side", "unknown")).strip().lower() + label = {"left": "L", "right": "R"}.get(side, "X") + meta["serial"] = f"OGLO-{label}-GOLDEN" + meta["serial_redacted"] = True + return meta + + def grab(s: serial.Serial, start: str, stop: str, seconds: float) -> bytes: s.reset_input_buffer() send(s, start) @@ -290,6 +308,11 @@ def main() -> int: ap = argparse.ArgumentParser(description=__doc__) ap.add_argument("--port", help="serial port; omit to auto-detect") ap.add_argument("--seconds", type=float, default=2.0) + ap.add_argument( + "--include-serial", + action="store_true", + help="include the real logical serial in generated metadata (not for public commits)", + ) args = ap.parse_args() if not math.isfinite(args.seconds) or args.seconds <= 0: raise SystemExit("--seconds must be a finite positive number") @@ -313,10 +336,7 @@ def main() -> int: has_mag = validate_capture_config(cfg) except (TypeError, ValueError) as exc: raise SystemExit(f"board does not satisfy the SDK contract: {exc}") from exc - meta = { - k: cfg.get(k) - for k in ("serial", "hw_rev", "fw_rev", "rate_hz", "imu_len", "has_mag") - } + meta = capture_metadata(cfg, include_serial=args.include_serial) print(f"board: {meta}") # One vector per required modality. Nothing is written until the complete diff --git a/uv.lock b/uv.lock index 00fa4c2..f349c12 100644 --- a/uv.lock +++ b/uv.lock @@ -708,7 +708,7 @@ wheels = [ [[package]] name = "oglo" -version = "0.1.0rc2" +version = "0.1.0rc3" source = { editable = "." } dependencies = [ { name = "bleak" }, From 36b0a1ac509e7880be9e56de085b76ee832468d8 Mon Sep 17 00:00:00 2001 From: Beomsoo Son Date: Sun, 9 Aug 2026 20:58:38 +0900 Subject: [PATCH 2/2] test: make fake stream pacing scheduler-safe --- tests/fake_serial.py | 21 ++++++++++++++++----- tests/test_doctor.py | 18 ++++++++++++++++++ 2 files changed, 34 insertions(+), 5 deletions(-) diff --git a/tests/fake_serial.py b/tests/fake_serial.py index 6e193e2..a96ca3d 100644 --- a/tests/fake_serial.py +++ b/tests/fake_serial.py @@ -91,7 +91,7 @@ def __init__( initial, _ = w.iter_tagged(stream) 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 - self._next_refill = 0.0 + self._next_refill: Optional[float] = None self.commands: List[str] = [] self.closed = False self._burst_tactile = 4 @@ -150,12 +150,19 @@ def _refill(self) -> bytes: """ if not self._stream: return b"" # nothing to produce; the test is driving _out by hand - if self.hz is not None: + if self._burst_secs > 0: now = time.monotonic() - if now < self._next_refill: + if self._next_refill is None or now < self._next_refill: return b"" - self._next_refill = max(now, self._next_refill) + self._burst_secs - n = self._burst_tactile + # A real board keeps sampling while the host process is descheduled and + # its USB buffer delivers those accumulated packets on the next read. + # Advancing from ``now`` silently discarded every missed fake interval, + # which made rate tests fail only on busy CI runners. + bursts = 1 + int((now - self._next_refill) / self._burst_secs) + self._next_refill += bursts * self._burst_secs + else: + bursts = 1 + n = self._burst_tactile * bursts out = tagged_burst(n, start_seq=self._next_tactile_seq) self._next_tactile_seq = (self._next_tactile_seq + n) & 0xFFFFFFFF return out @@ -181,6 +188,10 @@ def _handle(self, cmd: str) -> None: if up == "STREAM TAG ON": self._streaming = True self._out += self._stream + if self._burst_secs > 0: + # ``self._stream`` is the first produced burst; the next one is due + # one burst period later. Reset this on every new stream session. + self._next_refill = time.monotonic() + self._burst_secs return if up == "STREAM TAG OFF": self._streaming = False diff --git a/tests/test_doctor.py b/tests/test_doctor.py index 6e315e1..bba0fa4 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -77,6 +77,24 @@ def test_the_measured_rate_is_reported_against_what_the_board_says(monkeypatch): assert "250 Hz expected" in tac.detail +def test_fake_board_buffers_samples_while_host_is_descheduled(monkeypatch): + """A busy test runner delays reads; it does not stop the simulated board clock.""" + import fake_serial + from oglo import _wire as w + + now = [10.0] + monkeypatch.setattr(fake_serial, "time", SimpleNamespace(monotonic=lambda: now[0])) + serial = FakeSerial(CFG_V6, stream=tagged_burst(4), chunk=1_000_000, hz=250.0) + serial.write(b"STREAM TAG ON\n") + + # Three more four-sample bursts should accumulate during a 49 ms host pause. + now[0] += 0.049 + packets, remainder = w.iter_tagged(serial.read(1_000_000)) + tactile = [packet for packet in packets if isinstance(packet, w.TactilePacket)] + assert not remainder + assert len(tactile) == 16 + + def test_a_slow_board_is_failed_with_the_percentage(monkeypatch): """A number needs a reader who knows the expected value. A verdict does not.""" _ports(monkeypatch, [port("/dev/cu.usbmodemA")])