From 629efe175b9bab5c55980489f588c41ca7bfffaa Mon Sep 17 00:00:00 2001 From: Shang Date: Mon, 7 Sep 2026 04:10:47 -0400 Subject: [PATCH] feat(devices): export Experimental 0.4 Devices SDK Publish 0.4.0-experimental.1 from the audited private candidate so the public devices/ tree matches the website current SDK identity. Co-Authored-By: Claude Fable 5 --- devices/AGENTS.md | 12 +- devices/CHANGELOG.md | 47 ++ devices/CONTRIBUTING.md | 20 +- devices/QUICKSTART.md | 18 +- devices/README.md | 63 ++- devices/SHA256SUMS | 165 ++++-- devices/SPEC.md | 156 ++++- devices/docs/README.md | 6 + devices/docs/availability.json | 4 +- devices/docs/board-verification.md | 68 +-- devices/docs/cases/ahakey-x1-case.md | 178 ++++++ devices/docs/cases/multipad-first-case.md | 147 +++++ devices/docs/compatibility-tunnel.md | 63 +++ devices/docs/conformance.md | 22 +- devices/docs/development.md | 12 +- devices/docs/device-audio.md | 92 +++ devices/docs/foundation-development.md | 68 +-- devices/docs/hardware-support.md | 62 +- devices/docs/implementation-tracks.md | 12 +- devices/docs/interfaces.md | 114 ++-- devices/docs/migration-0.1-to-0.2.md | 12 +- devices/docs/multipad-usb.md | 118 ++++ devices/docs/porting-guide.md | 14 +- devices/docs/project-status.md | 36 +- devices/docs/reference-approval-controller.md | 8 +- devices/docs/use-cases.md | 16 +- devices/firmware/ahakey-x1/CMakeLists.txt | 45 ++ devices/firmware/ahakey-x1/README.md | 113 ++++ devices/firmware/ahakey-x1/ahakey_proto.c | 118 ++++ devices/firmware/ahakey-x1/ahakey_proto.h | 148 +++++ .../ahakey-x1/developer-kit/README.md | 69 +++ .../developer-kit/nexting_ahakey_port.c | 175 ++++++ .../developer-kit/nexting_ahakey_port.h | 81 +++ .../nexting-ahakey-device-info.template.json | 53 ++ .../ahakey-x1/nexting_ahakey_adapter.c | 303 ++++++++++ .../ahakey-x1/nexting_ahakey_adapter.h | 141 +++++ .../firmware/ahakey-x1/nexting_ahakey_audio.c | 452 +++++++++++++++ .../firmware/ahakey-x1/nexting_ahakey_audio.h | 119 ++++ .../ahakey-x1/tests/test_ahakey_adapter.c | 218 +++++++ .../ahakey-x1/tests/test_ahakey_audio.c | 214 +++++++ .../ahakey-x1/tests/test_ahakey_port.c | 132 +++++ .../ahakey-x1/tests/test_ahakey_proto.c | 132 +++++ .../transport-descriptor.nexting-device.json | 39 ++ devices/firmware/multipad/CMakeLists.txt | 25 + devices/firmware/multipad/README.md | 111 ++++ devices/firmware/multipad/bringup-status.md | 40 ++ .../firmware/multipad/hw-review-findings.md | 16 + ...nexting-multipad-device-info.template.json | 32 ++ .../multipad/nexting_multipad_adapter.c | 152 +++++ .../multipad/nexting_multipad_adapter.h | 72 +++ devices/firmware/multipad/pin-function-map.md | 43 ++ .../firmware/multipad/tests/test_adapter.c | 85 +++ .../firmware/multipad/tools/flash-multipad.sh | 65 +++ .../multipad/tools/multipad-cdc-smoke.py | 67 +++ devices/firmware/zephyr/README.md | 12 +- .../zephyr/tests/firmware-contract.test.mjs | 119 +++- devices/package.json | 7 +- devices/protocol/vectors/approval-v1.json | 5 +- .../vectors/compatibility-tunnel-v1.json | 56 ++ devices/protocol/vectors/config-v1.json | 56 +- devices/protocol/vectors/device-audio-v1.json | 115 ++++ devices/protocol/vectors/device-info-v1.json | 16 +- devices/protocol/vectors/keys-v1.json | 58 +- devices/protocol/vectors/navigation-v1.json | 68 ++- devices/protocol/vectors/rotary-v1.json | 59 +- devices/protocol/vectors/text-v1.json | 31 +- devices/protocol/vectors/usage-v1.json | 54 +- devices/protocol/vectors/voice-v1.json | 37 +- devices/reference/js/README.md | 10 +- devices/reference/js/package.json | 6 +- devices/reference/js/src/audio.mjs | 301 ++++++++++ devices/reference/js/src/device-info.mjs | 70 ++- devices/reference/js/src/framing.mjs | 4 +- devices/reference/js/src/protocol.mjs | 534 +++++++++++++++--- devices/reference/js/src/relay.mjs | 16 +- devices/reference/js/src/strict-json.mjs | 52 ++ devices/reference/js/src/tunnel.mjs | 147 +++++ .../reference/js/test/audio-control.test.mjs | 62 ++ devices/reference/js/test/audio.test.mjs | 152 +++++ .../reference/js/test/device-info.test.mjs | 98 +++- devices/reference/js/test/framing.test.mjs | 5 +- devices/reference/js/test/package.test.mjs | 16 +- devices/reference/js/test/protocol.test.mjs | 14 +- devices/reference/js/test/relay.test.mjs | 48 +- devices/reference/js/test/status.test.mjs | 9 +- devices/reference/js/test/tunnel.test.mjs | 120 ++++ devices/reference/js/test/vectors.test.mjs | 18 +- devices/schemas/message.schema.json | 157 ++++- .../schemas/transport-descriptor.schema.json | 108 ++++ devices/scripts/check-naming.mjs | 13 +- devices/scripts/check-public-boundary.mjs | 17 +- .../scripts/documentation-contract.test.mjs | 277 +++++++-- devices/scripts/export-manifest.json | 51 +- devices/scripts/export-nexting-devices.mjs | 75 ++- .../scripts/export-nexting-devices.test.mjs | 86 ++- devices/scripts/simulator-contract.test.mjs | 14 +- devices/sdk/c/CMakeLists.txt | 6 +- devices/sdk/c/README.md | 11 +- devices/sdk/c/include/nexting_device.h | 61 +- devices/sdk/c/include/nexting_device_audio.h | 65 +++ devices/sdk/c/src/nexting_device.c | 502 +++++++++++++++- devices/sdk/c/src/nexting_device_audio.c | 238 ++++++++ .../c/tests/generate_interaction_vectors.mjs | 10 +- devices/sdk/c/tests/generate_vectors.mjs | 27 +- devices/sdk/c/tests/test_audio.c | 241 ++++++++ devices/simulator/device-audio-simulator.mjs | 293 ++++++++++ .../simulator/device-audio-simulator.test.mjs | 240 ++++++++ 107 files changed, 8949 insertions(+), 711 deletions(-) create mode 100644 devices/docs/cases/ahakey-x1-case.md create mode 100644 devices/docs/cases/multipad-first-case.md create mode 100644 devices/docs/compatibility-tunnel.md create mode 100644 devices/docs/device-audio.md create mode 100644 devices/docs/multipad-usb.md create mode 100644 devices/firmware/ahakey-x1/CMakeLists.txt create mode 100644 devices/firmware/ahakey-x1/README.md create mode 100644 devices/firmware/ahakey-x1/ahakey_proto.c create mode 100644 devices/firmware/ahakey-x1/ahakey_proto.h create mode 100644 devices/firmware/ahakey-x1/developer-kit/README.md create mode 100644 devices/firmware/ahakey-x1/developer-kit/nexting_ahakey_port.c create mode 100644 devices/firmware/ahakey-x1/developer-kit/nexting_ahakey_port.h create mode 100644 devices/firmware/ahakey-x1/nexting-ahakey-device-info.template.json create mode 100644 devices/firmware/ahakey-x1/nexting_ahakey_adapter.c create mode 100644 devices/firmware/ahakey-x1/nexting_ahakey_adapter.h create mode 100644 devices/firmware/ahakey-x1/nexting_ahakey_audio.c create mode 100644 devices/firmware/ahakey-x1/nexting_ahakey_audio.h create mode 100644 devices/firmware/ahakey-x1/tests/test_ahakey_adapter.c create mode 100644 devices/firmware/ahakey-x1/tests/test_ahakey_audio.c create mode 100644 devices/firmware/ahakey-x1/tests/test_ahakey_port.c create mode 100644 devices/firmware/ahakey-x1/tests/test_ahakey_proto.c create mode 100644 devices/firmware/ahakey-x1/transport-descriptor.nexting-device.json create mode 100644 devices/firmware/multipad/CMakeLists.txt create mode 100644 devices/firmware/multipad/README.md create mode 100644 devices/firmware/multipad/bringup-status.md create mode 100644 devices/firmware/multipad/hw-review-findings.md create mode 100644 devices/firmware/multipad/nexting-multipad-device-info.template.json create mode 100644 devices/firmware/multipad/nexting_multipad_adapter.c create mode 100644 devices/firmware/multipad/nexting_multipad_adapter.h create mode 100644 devices/firmware/multipad/pin-function-map.md create mode 100644 devices/firmware/multipad/tests/test_adapter.c create mode 100755 devices/firmware/multipad/tools/flash-multipad.sh create mode 100755 devices/firmware/multipad/tools/multipad-cdc-smoke.py create mode 100644 devices/protocol/vectors/compatibility-tunnel-v1.json create mode 100644 devices/protocol/vectors/device-audio-v1.json create mode 100644 devices/reference/js/src/audio.mjs create mode 100644 devices/reference/js/src/strict-json.mjs create mode 100644 devices/reference/js/src/tunnel.mjs create mode 100644 devices/reference/js/test/audio-control.test.mjs create mode 100644 devices/reference/js/test/audio.test.mjs create mode 100644 devices/reference/js/test/tunnel.test.mjs create mode 100644 devices/schemas/transport-descriptor.schema.json create mode 100644 devices/sdk/c/include/nexting_device_audio.h create mode 100644 devices/sdk/c/src/nexting_device_audio.c create mode 100644 devices/sdk/c/tests/test_audio.c create mode 100644 devices/simulator/device-audio-simulator.mjs create mode 100644 devices/simulator/device-audio-simulator.test.mjs diff --git a/devices/AGENTS.md b/devices/AGENTS.md index 3e5f36f..a639525 100644 --- a/devices/AGENTS.md +++ b/devices/AGENTS.md @@ -53,7 +53,7 @@ Do not create language-specific behavior that is absent from the shared contract ## Required local gates -From the repository root: +From this package directory: ```sh npm run test:reference @@ -65,11 +65,11 @@ npm run check:boundary npm run check:naming ``` -`npm run check` is the final aggregate gate. At the 2026-07-27 snapshot it -passes, including Device Info 0.2, the audited exporter, mandatory bonding, -three-second bond reset, and notification fragmentation. Run -Do not weaken or delete these assertions; a source contract and compiler gate still do not -replace the real-board checklist. +`npm run check` is the final aggregate gate. The 2026-07-27 snapshot passed, +including Device Info 0.2, the audited exporter, mandatory bonding, +three-second bond reset, and notification fragmentation. Run it before shipping. +Do not weaken or delete these assertions; a source contract and compiler gate +still do not replace the real-board checklist. ## Hardware claims diff --git a/devices/CHANGELOG.md b/devices/CHANGELOG.md index b903856..cf3ca1c 100644 --- a/devices/CHANGELOG.md +++ b/devices/CHANGELOG.md @@ -4,6 +4,53 @@ This file records changes to public protocol behavior, shared vectors, SDK surfa ## Unreleased +## 0.4.0-experimental.1 — 2026-09-07 + +Wire major 1 remains compatible. This release publishes the AhaKey X1 +production-port boundary and makes Experimental 0.4 the current public SDK +identity. + +### Added + +- AhaKey X1 production-port boundary for `device-audio/1`: physical-only + microphone start, IMA ADPCM frames, bounded credit queue, fragmentation, + timeout/error wiping, and a data-only tunnel over its existing BLE service. +- AhaKey transport descriptor and injectable host CTests for encryption gates, + stock-command coexistence, device PCM, and flow-control failure paths. + +## 0.3.0-experimental.1 — 2026-08-13 + +Wire major 1 remains compatible. This release adds an independently negotiated +device microphone profile and a data-only compatibility transport. + +### Added + +- `device-audio/1` control messages, Device Info capability, binary IMA ADPCM + frames, CRC32C, fragmentation, credits, hostile vectors, and JSON Schema. +- Byte-identical JavaScript and fixed-buffer C99 audio codecs. +- Compatibility Tunnel v1 descriptors with a single fixed-opcode envelope, + strict allowlisting, encryption/bonding requirements, and no executable + plugin surface. +- A deterministic hardware-free simulator for physical-key capture, 120-second + limit, bounded RAM, credit stalls, cancellation, and missing-frame policy. + +### Changed + +- `voice/1` retains its existing meaning: it controls the Host microphone and + never carries audio. `device-audio/1` carries microphone audio sampled by the + physical device itself. +- A Host can prepare, reject, cancel, or terminate a device-audio stream, but + cannot remotely start the device microphone. + +### Added + +- AhaKey X1 integration kit: a portable C99 codec for the published AhaKey + BLE configuration protocol (`firmware/ahakey-x1/ahakey_proto.*`), the + Nexting mapping adapter for its keys, approval lever, LED strip, and OLED + (`firmware/ahakey-x1/nexting_ahakey_adapter.*`), a Device Info template, + host-side CTest coverage including the published wire captures, and the + end-to-end case document (`docs/cases/ahakey-x1-case.md`). + ### Changed - Reframed the Quickstart around the device–App–Agent architecture and split diff --git a/devices/CONTRIBUTING.md b/devices/CONTRIBUTING.md index 3bdcba9..a93621e 100644 --- a/devices/CONTRIBUTING.md +++ b/devices/CONTRIBUTING.md @@ -33,16 +33,16 @@ Unknown, malformed, unauthorized, stale, duplicate, replaced, or expired input m ## Run evidence appropriate to the change -| Change | Minimum evidence | -| --- | --- | -| Protocol or vectors | JavaScript, Swift, Kotlin, and C sanitizer suites | -| Official App integration | Private Nexting App validation; not part of this public repository | -| Public documentation system | Documentation contract, links, public-boundary, naming, and diff checks | -| C device core | CMake build and CTest with ASan/UBSan | -| BLE simulator | Private integration contract and warnings-as-errors `swiftc` build | -| Reference firmware | Firmware contract plus every affected pinned board build | -| Hardware claim | Complete dated real-iPhone checklist for the exact board and firmware commit | -| Documentation only | Link, public-boundary, naming, and diff checks | +| Change | Minimum evidence | +| --------------------------- | ---------------------------------------------------------------------------- | +| Protocol or vectors | JavaScript, Swift, Kotlin, and C sanitizer suites | +| Official App integration | Private Nexting App validation; not part of this public repository | +| Public documentation system | Documentation contract, links, public-boundary, naming, and diff checks | +| C device core | CMake build and CTest with ASan/UBSan | +| BLE simulator | Private integration contract and warnings-as-errors `swiftc` build | +| Reference firmware | Firmware contract plus every affected pinned board build | +| Hardware claim | Complete dated real-iPhone checklist for the exact board and firmware commit | +| Documentation only | Link, public-boundary, naming, and diff checks | The exact commands and current known failures live in [`docs/development.md`](docs/development.md). Do not delete, skip, or weaken a failing security assertion to make a branch green. diff --git a/devices/QUICKSTART.md b/devices/QUICKSTART.md index 79a3616..1c9a682 100644 --- a/devices/QUICKSTART.md +++ b/devices/QUICKSTART.md @@ -15,11 +15,11 @@ side of that contract; it does not provide a kit for building another App. ## What each layer does -| Layer | Responsibility | Does not receive | -| --- | --- | --- | -| Nexting device | Report physical intent and render bounded state | Agent credentials, accounts, session routes | -| Trusted Host | Authorize the device, validate fresh input, and map profiles to Agent actions | Unbounded or unauthenticated device commands | -| Agent integration | Continue the Claude Code, Codex, or compatible Agent session | A direct connection from accessory firmware | +| Layer | Responsibility | Does not receive | +| ----------------- | ----------------------------------------------------------------------------- | -------------------------------------------- | +| Nexting device | Report physical intent and render bounded state | Agent credentials, accounts, session routes | +| Trusted Host | Authorize the device, validate fresh input, and map profiles to Agent actions | Unbounded or unauthenticated device commands | +| Agent integration | Continue the Claude Code, Codex, or compatible Agent session | A direct connection from accessory firmware | This separation lets one public device protocol support a button, wearable, macropad, desk panel, or custom display without placing private Agent logic in @@ -27,10 +27,10 @@ firmware. ## Choose your path -| Path | Start | First proof | -| --- | --- | --- | -| Use a supported first-party Nexting product | Follow that product's in-App onboarding, connect the Agent, then pair and authorize the product | The same Agent session receives one validated physical action | -| Build with the Nexting SDK | Download the public SDK and run the protocol and firmware checks before adapting hardware | A deterministic profile exchange and a reproducible firmware build | +| Path | Start | First proof | +| ------------------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------ | +| Use a supported first-party Nexting product | Follow that product's in-App onboarding, connect the Agent, then pair and authorize the product | The same Agent session receives one validated physical action | +| Build with the Nexting SDK | Download the public SDK and run the protocol and firmware checks before adapting hardware | A deterministic profile exchange and a reproducible firmware build | First-party product onboarding does not authorize third-party or DIY hardware. If you do not own a supported product, use the SDK path today. diff --git a/devices/README.md b/devices/README.md index d23ce97..39ddccd 100644 --- a/devices/README.md +++ b/devices/README.md @@ -27,34 +27,35 @@ The device does not run the Agent session and does not receive Agent credentials ## Choose your path -| You want to… | Start here | -| --- | --- | -| Understand and connect Nexting | [Follow the public Quickstart](QUICKSTART.md) | -| Build the XIAO approval reference | [Build the reference approval controller](docs/reference-approval-controller.md) | -| Get ideas for what to build | [Browse the use cases](docs/use-cases.md) | -| Build and flash a supported board | [Run the first approval](docs/first-approval.md), then use the [reference-board track](docs/implementation-tracks.md#track-1-run-a-reference-board) | -| Understand the device connection contract | [Browse every public interface](docs/interfaces.md) | -| Port a new MCU, RTOS, or chip family | [Build the public foundation](docs/foundation-development.md), then use the [MCU port track](docs/implementation-tracks.md#track-3-port-a-new-mcu-or-rtos) | -| Implement another protocol tool | Use the [protocol-tooling track](docs/implementation-tracks.md#track-3-maintain-protocol-tooling) | -| Make a compatibility claim | [Understand compatibility evidence](docs/conformance.md) | -| Upgrade an Experimental 0.1 integration | [Read the 0.2 migration guide](docs/migration-0.1-to-0.2.md) | - -## Experimental 0.2.0-experimental.2 - -The release keeps wire major 1 and publishes nine independently negotiated +| You want to… | Start here | +| ----------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Understand and connect Nexting | [Follow the public Quickstart](QUICKSTART.md) | +| Build the XIAO approval reference | [Build the reference approval controller](docs/reference-approval-controller.md) | +| Get ideas for what to build | [Browse the use cases](docs/use-cases.md) | +| Build and flash a supported board | [Run the first approval](docs/first-approval.md), then use the [reference-board track](docs/implementation-tracks.md#track-1-run-a-reference-board) | +| Understand the device connection contract | [Browse every public interface](docs/interfaces.md) | +| Port a new MCU, RTOS, or chip family | [Build the public foundation](docs/foundation-development.md), then use the [MCU port track](docs/implementation-tracks.md#track-3-port-a-new-mcu-or-rtos) | +| Implement another protocol tool | Use the [protocol-tooling track](docs/implementation-tracks.md#track-3-maintain-protocol-tooling) | +| Make a compatibility claim | [Understand compatibility evidence](docs/conformance.md) | +| Upgrade an Experimental 0.1 integration | [Read the 0.2 migration guide](docs/migration-0.1-to-0.2.md) | + +## Experimental 0.4.0-experimental.1 + +The release keeps wire major 1 and publishes ten independently negotiated profiles. A device declares only the profiles and hardware it implements: -| Profile | What it carries | -| --- | --- | -| `approval/1` | One active Allow/Deny request with TTL | -| `status/1` | Volatile Agent state for up to eight slots | -| `navigation/1` | Bounded options, cursor movement, and selection | -| `keys/1` | Host-defined key labels/light state and generic key events | -| `rotary/1` | Host-defined dial labels plus relative turn/press events | -| `voice/1` | Push-to-talk start/stop/cancel control; no audio | -| `text/1` | Bounded plain text for a device display | -| `usage/1` | Model label and bounded usage counters | -| `config/1` | Versioned atomic configuration and result | +| Profile | What it carries | +| -------------- | ---------------------------------------------------------- | +| `approval/1` | One active Allow/Deny request with TTL | +| `status/1` | Volatile Agent state for up to eight slots | +| `navigation/1` | Bounded options, cursor movement, and selection | +| `keys/1` | Host-defined key labels/light state and generic key events | +| `rotary/1` | Host-defined dial labels plus relative turn/press events | +| `voice/1` | Start/stop/cancel control for the Host microphone; no audio | +| `device-audio/1` | Physical-device microphone audio with credits and cancellation | +| `text/1` | Bounded plain text for a device display | +| `usage/1` | Model label and bounded usage counters | +| `config/1` | Versioned atomic configuration and result | Public: @@ -83,9 +84,13 @@ the device supports. The Host rejects traffic for undeclared profiles. The profiles carry generic physical intent, not private Agent commands. The trusted Host's Agent adapter decides whether key 3 means `fork`, whether a dial switches a session or model, and which bounded text is safe to display. -`voice/1` carries only push-to-talk control: capture, permission, audio, and -transcription stay on the Host microphone. This lets DIY hardware remain useful -without receiving Agent credentials, internal session IDs, or account data. +`voice/1` carries only Host-microphone control. `device-audio/1` is separate: +it carries IMA ADPCM sampled by a microphone on the physical device, and only a +local physical input can begin a stream. Both paths reuse the official App's +speech-to-text and isolated Agent routing without giving hardware Agent +credentials, internal session IDs, or account data. See +[Device audio](docs/device-audio.md) and the +[Compatibility Tunnel](docs/compatibility-tunnel.md). Two identity tiers share the contract: Nexting first-party products (PIN, Ring) carry production identity and the full capability set; third-party and DIY devices use the same protocol under explicit, revocable user authorization in the Host App. diff --git a/devices/SHA256SUMS b/devices/SHA256SUMS index f903c32..54db43a 100644 --- a/devices/SHA256SUMS +++ b/devices/SHA256SUMS @@ -1,22 +1,55 @@ -2640a33ac6d69556ef36c3d32708d2cd4c79eed7089e63d72746823614a09501 AGENTS.md -a311a5de4c12cedd2803da7c3fa12d759c90a4d2edcc2af18ab6a8f6048f2308 CHANGELOG.md -841a6883b971b9d286eb847d75925d498f66a624cdd331da57363bc41be70ee4 CONTRIBUTING.md -af25616c5fde628b7fd02d2ca52a7ac284fa98d985875ea0293122f07890fe6b docs/availability.json -fee2bc61257031a3a77f16d08317ffc900399da391e23c34c5a8eeee5e1242a4 docs/board-verification.md -32b057ed232482338571cdfe059e7cafd39e3bb50181faafc20d4e5686a6d442 docs/conformance.md -29dc27c8528032e2a5336f321d46168ff7fbf4e77eda64660a5bc2ecb0483b01 docs/development.md +1af2d60ef6b48f392616c31f1d149b87ccd3754a637af8145375c1186a2d0201 AGENTS.md +8077f6c9950da6f28e13141ad4facd2caefcd4d21b2f1ec9b4afe015b419a81b CHANGELOG.md +896beb516722fda2cae1d0b12323c031c1849e4cc79388cfe1c767c96db5bb08 CONTRIBUTING.md +a8be36252b186dd82ec548855891a8f4d3f259a98271111c62b2346b37842ff0 docs/availability.json +f0e5a3b3fc8d8d664d76be452f1631a296ef89566acb58440354da31af2074bd docs/board-verification.md +1c2c754b97f5e6740a48be6bf2d5073ec008dde58ee605cdca225eab7550c8a2 docs/cases/ahakey-x1-case.md +06a0f796634a47af9fb8628b11206a91120505e0a0d0fd6757915ef44f961f47 docs/cases/multipad-first-case.md +62de222254705013e539846f7929e5192ab41774d3ff3ff4ec7980639853e4fe docs/compatibility-tunnel.md +1acc6490da634d7c329d2bf44b46029a5fba617cb27d93c7415a05d926151ddc docs/conformance.md +5707c42f8d2fbb981753e128bfcbec0d48bb3c8a950a67f97355b5c659cdc50a docs/development.md +c020330fda25a25f8c328e007221ab4232e51123e00affdf4c9f3998c241006e docs/device-audio.md 554f14560e53c8428ef18575cc155eef52650767e698ca66b2f3de5c01c7eee9 docs/first-approval.md -e1a42beb0cc2e2715cfb50d4c2d9aaa4f9cc5e0b03f1168b6dfe8d9ab9624162 docs/foundation-development.md -45a6f16c8f43443ee7035efa0dd5d0c87839e2835510f52a4760ab77bbe76569 docs/hardware-support.md -b653e1bd72ddf8f9bbaa4a11457ac17a35ad9331bd655fa79cace3f9b54cd74e docs/implementation-tracks.md -3efcaa345571e2b67393634a8622328540e660d97f7811b9ede05d2d70caf289 docs/interfaces.md -8912e3a90ae17e5b864141efef5017bab3cc62e23ff432595e87114168905f5a docs/migration-0.1-to-0.2.md -b9dda6616039d28de4247731274a96b3b861fa5bd931a234ae4b05e3f2ca1978 docs/porting-guide.md -967ef62628994aaa085e8b617888f186e21d923515670422a672e09492ecbcfd docs/project-status.md -fcdfdd6dc2cdbf13d608b339dbd8439bbcfba3c4568816d7dd00b66e869fb796 docs/README.md -773df90e0d34f4c079ff48066383ba2f0dc1d872079c4ac3c3bd2e33eb21ee97 docs/reference-approval-controller.md +97104965fd204e878559740831a4014257b9ad09def7b24e5c3ed66aafa5e867 docs/foundation-development.md +b41e9a47944418618b21b1e295745bf6f310ecbeff4410db656f3df2748b04ff docs/hardware-support.md +72f7796e6277308e3ba09edc58c2200b5af3bd6f38f627a374a0db6ffeceebe5 docs/implementation-tracks.md +3c4487a1ae21c3e9e87589463e0a911f29b8b90a21c043fed32136564f3451c0 docs/interfaces.md +61f50d6a76c7b55251df98f785fdbcf36ce683d021f68384133051f210d5150d docs/migration-0.1-to-0.2.md +74f60bf30d5940a6cb2f71b208abb48196249a0f1195eb7ac7f6eeb125c3e573 docs/multipad-usb.md +36f7151ddbf307f8ff4cf5487d094930882b78b7ee91d241adf2eff53753ac55 docs/porting-guide.md +555b8a59da7f379ca4627d2705b4a9de9a854d49a23669d87bd7464922f403db docs/project-status.md +b8cfa2770d2e2cd1b391997eb8ae3deb148e447c3f4d826a34e60650860f3e79 docs/README.md +2f5e27fd1e4a5621785ee951505d04c32a74afc525bbff6e4a9209235c909d02 docs/reference-approval-controller.md 317190076de89ff907b686a29df6303e271ca65bb5383deee7e80122425d1cd9 docs/troubleshooting.md -8bded68d2fb5341d6595bc44ecfee4e16319f0f5a5a9431a09d8b013a63ba0cc docs/use-cases.md +482d4727e7dff88f5f3a792633946b53ba3cde783cf15ed454199608557e779d docs/use-cases.md +be8af1bf038252dd434242ac128428f65c92de6623368aeb51922e9e51ba11b2 firmware/ahakey-x1/ahakey_proto.c +42d5ba76d2bf0d1287084866fb51ec66866aa4bc5d92c924d84b817fd1728cca firmware/ahakey-x1/ahakey_proto.h +81836097b909ab049fce8dff7c85ea6878425d72a82579ddadccf68ce8aee184 firmware/ahakey-x1/CMakeLists.txt +32f3e0863fdb8c89646b16805c1befa480e6c3775ed3d1a8b6893d9cb5a997c1 firmware/ahakey-x1/developer-kit/nexting_ahakey_port.c +d60256fe93b0e67bbdf982a92734c95581b317a128f5de6dd21ca50f73f68950 firmware/ahakey-x1/developer-kit/nexting_ahakey_port.h +8f8111554029fc6926ada4b07efa6e59389ef2b7e3e915076216f6be9379ddc6 firmware/ahakey-x1/developer-kit/README.md +3e01041177cd76b56b577cf2bed549b3f6d1392d9f262ce6b08594a285d9170d firmware/ahakey-x1/nexting_ahakey_adapter.c +46740ee2b3a75835953ed313c72c2712e60dc97f019b5aaf84288ca65678fb88 firmware/ahakey-x1/nexting_ahakey_adapter.h +22c4267129e6c2688afb1ad375747a9e8e8d47f570b884273b08f7d59eb41de8 firmware/ahakey-x1/nexting_ahakey_audio.c +5e74849cf5a7a9b4af567f2a85fd7e3c2edced9c34e757f7592fc541a1d1843d firmware/ahakey-x1/nexting_ahakey_audio.h +706d1289aabdd1d685af50383945d62031c1a02613a534eae32048c36f6c4322 firmware/ahakey-x1/nexting-ahakey-device-info.template.json +f76a9306244d78bf43303292d263a8e09b04cede6706af55854343fe57fe793c firmware/ahakey-x1/README.md +f470628411b1136cfad6188c4dfbcb014d1f81fe2b3bf644abc58fde188a8ae3 firmware/ahakey-x1/tests/test_ahakey_adapter.c +2cb59b237a3196d55557cc8bff8280bf5f8488d3b096ee86d13cfed6a4141eb7 firmware/ahakey-x1/tests/test_ahakey_audio.c +cbcce4e5d2219256418485e0151d41e58c027267ec2bfeb3c8cfa756c0b32a61 firmware/ahakey-x1/tests/test_ahakey_port.c +c392a527057017e7e0e56e2e8b822c0743a2233dd4ec8a5b3a77cfe7cf062a3d firmware/ahakey-x1/tests/test_ahakey_proto.c +1da0d8e8a586b640ff119460c5161891579f98d59d812b9ab4152f693c636bca firmware/ahakey-x1/transport-descriptor.nexting-device.json +445fd02928f57ec5cb23b15c6fd4617cff6348e2df991736d04bad5d4ae8ec90 firmware/multipad/bringup-status.md +e93dad280dad39b252d5d0edaf549cf9db8a8833eb009bc2b089da8ebfb181d2 firmware/multipad/CMakeLists.txt +58edf13b1bbc8c855432f921eab5ac74befe9a78ac8254c9040eeef4b0704c52 firmware/multipad/hw-review-findings.md +d40be2000fae5697e0d40abebfc833d31c8568679c66c3f1b1cbb85744dac7d4 firmware/multipad/nexting_multipad_adapter.c +f4bab39e2320ae1f171eca78005cdfd355848dda5690020f2201de4a96d37587 firmware/multipad/nexting_multipad_adapter.h +55e8c889cfe9b62dcc62c591880aba2f9826a6a1353d7a89213916efd27c0bce firmware/multipad/nexting-multipad-device-info.template.json +732ad9fa55a9055e2d8c1c701489fa09608634bda4b89a2e1d601dff5e75e013 firmware/multipad/pin-function-map.md +2c743c98200d48a611fc9cb5994e11372b9daa3dc944570c3d48e55bf88be7ba firmware/multipad/README.md +49d3bf8fc0756bf4c69eb6d5af3b7cfe788c2c56822f000ba05adcad8d16e7c7 firmware/multipad/tests/test_adapter.c +a111d8e0484745a126069df990273171181a61caa5a3a751043974cc52f43f00 firmware/multipad/tools/flash-multipad.sh +4c27437ae7b7c5555a8a21eb763785e8600682ad71f0b12f29304e56cf132d5f firmware/multipad/tools/multipad-cdc-smoke.py 1449dfcf09318c73ba53332f779b87e5e9f4f8eb81e6b03b8ff5794c922fc8b4 firmware/zephyr/boards/xiao_ble.overlay beb6f3a8a45f6dfebdcf509c17f595431f23649b15239b96ee8a28593d8964e7 firmware/zephyr/boards/xiao_esp32c3.overlay 1449dfcf09318c73ba53332f779b87e5e9f4f8eb81e6b03b8ff5794c922fc8b4 firmware/zephyr/boards/xiao_esp32s3.overlay @@ -24,57 +57,69 @@ c24d171b96e0541a3f5c8053d83e1630f7f16ef1d6f6d30b630e7063d122b554 firmware/zephy 0be1017de0baf69292cb7eee26a9f6c08753a43ca6d583a9344397dd349d46b4 firmware/zephyr/debug-test-device.conf 363579f1d3b70d4e83de33a51587ee496c1f9d26105ace386251b6187ac0b321 firmware/zephyr/package.json ca92428690e5d784eea852e5f31573d78c6bc42c9fb7229d7989ff89cbd3b543 firmware/zephyr/prj.conf -c700f02585e957eb666ce6b014a0cd9c30690c77deda5b19a86073c67716c1d7 firmware/zephyr/README.md +d060ff9e9a7c6fb3cebc008b1db5b0127bbf077008e3553c95d7afdf37115c36 firmware/zephyr/README.md 958e3a5e90984f2a0e2915c6c4047d98bbf4b46a9853e4ace2e2fe2143fc242d firmware/zephyr/src/main.c -2cf5855a3de0ebf320ad2df4b7c31cc22818d0dd149fef88b94e18eab5aba31c firmware/zephyr/tests/firmware-contract.test.mjs +d7a9f2e9fc82fc5b863b86a1124bc9f9e7f816aac6825a562e462ca57f5079f4 firmware/zephyr/tests/firmware-contract.test.mjs 01d0ae601acc2f5654a086597be88738ddf92967dc9d70fe81941afe42a62b44 LICENSE ffb09337e397e3597a52cacd161efd2a1b274fb6800a75fd1de67893a9d27021 LICENSES/CC-BY-4.0.txt faab06a4630dfc7b7e5c1fc7ecef24ae6ca0c0040f3b71f3d112d1a816a410b9 NOTICE -8625f21424019461e1cecabb81304ca2b4a1bfdee3df5b1c2b25b816b04e2be1 package.json -17225fa8b318326a9b8dbe9c59cc260e545e7615aa2b989229efb2eaa2fd47ed protocol/vectors/approval-v1.json -4c44778920f0b79b8df54823cec6b61845a68d5c9ea843cea893315bccb77e26 protocol/vectors/config-v1.json -f6f614c1eba9506c4cdc0dbd3f6726c1f47e060962d90b9fdb540a88c007a6b6 protocol/vectors/device-info-v1.json -808001c0cd305a1e30a09047c2ec786a0e5f3ceaf58775ce80c780f3ed9161c9 protocol/vectors/keys-v1.json -56b17d56f53afff5eaebfb140f8f68a13a8e7bd07001ad4b9a53b64857978791 protocol/vectors/navigation-v1.json -09b0cfaedfcfe1de28a932dc455cd8aee28994bfa85b4c6e1cd4298c45b48bf4 protocol/vectors/rotary-v1.json +e5db5013436c4c1a8341c1495df62b655ba054f42d54c2155a39167c5fde3cc5 package.json +af1ea5c4627d67b21afd238f48b7eb4931655894bd899450b16fc802d8cdcf68 protocol/vectors/approval-v1.json +6ddc663cb97dbe9e6a3e01f2854d0c739456ba87aeeb39105d6fd29bb9216e84 protocol/vectors/compatibility-tunnel-v1.json +00c60ff755e3880e1a9c8f54f1ee979acbe13b39e7b32048d442ea707e5864c5 protocol/vectors/config-v1.json +55e52f103dc18fd607daf9262dc3a8274be3aefd7d4e25d07fb6e976d70ebb41 protocol/vectors/device-audio-v1.json +e8114693a689dae05af19319434064e300f450c463c03f9623c08448fa278895 protocol/vectors/device-info-v1.json +e58b7425b9ff5838aea46511a248211769dfb56fa15654d5c986ff10edd9c284 protocol/vectors/keys-v1.json +f4d01e3a1eaf30cbc3e542049f9399e3fc88d73035e5375731074aa81acc6e53 protocol/vectors/navigation-v1.json +19d9566a51a7c6175f6431fefe48c24b2918cdfbcfdd57c3cc68bd183c8d21cb protocol/vectors/rotary-v1.json 3523ce906d597735ed9de66d79923b12de284595566c556725c10bf726ba84b0 protocol/vectors/status-v1.json -592cf022e7fb1310fbded1a7e5389abffdc42faf1c1c226feb8be41f5849d2c0 protocol/vectors/text-v1.json -052e46a781878cf3f343370c0492391f011577df44c7e3f8fe81c7c21e8499b1 protocol/vectors/usage-v1.json -2a5298dba7fb95fc5883407723ee178065139d81b5c6f46248c65829d8a6afbf protocol/vectors/voice-v1.json -8e3158f9e254db8a160c30a97539d94c768fdb38812181438e08df4854272d88 QUICKSTART.md -c074fecbd58ed23cd3d267a30a3ce939f05881efce3a7b1345c1b301dab54d64 README.md -d34ae54889cc7d601bc57011a65f2377bf70ee08cbbfba2aec081bd5e9ec02c7 reference/js/package.json -268baee817da2c39b26da691c9da621945528f5d7598e33b3dd55732bbe47335 reference/js/README.md -b25bb77cd8c9301aa664062b1ef00208f2e7d220419173268842338b7fd7254f reference/js/src/device-info.mjs -f438e875009475d7abfd7648607c7583ffac30e039807fa7b77eac4ee7106ec7 reference/js/src/framing.mjs -cc9bb6c2e427c6f6ffebc9334d24f8185a2c8e36cb0b6ad0912a056793e946d5 reference/js/src/protocol.mjs -a991cfc985e6623fed6d32cf8f0131b05940c8be04c418725dead5a60dd7cf56 reference/js/src/relay.mjs -4ab587c2d31a6a244a3f7a3501fea05afadb5d54f21921bbb5dc438e7d7b08d3 reference/js/test/device-info.test.mjs -028ce673b8b54d939b50d47b4cf534e2bf5cefa1bbca5030ec93b9bd78630c41 reference/js/test/framing.test.mjs -f253d2a3465557af1c228a04c820e837f95c8ee53b8f5af098240ae9b29c13e8 reference/js/test/package.test.mjs -7f52ef92691df05c526aa04b9283d924c9786f96d5e4288a9e2b28813f61979e reference/js/test/protocol.test.mjs -5589b79e0966553dc1f8067295ffbff4ec915f52eef060ba9af73fdeb72084b6 reference/js/test/relay.test.mjs -f98d37b7d3ec94cde23d46631d452f9a984bff7bc42a2ecd79383e91c9b75b18 reference/js/test/status.test.mjs -1f2e8af46598004fbfc7d21e1457506766835c7599be54f3a5f0cbaa206711e3 reference/js/test/vectors.test.mjs -224b6cd5e57ad61cdc531671cc1c2bf264b6131c8d7a5385f1b51f91b6dc7ca3 schemas/message.schema.json +cc1b6bf1ad2dfd7bd378aaf9c708e058530caf05af076fae1652c2bcb63788be protocol/vectors/text-v1.json +aef7febb2eccb1d8e30a56b8b5db4b8a823d999084e8f9d4ef8873fa195c4f5d protocol/vectors/usage-v1.json +209c3c1bf109ca91d8dfcadd4898c96f2dbeaad4da125b70dd0a01e3b6871052 protocol/vectors/voice-v1.json +23ffc20ed602de5e4cc7008f08fc568632c14f5aaf625b836aaf95b185f15649 QUICKSTART.md +814f7436d7e110ee54908fce43d7b7551b9ef41677a55fcf46f3471e6c20975f README.md +53e493faaedcbe3416d05883b459e656f0c7484ae81f44c22079165906bca6ee reference/js/package.json +fc664e3bade272f44ba98a15cae0e57aa2a6f931666ad9501dbc0a5aa19496f3 reference/js/README.md +f52346c5939e2e1230226dff14b634a12e083ebd3e50b5b67d75de6d8b936096 reference/js/src/audio.mjs +2b7019d502b862bb9e1064e7f99dc19126f6212dce9a22f1040b18fc7df7ad7e reference/js/src/device-info.mjs +e7ff09171e891a9e82bdc2a9b7b1d7f089a320e7c27c799337579002fca259b4 reference/js/src/framing.mjs +9f96281f5a6804bfca8801d28d6b450fafe4ce21cf272fdc13ed7501c354d2ac reference/js/src/protocol.mjs +8eea06b2746cd9ed956cb769712ef5702b06717d97318397cc320c3733b183d9 reference/js/src/relay.mjs +958d0af4b9b5d198cbecab420f66c8a85fa12beaed41881eb0af4a7a1b8554dd reference/js/src/strict-json.mjs +9cebbcd428d1c3e8feb4a73a679c39dd07a1d13c9715d7bf894c9d1ea91cc506 reference/js/src/tunnel.mjs +b76e865ad30d372def5a8484b5e5bf03b2714820321b98f3fedd9e278cd8a986 reference/js/test/audio-control.test.mjs +022f70f7b980c6ea9045cef55423e8f85c5e635cdebdca4acc220b33d1591c4a reference/js/test/audio.test.mjs +20b7f343d5be62d36224596f5a7cf96cbfd9c93acf3042296e7abb42ce80a870 reference/js/test/device-info.test.mjs +23bc2342bdf448d85738b316759802d36745d7b550c63f6b4b2ab2112de59666 reference/js/test/framing.test.mjs +99eb93651f102d063388efe6d44d1097450acd71a180c5e9115589a20006f85d reference/js/test/package.test.mjs +69fbc61f488aa7f28c6cd1eba4af9ede4d904228bd05c54618df3fb9d57a14a4 reference/js/test/protocol.test.mjs +7ba8fbf40ef598c26654ba0a6a20e4c64b6f92fb9537e0e6d5bfa0aeaf5201c0 reference/js/test/relay.test.mjs +10baf9bffbe230bca868c907c76795f9c63f2d93a02c4aa0723a6993f78752fd reference/js/test/status.test.mjs +8778ef9074aceedfedd61cda682fef1f8db36eaaa4352c8058a8854877230f46 reference/js/test/tunnel.test.mjs +82402afd5d7ec3f650d1a70cf935f6e25a0284e4b79a24639593547dd8a49b2d reference/js/test/vectors.test.mjs +aa1bd1757793a0fdf31933b0f3902f66c373dd79dfe349fcee947d029eb87661 schemas/message.schema.json +d7e0289ed5965163152f21101a93a0272b19fb1b749f3cb34f87f8c45ca2298b schemas/transport-descriptor.schema.json 9bc0262c63eb03ea8adf4eea273d213dc82ffa3c69b2fc838aca6772f785ced2 scripts/bootstrap-zephyr.sh edd88282a6903f32c585803a60c3c5bb076445d36fc76e511d9965ed57e20422 scripts/bootstrap-zephyr.test.mjs -327730ddb2cc1bc0265d6a15df0cd89bb94b154d1eb18d698cc57fa2be4cacfd scripts/check-naming.mjs -0ac9fbc4e0688525458164868e417919bc176fbae0c21181b3ea4b8711cc9a34 scripts/check-public-boundary.mjs +707046c2762b923fcc0add6465f23a504a93f48502c55c24155c7367fcbfe4ae scripts/check-naming.mjs +e3f347b6e98e6a1ca3f9f842a6c2f1245f0f6953680a685ecbdd42d17dd1e1ed scripts/check-public-boundary.mjs 8d7aad1d02044e2762835d765a6bad5fc41c454e15d75111f957db45bc72348c scripts/check-public-boundary.test.mjs -65094ffb7be92f7e2c39fb99be908515efd0ab0fa2ad8ff43cd599c330271488 scripts/documentation-contract.test.mjs -013437897df7e948137e9bfa27f1696d018553aa459ccf8fc32c3487455664e9 scripts/export-manifest.json -7050262f57951c99f890fdda5ef055809a09d70672aa69045e1c24934417030b scripts/export-nexting-devices.mjs -42f3f2d66a5bf8a5c723d00c94c98f4c37eb3da26be3162e5c0b8d04ca26b76d scripts/export-nexting-devices.test.mjs +845ae8827d9a2407f98ee52d6e02ce6eed1aeba09a0f43b34a2bec8815d45945 scripts/documentation-contract.test.mjs +e350ccd35592d0e55ac2e3e39a9aa5305ca37099bfcdb3d1904f53d06facd2fd scripts/export-manifest.json +280b891e860214201773fb7a08f1876d832a8cd468fc41ea092c9a090a795e53 scripts/export-nexting-devices.mjs +2a729b877501776678b02336e2f5d3ee218bfb571c68f855a0c91346ff746663 scripts/export-nexting-devices.test.mjs f44eaaeef69779a0b34fcd343cda3dab63cf20ba33e9961f05a8ddafff5a4bc9 scripts/public-workflows/nexting-devices-ci.yml 61a898bbca6629ab9e990fb8fca9b22928cf245b6bfab91f85c9c70b96307e0d scripts/public-workflows/nexting-devices-firmware.yml -52f65438c304b96e40cffab7b4bc85bb29adda221fe67dfd4a8be43801b15a62 scripts/simulator-contract.test.mjs -cd514db604b66c71a3838261b66f74082ecfe2ff20050d9be8691e957e7535f2 sdk/c/CMakeLists.txt -2633a2cf0ab1a73ffbb821f809913aeacfe876280507f08f793d342b91451057 sdk/c/include/nexting_device.h -254b4b25875f0a583b854916123593d154d5ef3e03cfbf531ac1a954e8fe0a6c sdk/c/README.md -6045f7e936ecda97e327708aacd52244f81acca7a4079c7f9fbe2364515bab08 sdk/c/src/nexting_device.c -fe112284db09e0d371b1c206a632bbe7bbfa1d5eb214b4cae65a1e64c09ed9ee sdk/c/tests/generate_interaction_vectors.mjs -d55675aee8a104b896aacb9def0a099ecead4216b63d0202e0e26bf12e693119 sdk/c/tests/generate_vectors.mjs +eb8e5e589e5d9dc7ee947937228e297c102460e92210f70aa40a6c49b7db79cc scripts/simulator-contract.test.mjs +b56a1324e661b7a52b8e6a306a3fc262acaa76b0ed31818cea1bc5f968827d40 sdk/c/CMakeLists.txt +b0beac88235235ecae4935b21604ba5d34c3a199bd395e7bad2d58d8e85cdcd9 sdk/c/include/nexting_device_audio.h +2eefcf30dee59aaa60c7c9fb4545158238a1b01a283e6effd6241dc1b7cdc322 sdk/c/include/nexting_device.h +1cd5057885879a5eadf05cacbbd63fbf4e97781476c19d8e3c5820eecce329e8 sdk/c/README.md +bb6c028c97c1bfb4655a21daa53cfd9633afc50caa4bd6968e4793ecf038f84d sdk/c/src/nexting_device_audio.c +94404d95311a9a2e05fa350a02e020374941dbb0335378097db2e859129aeb4b sdk/c/src/nexting_device.c +052425de73a4347293c72aad7e5aaf4a91409a99bf484adb076bdc58beb4f78f sdk/c/tests/generate_interaction_vectors.mjs +762bc8940658e05d8ed971f169e0b2a1f70996a177511d687952977ed53af456 sdk/c/tests/generate_vectors.mjs +f8f259be3a55c28069a4bc9a2c220cce4c5d4ab720fc2f4211a1ef81908c4767 sdk/c/tests/test_audio.c 25d1d7913e802c8bc97d946e6397a7ea2a9be9b62d41627b39e8fd675b6f1155 sdk/c/tests/test_codec.c b6b197c0a70209e0b366f2ff7fd8c39d239badff9dc531d5f93eb1cbbe514bc8 sdk/c/tests/test_device_info.c d51158c657c8ae15d5a985f8af7be781f7fffa661f3fb91b34a78937decf1d2f sdk/c/tests/test_interactions.c @@ -82,5 +127,7 @@ d51158c657c8ae15d5a985f8af7be781f7fffa661f3fb91b34a78937decf1d2f sdk/c/tests/te d38b6768087760080a69478e51557a313d06ab5d5f97dd1605a33e04a430a5e6 sdk/c/tests/test_status.c 8ac606b50e5a4a5413fa9d570583caf1bab29779b0d930c5a32705a461ea08af sdk/c/tests/test_stream.c 5237d7e35cf872b9348db413bed8a500c7868f368be5fe9e5ccc8e27faffb961 SECURITY.md -85982451b0fe5b20e7e1b00539fec7c8b54a06a1df563ef08d30063f4bdb15ed SPEC.md +1f06be6b13597ce4b7e7db2e56c5de9128d057d65ce4fa4b5848fca67569e399 simulator/device-audio-simulator.mjs +3ff4cb1b3a7927148c0bc051ae848dfab8ccae3943d27b6f3387f1721286b0c1 simulator/device-audio-simulator.test.mjs +e671fb721797d9920f4f466ca32b34c015a84a93abb6b03b89c495c909ed6116 SPEC.md 4196491d589dbc14ad631e03d48d6ee1d736e1a119960a0b0240f6e35c173f56 west.yml diff --git a/devices/SPEC.md b/devices/SPEC.md index 87dea8c..9feb292 100644 --- a/devices/SPEC.md +++ b/devices/SPEC.md @@ -1,8 +1,8 @@ -# Nexting Device Protocol — Experimental 0.2 +# Nexting Device Protocol — Experimental 0.4 This document is normative for wire major `1` and profiles `approval/1`, `status/1`, `navigation/1`, `keys/1`, `rotary/1`, `voice/1`, `text/1`, -`usage/1`, and `config/1`. +`usage/1`, `config/1`, and `device-audio/1`. ## Product contract @@ -14,17 +14,19 @@ Profile `approval/1` represents exactly two one-time actions: Allow maps to sour - Device: BLE peripheral and GATT server. - Host: Nexting App acting as BLE central and GATT client. -- Public transport: BLE only in Experimental 0.2. +- Public transport: BLE only in Experimental 0.4, through native Nexting GATT + or an audited Compatibility Tunnel v1 descriptor. - Public TCP, UDP, HTTP, MQTT, and cloud API ports: none. ## GATT service -| Role | UUID | Required properties | Direction | -| --- | --- | --- | --- | -| Service | `6EADC0DE-0001-4A21-9C5E-1B7F3D9E42A0` | Primary Service | — | -| Downlink | `6EADC0DE-0002-4A21-9C5E-1B7F3D9E42A0` | Write | Host → device | -| Uplink | `6EADC0DE-0003-4A21-9C5E-1B7F3D9E42A0` | Notify | Device → host | -| Device Info | `6EADC0DE-0004-4A21-9C5E-1B7F3D9E42A0` | Read | Device → host | +| Role | UUID | Required properties | Direction | +| ----------- | -------------------------------------- | ------------------- | ------------- | +| Service | `6EADC0DE-0001-4A21-9C5E-1B7F3D9E42A0` | Primary Service | — | +| Downlink | `6EADC0DE-0002-4A21-9C5E-1B7F3D9E42A0` | Write | Host → device | +| Uplink | `6EADC0DE-0003-4A21-9C5E-1B7F3D9E42A0` | Notify | Device → host | +| Device Info | `6EADC0DE-0004-4A21-9C5E-1B7F3D9E42A0` | Read | Device → host | +| Audio Uplink | `6EADC0DE-0005-4A21-9C5E-1B7F3D9E42A0` | Encrypted Notify | Device → host | Write Without Response is optional. A host uses Write With Response by default and may use the optional mode only when it implements bounded flow control. @@ -35,7 +37,47 @@ Approval traffic requires an encrypted, bonded BLE link. Device Info may be read The Device Info value is one compact UTF-8 JSON object: ```json -{"protocol":"nexting-device","spec":"0.2.0-experimental.2","wire":[1],"profiles":["approval/1","status/1","navigation/1","keys/1","rotary/1","voice/1","text/1","usage/1","config/1"],"model":"multi-pad","fw":"0.2.0","max_message_bytes":4096,"max_summary_bytes":240,"statusSlots":3,"device_id":"5cc0a66e-a204-4c33-a3ef-b2b352a35489","manufacturer":"ILX","display_name":"Desk Controller","serial_number":"MP-0007","button_count":12,"approval_button_count":2,"custom_button_count":10,"rotary_count":2,"rotary_press_count":2,"battery_service":true} +{ + "protocol": "nexting-device", + "spec": "0.4.0-experimental.1", + "wire": [1], + "profiles": [ + "approval/1", + "status/1", + "navigation/1", + "keys/1", + "rotary/1", + "voice/1", + "text/1", + "usage/1", + "config/1", + "device-audio/1" + ], + "model": "multi-pad", + "fw": "0.2.0", + "max_message_bytes": 4096, + "max_summary_bytes": 240, + "statusSlots": 3, + "device_id": "5cc0a66e-a204-4c33-a3ef-b2b352a35489", + "manufacturer": "ILX", + "display_name": "Desk Controller", + "serial_number": "MP-0007", + "button_count": 12, + "approval_button_count": 2, + "custom_button_count": 10, + "rotary_count": 2, + "rotary_press_count": 2, + "battery_service": true, + "audio": { + "source": "device_microphone", + "codecs": ["ima_adpcm"], + "sample_rates": [16000], + "channels": 1, + "frame_ms": [20], + "max_duration_ms": 120000, + "startup_buffer_ms": 200 + } +} ``` The required fields remain `protocol`, `spec`, `wire`, `profiles`, `model`, @@ -88,7 +130,12 @@ implies command-key, rotary-input, voice, text, or configuration profiles. DIY hardware may add one inert vendor section: ```json -{"vendor":{"namespace":"com.ilx.multipad","facts":[{"key":"layers","label":"Key layers","value":"4"}]}} +{ + "vendor": { + "namespace": "com.ilx.multipad", + "facts": [{ "key": "layers", "label": "Key layers", "value": "4" }] + } +} ``` The section is at most 1024 encoded bytes, contains a reverse-domain namespace @@ -112,7 +159,7 @@ Each logical message is one compact UTF-8 JSON object followed by a newline byte 7. never executes a partial message; 8. does not accept interleaved fragments from different logical messages. -The official JavaScript and portable C99 implementations cap a complete Experimental 0.2 frame at 4096 bytes even when Device Info advertises a larger capacity. The official Nexting App may negotiate a smaller ceiling. Transport integrations must feed bounded radio or socket chunks rather than an arbitrarily large aggregate input buffer. +The official JavaScript and portable C99 implementations cap a complete Experimental 0.4 control frame at 4096 bytes even when Device Info advertises a larger capacity. The official Nexting App may negotiate a smaller ceiling. Transport integrations must feed bounded radio chunks rather than an arbitrarily large aggregate input buffer. Downlink fragments are sent serially with Write With Response by default. Uplink answers use Notify and application-level retry: if a device does not receive `resolved` within one second, it may resend the exact same answer until resolution or local TTL expiry. It must not change the choice during retry. Answers are idempotent by request ID at the host. @@ -134,7 +181,14 @@ All TTL comparisons use elapsed monotonic time. Wall-clock changes, time-zone ch ### Present ```json -{"v":1,"t":"present","id":"3bb7","sum":"Allow git push?","opt":["allow","deny"],"ttl":30000} +{ + "v": 1, + "t": "present", + "id": "3bb7", + "sum": "Allow git push?", + "opt": ["allow", "deny"], + "ttl": 30000 +} ``` - `sum`: 0–240 UTF-8 bytes. @@ -148,7 +202,7 @@ A new present replaces the current request. The host resolves the old request as ### Answer ```json -{"v":1,"t":"answer","id":"3bb7","ch":"allow"} +{ "v": 1, "t": "answer", "id": "3bb7", "ch": "allow" } ``` `ch` is `allow` or `deny`. The device sends an answer only for its currently visible request and then waits for resolution. Repeated identical answers are allowed; the host keeps the first hardware choice locked and permits at most one authoritative action-sink attempt at a time. @@ -156,7 +210,7 @@ A new present replaces the current request. The host resolves the old request as ### Resolved ```json -{"v":1,"t":"resolved","id":"3bb7","r":"answered"} +{ "v": 1, "t": "resolved", "id": "3bb7", "r": "answered" } ``` `r` is one of: @@ -171,7 +225,7 @@ The device clears matching UI and cached retry state immediately. ### Error ```json -{"v":1,"t":"error","id":"3bb7","code":"unknown_request"} +{ "v": 1, "t": "error", "id": "3bb7", "code": "unknown_request" } ``` Error codes: @@ -191,7 +245,11 @@ An implementation may silently drop malformed or attacker-controlled input when Profile `status/1`. The host sends this message on the Downlink characteristic only to a device that declared `statusSlots` of at least `1` in Device Info: ```json -{"v":1,"t":"status","agents":[{"slot":0,"state":"thinking","label":"fix login bug"}]} +{ + "v": 1, + "t": "status", + "agents": [{ "slot": 0, "state": "thinking", "label": "fix login bug" }] +} ``` - `agents`: array of 0–8 entries. An empty array clears every slot. @@ -281,15 +339,73 @@ Profile `voice/1` controls a Host-owned microphone lifecycle: is 1–64 UTF-8 bytes with no control characters. `stop` and `cancel` require a currently accepted `start`. -This profile never carries audio bytes, transcripts, or credentials. A future -device-microphone transport requires a separate negotiated profile. +This profile never carries audio bytes, transcripts, or credentials. A +physical device microphone uses the separately negotiated `device-audio/1` +profile below. + +### Device microphone audio + +`device-audio/1` transports audio captured by a microphone on the physical +device. It does not change `voice/1`, and the Host never silently substitutes +its own microphone. The baseline capability is 16 kHz mono, 20 ms frames, +IMA ADPCM, a 120-second maximum, and a 200 ms buffer that begins only after a +physical voice-key input. + +The JSONL control messages are `audio_config` (Host prepares a receiver), +`audio_begin` (device reports a locally started stream), `audio_credit` +(cumulative acknowledgement plus credits), `audio_end`, `audio_cancel`, and +`audio_state`. Their exact canonical forms and rejection cases are in +`protocol/vectors/device-audio-v1.json`. `audio_config` cannot transition the +device from Ready to Capturing; only a local physical input can do that. + +Each 192-byte logical audio frame is encoded explicitly in little-endian order: + +| Offset | Bytes | Field | +| ---: | ---: | --- | +| 0 | 1 | wire version `1` | +| 1 | 1 | packet type `1` | +| 2 | 1 | codec `1` (IMA ADPCM) | +| 3 | 1 | reserved, zero | +| 4 | 4 | connection epoch | +| 8 | 4 | stream ID | +| 12 | 4 | frame sequence | +| 16 | 4 | starting sample index | +| 20 | 2 | sample count `320` | +| 22 | 2 | signed predictor (sample 0) | +| 24 | 1 | initial step index `0...88` | +| 25 | 1 | reserved, zero | +| 26 | 2 | payload length `160` | +| 28 | 160 | codes for samples 1...319, low nibble first | +| 188 | 4 | CRC32C/Castagnoli over bytes 0...187 | + +The unused high nibble of the final payload byte is zero. Each frame is +independently decodable. Sequence starts at zero, is at most 5999, and the +starting sample index must equal `sequence × 320`. `audio_end.sample_count` +must equal `(last_seq + 1) × 320`; inconsistent metadata is rejected. A +fragment uses a 12-byte header (version `1`, type +`2`, fragment index/count, stream ID, sequence) and carries a contiguous frame +slice. Frames never interleave and never exceed four fragments. + +The Host grants eight initial frame credits and replenishes credit only after a +complete frame is consumed. The device retains at most ten queued frames and +cancels after 500 ms without restored credit. One missing frame becomes equal +duration silence; three consecutive or ten total missing frames, malformed +metadata, an invalid CRC, or inconsistent fragments terminate and erase the +stream. Disconnect, Reject, cancellation, timeout, and failure are idempotent +and clear volatile audio. See `docs/device-audio.md` for the complete lifecycle. ### Text Profile `text/1` replaces one plain-text display channel: ```json -{"v":1,"t":"text","channel":0,"title":"Current task","content":"Waiting for approval"} +{ + "v": 1, + "t": "text", + "channel": 0, + "title": "Current task", + "content": "Waiting for approval" +} ``` `channel` is 0–7. Optional `title` is 1–64 UTF-8 bytes with no control diff --git a/devices/docs/README.md b/devices/docs/README.md index 79f0efe..ff81017 100644 --- a/devices/docs/README.md +++ b/devices/docs/README.md @@ -15,6 +15,8 @@ proof. If a checkpoint fails, use [Troubleshooting](troubleshooting.md). - [Foundation development blueprint](foundation-development.md): lifecycle, modules, dependency direction, exact files, change map, and the current capability set. - [Public interface catalog](interfaces.md): BLE, wire messages, and the portable C device API. - [Protocol specification](../SPEC.md): normative wire, BLE, state, limits, and versioning. +- [Device audio](device-audio.md): physical-device microphone lifecycle, wire, flow control, and privacy. +- [Compatibility Tunnel](compatibility-tunnel.md): constrained data-only adapters for existing BLE services. - [Security model](../SECURITY.md): trust boundary, minimum controls, threats, and non-claims. ## Run a working example @@ -33,6 +35,10 @@ proof. If a checkpoint fails, use [Troubleshooting](troubleshooting.md). - [Portable C99 SDK](../sdk/c/README.md): fixed-buffer device codec, stream, and state. - [JavaScript reference](../reference/js/README.md): readable protocol, framing, and relay behavior. - [Zephyr reference firmware](../firmware/zephyr/README.md): shared Nordic and Espressif adapter. +- [MultiPad USB CDC guide](multipad-usb.md): open-source STM32 adapter, board variant check, and fail-closed flash preparation. +- [First case: ILX MultiPad](cases/multipad-first-case.md): reproduce the upstream build, add the public adapter, map the first two keys, and verify the flash path. +- [AhaKey X1 case](cases/ahakey-x1-case.md): integrate a shipping BLE vibecoding keyboard natively or through a bridge dongle, mapping its keys, lever, LED, and OLED. +- [AhaKey X1 integration kit](../firmware/ahakey-x1/README.md): portable C99 codec for the published AhaKey protocol plus the Nexting mapping adapter and Device Info template. - [Port a chip](porting-guide.md): platform contract and adapter rules. ## Verify and make claims diff --git a/devices/docs/availability.json b/devices/docs/availability.json index 8c27662..ffe2b58 100644 --- a/devices/docs/availability.json +++ b/devices/docs/availability.json @@ -1,8 +1,8 @@ { "schemaVersion": 1, - "sdkVersion": "0.2.0-experimental.2", + "sdkVersion": "0.4.0-experimental.1", "wireMajor": 1, - "checkedAt": "2026-07-31", + "checkedAt": "2026-09-07", "developerEnrollment": { "ios": { "available": false, diff --git a/devices/docs/board-verification.md b/devices/docs/board-verification.md index 93ba932..3d7f41e 100644 --- a/devices/docs/board-verification.md +++ b/devices/docs/board-verification.md @@ -13,40 +13,40 @@ Use this checklist for each exact board, firmware commit, and App version. Attac ## Required behavior -| Case | Expected result | Pass / evidence | -| --- | --- | --- | -| Device Info | All required fields are readable; wire/profile/bounds match the binary. | | -| Optional Device Info | Every advertised button/rotary/display/haptics/battery capability matches the physical unit; absent capabilities create no empty App rows. | | -| Vendor facts | Bounded inert facts render only in the vendor section and cannot replace system-owned rows or create actions. | | -| Account metadata | Name, optional user number, and notes edit and sync without changing immutable hardware identity. | | -| Cross-platform table | iOS and Android show the same ordered continuous key/value information table for the same Device Info. | | -| Encryption | Plain approval write and unencrypted notification subscription are rejected or trigger pairing. | | -| Bond required | A peer that requests an encrypted but non-bonding session is rejected; a completed bond survives reconnect and board reboot. | | -| Pairing failure recovery | A rejected or failed security upgrade disconnects that peer, then the board becomes discoverable again without rebooting. | | -| Unauthorized device | Release App does not connect or disclose a summary before explicit authorization. | | -| Present | One current summary appears and Pending output turns on. | | -| Device Allow | One Allow wins, App continues, matching `answered` clears the device. | | -| Device Deny | One Deny wins, App continues, matching `answered` clears the device. | | -| Phone first | Phone choice wins; device clears; late button press is ignored. | | -| Device first | Device choice wins; later phone tap cannot consume the prompt again. | | -| Repeat | Holding/bouncing the button never changes or double-consumes the first choice. | | -| Retry | Dropping the first notification causes the exact same answer to retry after one second. | | -| Answer fragmentation | At a small negotiated ATT MTU, a maximum-ID Answer arrives as one ordered newline frame with no truncation, duplication, or interleaving. | | -| Replacement | New Present replaces old state; old ID cannot answer. | | -| Expiry | At the TTL boundary, LED clears and a press cannot answer. | | -| Cancellation | Host cancellation clears Pending immediately. | | -| Disconnect | Connection loss clears current request, answer cache, LED, and partial frame. | | -| Reconnect | Device restores no approval; host may re-present with only remaining TTL. | | -| Reboot | Bond may persist; approval and partial frame never persist. | | -| Fragmentation | A valid message split through a multi-byte UTF-8 character decodes once. | | -| Oversize | Input above the advertised limit is discarded through newline and recovery works. | | -| Malformed input | Invalid UTF-8, JSON, version, profile, option, and ID fail closed. | | -| Short reset chord | Pressing both buttons for less than three seconds neither answers nor clears bonds. | | -| Local bond reset | Holding both buttons for three seconds clears volatile state and all board bonds, disconnects the phone, shows the one-second LED confirmation, and resumes advertising. | | -| Bond-reset failure | Force or instrument `bt_unpair` failure; the board logs the failure, does not show the success LED, and keeps connectable advertising stopped. | | -| Fresh pairing after reset | The old phone bond cannot silently resume; after forgetting it phone-side, a new encrypted bond succeeds. | | -| App revocation | Removing App authorization stops future summaries and answers independently of the board's local bond reset. | | -| Agent isolation | A Claude Code answer can reach only Claude Code; a Codex answer can reach only Codex; both race with phone input through one single-consumption gate. | | +| Case | Expected result | Pass / evidence | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- | +| Device Info | All required fields are readable; wire/profile/bounds match the binary. | | +| Optional Device Info | Every advertised button/rotary/display/haptics/battery capability matches the physical unit; absent capabilities create no empty App rows. | | +| Vendor facts | Bounded inert facts render only in the vendor section and cannot replace system-owned rows or create actions. | | +| Account metadata | Name, optional user number, and notes edit and sync without changing immutable hardware identity. | | +| Cross-platform table | iOS and Android show the same ordered continuous key/value information table for the same Device Info. | | +| Encryption | Plain approval write and unencrypted notification subscription are rejected or trigger pairing. | | +| Bond required | A peer that requests an encrypted but non-bonding session is rejected; a completed bond survives reconnect and board reboot. | | +| Pairing failure recovery | A rejected or failed security upgrade disconnects that peer, then the board becomes discoverable again without rebooting. | | +| Unauthorized device | Release App does not connect or disclose a summary before explicit authorization. | | +| Present | One current summary appears and Pending output turns on. | | +| Device Allow | One Allow wins, App continues, matching `answered` clears the device. | | +| Device Deny | One Deny wins, App continues, matching `answered` clears the device. | | +| Phone first | Phone choice wins; device clears; late button press is ignored. | | +| Device first | Device choice wins; later phone tap cannot consume the prompt again. | | +| Repeat | Holding/bouncing the button never changes or double-consumes the first choice. | | +| Retry | Dropping the first notification causes the exact same answer to retry after one second. | | +| Answer fragmentation | At a small negotiated ATT MTU, a maximum-ID Answer arrives as one ordered newline frame with no truncation, duplication, or interleaving. | | +| Replacement | New Present replaces old state; old ID cannot answer. | | +| Expiry | At the TTL boundary, LED clears and a press cannot answer. | | +| Cancellation | Host cancellation clears Pending immediately. | | +| Disconnect | Connection loss clears current request, answer cache, LED, and partial frame. | | +| Reconnect | Device restores no approval; host may re-present with only remaining TTL. | | +| Reboot | Bond may persist; approval and partial frame never persist. | | +| Fragmentation | A valid message split through a multi-byte UTF-8 character decodes once. | | +| Oversize | Input above the advertised limit is discarded through newline and recovery works. | | +| Malformed input | Invalid UTF-8, JSON, version, profile, option, and ID fail closed. | | +| Short reset chord | Pressing both buttons for less than three seconds neither answers nor clears bonds. | | +| Local bond reset | Holding both buttons for three seconds clears volatile state and all board bonds, disconnects the phone, shows the one-second LED confirmation, and resumes advertising. | | +| Bond-reset failure | Force or instrument `bt_unpair` failure; the board logs the failure, does not show the success LED, and keeps connectable advertising stopped. | | +| Fresh pairing after reset | The old phone bond cannot silently resume; after forgetting it phone-side, a new encrypted bond succeeds. | | +| App revocation | Removing App authorization stops future summaries and answers independently of the board's local bond reset. | | +| Agent isolation | A Claude Code answer can reach only Claude Code; a Codex answer can reach only Codex; both race with phone input through one single-consumption gate. | | ## Label decision diff --git a/devices/docs/cases/ahakey-x1-case.md b/devices/docs/cases/ahakey-x1-case.md new file mode 100644 index 0000000..d673cd3 --- /dev/null +++ b/devices/docs/cases/ahakey-x1-case.md @@ -0,0 +1,178 @@ +# AhaKey X1 case: a shipping vibecoding keyboard on Nexting Devices + +**Summary.** The AhaKey X1 is a commercially shipping BLE console for +coding Agents — four keys, a physical approval lever, an LED strip, and an +OLED. This case integrates it with Nexting Devices in two modes: a native +firmware port for the AhaKey team, and a bridge dongle that works with any +stock X1 without a firmware change. The keyboard then pairs with the +Nexting App and reaches Claude Code and Codex sessions through the same +remote path as every other Nexting device — including sessions running on a +computer somewhere else. + +| Layer | Deliverable | Evidence level | +| ------------------- | ---------------------------------------------------------------------- | -------------------- | +| Upstream protocol | AhakeyAI/desktop `docs/ble-protocol.md`, pinned to `c1663032` | Source auditable | +| AhaKey codec | `firmware/ahakey-x1/ahakey_proto.c/.h` | Core tested | +| Nexting mapping | `firmware/ahakey-x1/nexting_ahakey_adapter.c/.h` | Core tested | +| Device audio core | `firmware/ahakey-x1/nexting_ahakey_audio.c/.h` | Core tested | +| Compatibility port | `firmware/ahakey-x1/developer-kit/nexting_ahakey_port.c/.h` | Core tested | +| App descriptor | `firmware/ahakey-x1/transport-descriptor.nexting-device.json` | Schema/vector tested | +| Device Info | `firmware/ahakey-x1/nexting-ahakey-device-info.template.json` | Template | +| CH582 microphone hook | AhaKey production source + physical unit | Pending board bring-up | +| Host boundary | Host owns authorization, Agent routing, and the final answer action | Public contract | + +# 案例:AhaKey X1 接入 Nexting Devices + +AhaKey X1 是一把已经在售的 BLE vibecoding 键盘:4 个按键、一个物理审批 +拨杆、一条 LED 灯带和一块 OLED。这个案例的目标不是重做它,而是保留原 +厂全部能力,只增加一层可验证的 Nexting 设备协议映射。接入之后,键盘 +配对 Nexting App,经同一条远程路径触达 Claude Code 与 Codex 会话—— +包括运行在其他电脑上的会话。键盘不再只能坐在 Mac 旁边用。 + +## 上游工程与版本 + +- 桌面客户端与协议文档:[AhakeyAI/desktop](https://github.com/AhakeyAI/desktop), + Apache-2.0,协议文档 `docs/ble-protocol.md` pin 在 commit + `c16630329cd0a53fcbcdd4d528015257a19449f6`。 +- 上游协议已公开证明的事实:GATT 服务 `0x7340`(命令 `0x7343`、通知 + `0x7344`、大数据 `0x7341`)、帧格式 `AA BB cmd data CC DD`、标准 + Battery Service `0x180F`、IDE 状态同步命令 `0x90`(9 个 ClaudeState)、 + 状态查询命令 `0x00`(含电量与拨杆 SwitchState)、OLED 160×80 RGB565、 + 按键为可配置 HID 输出。 +- AhaKey 官方 firmware 仓库当前公开的是 v1.1.0 HEX、CH582 烧录说明和发布 + 记录,没有公开可构建的量产源码、原理图或 PCM callback。产品方确认设备 + 具有录音能力,因此本案例实现完整的设备音频接口和状态机;最后一个 + ADC/I2S/PDM 符号只能在拿到量产源码与实物后绑定,现阶段不虚构接口名, + 也不宣称 Board verified。 + +## 能力映射 + +| X1 硬件 | Nexting 公共接口 | 案例行为 | +| --------------------- | --------------------- | ------------------------------------------ | +| 配置为 F18 的按键 | `approval/1` → Allow | 回答当前待决请求 | +| 配置为 F19 的按键 | `approval/1` → Deny | 回答当前待决请求 | +| 配置为 F13–F16 的按键 | `keys/1` 槽位 0–3 | keymap 启用后发带递增序列号的 `key_event` | +| 物理拨杆 | `keys/1` 槽位 7 | keymap 启用后:离开静止位 press,回位 release | +| LED 灯带 | `status/1` | Agent 状态 → AhaKey IDE 状态(`0x90`) | +| OLED 160×80 | `text/1` | 标题 + 正文 | +| Battery `0x180F` | Device Info | `battery_service: true` | +| 设备本身麦克风 | `device-audio/1` | PCM16/16 kHz → IMA ADPCM → BLE → App | +| 待决审批(任意来源) | LED 覆盖 | 待决期间强制 permission-request 状态 | + +待决审批会用等待授权灯效覆盖灯带(审批通道与状态通道共用一条灯带,审 +批优先)。其余时候灯带按 status/1 规则渲染编号最小的已占用槽位:活 +动中的 Agent 显示工具执行,needs-input 显示等待授权,完成的 Agent +显示任务完成,错误显示通知告警,更高编号的槽位忽略,没有占用则熄 +灭。按键与拨杆只在 Host keymap 声明并启用对应槽位后才发事件。拨杆 +的语义保持在设备侧中立:它只报告物理位置,授权策略与自动批准的最 +终决定权永远在 Host。 + +## 模式 A:Compatibility Tunnel 固件(推荐) + +这条路径不要求 Nexting App 内置 AhaKey 解析器。App 从资源目录加载经过审 +计的 JSON 描述符;AhaKey 的 UUID 与 opcode 只存在于描述符和本适配器。 + +在原有 `0x7343` write / `0x7344` notify 上增加四个直接前缀:A0 控制 +下行、A1 控制上行、A2 音频上行、A3 流控下行。原厂 `AA BB ... CC DD` +配置帧原样交回原解析器,二者可共存。详细接线与入口见 +[`developer-kit/README.md`](../../firmware/ahakey-x1/developer-kit/README.md)。 + +必须满足:bond + encryption、ATT payload 至少 64 字节、订阅后主动发 +送带稳定 `device_id` 的 Device Info、生产 PCM hook 成功后才能声明 +`device-audio/1`。Host 的 `audio_config` 只能准备缓冲区,绝不能启动麦 +克风;只有实体语音键调用 `nexting_ahakey_port_voice_pressed()`。 + +## 模式 B:原生 Nexting GATT(可选) + +把下面的公开文件编入 X1 固件,不要修改公共 SDK 的语义: + +```text +devices/sdk/c/include/nexting_device.h +devices/sdk/c/src/nexting_device.c +devices/sdk/c/include/nexting_device_audio.h +devices/sdk/c/src/nexting_device_audio.c +devices/firmware/ahakey-x1/ahakey_proto.h +devices/firmware/ahakey-x1/ahakey_proto.c +devices/firmware/ahakey-x1/nexting_ahakey_adapter.h +devices/firmware/ahakey-x1/nexting_ahakey_adapter.c +devices/firmware/ahakey-x1/nexting_ahakey_audio.h +devices/firmware/ahakey-x1/nexting_ahakey_audio.c +``` + +1. 广播 Nexting 主服务 `6EADC0DE-0001-4A21-9C5E-1B7F3D9E42A0`,保 + 留原厂 `0x7340` 服务——两者互不冲突,原厂配置工具照常可用。 +2. 下行特征(`6EADC0DE-0002-…`)的写入喂给 + `nexting_ahakey_receive()`;`write_frame` 回调的内容通过上行特征 + (`6EADC0DE-0003-…`)notify 发出。 +3. Device Info 特征(`6EADC0DE-0004-…`)从 + `nexting-ahakey-device-info.template.json` 出参,`fw` 与 + `device_id` 按实机填写。 +4. 渲染回调直接驱动硬件:`render_led` 写灯带、`render_text` 画 + OLED、`render_approval` 显示待决摘要。这个模式里不经过 `0x7340` + 帧。 +5. 按键中断调用 `nexting_ahakey_choose()` 或 + `nexting_ahakey_hid_key()`;拨杆边沿调用 + `nexting_ahakey_lever()`。 +6. 适配器不接触 Agent 凭证、账号或云端地址;授权与最终审批动作始终 + 在 Host 侧。不要把任何 Claude Code、Codex 或云配置写进固件。 + +## 模式 C:桥接 dongle(原厂固件零改动) + +一块 BLE 小 dongle(XIAO nRF52840 参考板即可)坐在键盘和手机之间: + +```text +AhaKey X1 ──BLE──> 桥接 dongle ──BLE──> Nexting App ──> 你的 Agent + (HID + 0x7340) (本套件) (Nexting 服务) +``` + +1. 先用原厂工具配置一次键位:Key1 = F18、Key2 = F19、Key3–Key4 = + F13–F14,并保存到 Flash。 +2. dongle 对 X1 做 central:订阅 HID 输入报告收按键、周期发送状态查 + 询(`0x00`)轮询拨杆、用 `ahakey_proto_build_update_state()` 写 + `0x90` 驱动 LED、用键描述帧(`0x73/0x75`)写 OLED 短文本。 +3. dongle 对手机做 peripheral:运行 Nexting 服务,Device Info 声明 + 使用本目录模板,`model` 保持 `ahakey-x1` 以便 App 识别形态。 +4. 完整 RGB565 图片通道(`0x80–0x83`)已在 `ahakey_proto` 的通用帧 + 构建器覆盖,图片排版属于桥接固件的板级实现,不属于协议核。 + +桥接模式默认不声明 `device-audio/1`。只有原厂 stock 固件把键盘真实 +PCM 流开放给 dongle,并且通过同一套音频测试后,才可开启该能力;不允 +许用手机麦克风伪装成 AhaKey 录音。 + +## 一次只验证一个行为 + +1. `npm run test:ahakey` 通过,包括上游文档公布的抓包帧。 +2. 桥接或原生固件广播后,App 读到 Device Info,能力与本模板一致。 +3. 发一个 `present`,确认 LED 进入等待授权、屏幕只显示当前请求。 +4. 按 Allow 键,确认收到同一个 `id` 的 `answer`;Host 成功后发送 + `resolved`,设备清掉待决状态。 +5. 确认 Host 的 keymap 声明槽位 0–3 与 7 后,拨动拨杆:槽位 7 的 + press/release 各到达一次;静止不动时无重复事件;未声明的槽位不发 + 任何事件。 +6. 单独测试超时与 BLE 断连:两者都必须清空审批与状态渲染,LED 熄 + 灭,不能留下旧提示。 +7. 用逻辑分析/抓包确认音频来自设备麦克风:实体键前没有采样,实体键 + 后为 16 kHz mono PCM,每 320 样本编码一帧,A2 上行;拔掉/禁用手机 + 麦克风权限仍能录音。 +8. 人为停止 Host credit,确认 RAM 队列不超过 10 帧,500 ms 内取消; + 断连、拒绝、硬件错误和两分钟上限均停止麦克风并擦除 DMA/队列。 + +在没有这些逐项证据前,只能写「适配器与编解码 Core tested」,不能写 +「X1 已 Board verified」或「桥接模式已量产」。措辞边界见 +[conformance.md](../conformance.md)。 + +## 这个案例与 Nexting App 的关系 + +与所有第三方设备相同:X1(任一模式)必须在 App 内显式授权、可撤 +销、标记为第三方设备,不能冒充 Nexting 生产硬件。公开 App 的第三方 +开发者设备注册进度以 `docs/availability.json` 为准;注册未开放前, +验证走开发构建,不把内部进度写成公开承诺。 + +## 完成定义 + +- 上游协议文档固定到具体 commit; +- `npm run test:ahakey` 与全仓 `npm run check` 通过; +- 目标集成路径完成上面全部逐项验证并有记录; +- Device Info 只声明实际存在的能力(PCM hook 未验证时不声明 + `device-audio/1`;`voice/1` 仍仅表示 Host 手机/电脑麦克风); +- 公开表述遵守 conformance 证据等级,不把 Core tested 写成整机兼容。 diff --git a/devices/docs/cases/multipad-first-case.md b/devices/docs/cases/multipad-first-case.md new file mode 100644 index 0000000..417600d --- /dev/null +++ b/devices/docs/cases/multipad-first-case.md @@ -0,0 +1,147 @@ +# 第一个案例:ILX MultiPad 接入 Nexting Devices + +ILX MultiPad 是 Nexting Devices 的第一个完整硬件案例。它不是一块 +Nexting 量产板,而是一块已经开源、已经能作为键盘使用的 STM32F103VET6 +USB HID + CDC 设备。这个案例的目标是说明:如何保留原作者的键盘功能, +只增加一层可验证的 Nexting 设备协议适配。 + +## 这个案例到底交付什么 + +| 层 | 案例交付物 | 证据等级 | +| -------------- | -------------------------------------------------------------------- | ------------------ | +| 上游固件 | `iLx11/multi-pad`,固定到 `78c1ee533a7f513e9f390741c4f5eed1e0aa91b3` | 源码可审计 | +| Nexting 设备核 | `sdk/c` 的固定缓冲 C99 协议、流式拼帧和审批状态机 | Core tested | +| MultiPad 适配 | `firmware/multipad/nexting_multipad_adapter.c/.h` | CMake 合约测试通过 | +| 兼容入口 | 原有 `AA BB xx` 保留,只有换行 JSON 帧进入 Nexting 适配器 | 主机测试通过 | +| 烧录路径 | Module 版走串口路径(仅当看到 SERIAL/BOOT/RESET);FPC 版走 SWD | 由拆机证据决定 | +| Host 边界 | Host 负责授权、Agent 路由和最终回答;固件不保存凭证 | 公开契约 | + +当前公开 SDK 不提供一个可以误称为“已经适配所有版本”的 HEX。上游工程中 +的 `leden.hex` 是原版固件(原作者固件);接入 Nexting 适配器后必须重新构建并单独保存 +新的 HEX。不能把原固件误当作 Nexting 固件烧进去。 + +## 上游工程与版本 + +使用下面两个公开仓库,不要把上位机仓库当成下位机固件: + +- 下位机固件:[iLx11/multi-pad](https://github.com/iLx11/multi-pad) +- 上位机:[iLx11/key-pad-application](https://github.com/iLx11/key-pad-application) + +本案例只接入下位机仓库。上游工程本身已经证明了这些事实: + +- `STM32F103VET6`、Cortex-M3; +- USB HID + USB CDC composite; +- 2 × 4 矩阵按键和 3 个旋钮模块; +- `PA13 = SWDIO`、`PA14 = SWCLK`; +- `PA9/PA10 = USART1_TX/RX`; +- 工程没有 DFU、IAP 或应用侧 USB bootloader; +- 上游许可证为 GPL-3.0。 + +烧录配置在上游的 `Config/stlink.cfg`,不是 Nexting App 的配置。普通 Type-C +枚举出来的 CDC 端口只能证明应用正在运行,不能证明它已经进入 bootloader。 +Type-C 不能直接烧录,除非先确认对应的 bootloader 路径。 + +## 第一个可运行映射 + +这个案例只承诺一个小而明确的映射,不把未来能力提前写成已支持: + +| MultiPad 控件 | Nexting 公共接口 | 案例行为 | +| ----------------- | ---------------------------------- | ----------------------------------------------- | +| 第一个矩阵键 | `approval/1` → Allow | 批准当前唯一待决请求 | +| 第二个矩阵键 | `approval/1` → Deny | 拒绝当前唯一待决请求 | +| 现有 CDC 接收回调 | `nexting_multipad_receive()` | 接收 `present`、`resolved`、`status` | +| 现有 CDC 发送函数 | `nexting_multipad_write_frame()` | 回传 `answer` | +| 板上屏幕/指示器 | `render_approval`、`render_status` | 只渲染回调收到的状态 | +| 其他按键和旋钮 | 保留原厂行为 | 等 `keys/1`、`rotary/1` 等 profile 发布后再映射 | + +适配器不会解析 Agent 会话,也不会把 Claude Code、Codex、账户或云地址写入 +设备。显示摘要、倒计时和状态的具体排版属于板级回调,不属于协议核。 + +## 从上游源码到案例固件 + +### 1. 固定上游源码并先构建原版 + +```sh +git clone https://github.com/iLx11/multi-pad.git +cd multi-pad +git checkout 78c1ee533a7f513e9f390741c4f5eed1e0aa91b3 + +# 上游工程要求 arm-none-eabi-gcc、CMake 和 STM32 工具链 +cmake -S . -B build +cmake --build build +sha256sum build/leden.hex +``` + +先保存这个原版产物和哈希。它只用于恢复原厂功能,不能标记为 Nexting 固件。 + +### 2. 加入公开 Nexting 源码 + +从 `Nexting-ai/nexting/devices/` 取下面的公开文件,复制到上游工程的独立 +目录(例如 `USER/Nexting/`),不要修改公共 SDK 的语义: + +```text +devices/sdk/c/include/nexting_device.h +devices/sdk/c/src/nexting_device.c +devices/firmware/multipad/nexting_multipad_adapter.h +devices/firmware/multipad/nexting_multipad_adapter.c +``` + +把这四个文件加入上游 CMake target,并把 `sdk/c/include` 加入 include path。 +上游 `USER/Usb/usb_user.c` 的 CDC 回调先判断 Nexting 帧,再落回原有分支: + +```c +if (nexting_multipad_accepts(&nexting_adapter, Buf, *Len)) { + (void)nexting_multipad_receive(&nexting_adapter, Buf, *Len); + return USBD_OK; +} + +/* 原有 AA BB CC / AA BB AA / AA BB DD / AA BB EE / AA BB FF 分支继续保留。 */ +``` + +初始化时提供四个板级回调:单调时钟、CDC 发送、审批渲染、状态渲染。主循环 +调用 `nexting_multipad_tick()`;USB 断连调用 +`nexting_multipad_disconnect()`;前两个审批键分别调用 +`nexting_multipad_choose(...ALLOW)` 和 +`nexting_multipad_choose(...DENY)`。回调失败时不要伪造成功 UI。 + +### 3. 构建、备份、烧录 + +重新构建后,对新的 `leden.hex` 计算哈希,并把原版备份、Nexting 版本和日志 +放在不同文件名下。根据硬件版本选择路径: + +| 版本 | 写入方式 | 关键条件 | +| ---------- | -------------------------- | ------------------------------------------ | +| Module PCB | 上游串口路径(待实板确认) | 必须看到 SERIAL、BOOT、RESET 路径 | +| FPC PCB | ST-Link/J-Link SWD | `3V3`、`GND`、`SWDIO(PA13)`、`SWCLK(PA14)` | + +在确认 bootloader 之后,才可以使用本目录的 +`firmware/multipad/tools/flash-multipad.sh`。脚本要求显式的端口、HEX、 +`--confirm --allow-write`,且不会覆盖原始备份。 + +### 4. 一次只验证一个行为 + +1. 先用 `multipad-cdc-smoke.py` 验证原有 `AA BB CC` 回显。 +2. 发送一个 `present`,确认屏幕或指示器只显示当前请求。 +3. 按 Allow,确认收到同一个 `id` 的 `answer`。 +4. 确认 Host 成功后发送 `resolved`,设备清掉待决状态。 +5. 单独测试超时和 USB 断连;两者都必须清空审批和状态,不能留下旧提示。 + +在没有这些逐项证据前,只能写“适配器测试通过”,不能写“MultiPad 真板已 +兼容”或“Type-C 可直接烧录”。 + +## 这个案例与 Nexting App 的关系 + +MultiPad USB 是公开的开发者案例,不是现有 BLE App 自动支持的 USB 变体。 +Host 必须自己拥有 USB 权限、端口选择、授权状态和 Agent 最终回答操作; +设备只接收有界的公共消息。未来如果 Nexting App 开放 USB transport,需要 +另行发布 Host 支持、授权测试和断连策略,不能由这份固件文档推断出来。 + +## 完成定义 + +- 上游工程能在固定 commit 上复现构建; +- 原版 HEX 已备份并有哈希; +- Nexting HEX 由明确的适配源码生成并有哈希; +- `npm run test:multipad`、C99 合约测试和 CDC smoke 通过; +- `present → answer → resolved`、超时、断连分别有记录; +- `Device Info` 只声明实际存在的按键、旋钮、显示和电量能力; +- GitHub README 只链接本案例,不把实验性 USB 路径写成量产承诺。 diff --git a/devices/docs/compatibility-tunnel.md b/devices/docs/compatibility-tunnel.md new file mode 100644 index 0000000..da8d212 --- /dev/null +++ b/devices/docs/compatibility-tunnel.md @@ -0,0 +1,63 @@ +# Nexting Compatibility Tunnel v1 + +Native Nexting GATT is the preferred production transport. Compatibility +Tunnel v1 exists for hardware whose supported firmware API can register bounded +commands inside an existing encrypted BLE service but cannot add the native +Nexting service. + +The tunnel changes only the envelope. Device Info, JSONL control messages, +binary audio frames, authorization, sequence, credit, and cancellation rules +remain the standard Nexting contracts. + +## Data-only descriptor + +The official App loads audited descriptors matching +[`transport-descriptor.schema.json`](../schemas/transport-descriptor.schema.json). +A descriptor can declare only: + +- an integration ID and display name; +- one discovery service UUID; +- write/notify characteristic UUIDs; +- the fixed `opcode-prefix-v1` envelope; +- four distinct opcodes for `control_down`, `control_up`, `audio_up`, and + `flow_down`; +- bounded payload and ATT requirements; +- mandatory bonding and encryption. + +It cannot provide code, a parser, a URL, a cloud route, an Agent action, a +script, credentials, or arbitrary Bluetooth behavior. The only envelope is one +opcode byte followed by a bounded unmodified Nexting payload. + +## Trust and selection + +A descriptor match only permits a Nexting handshake; it does not authorize the +device. Enrollment is explicit and revocable. The handshake must return strict +Device Info with a stable `device_id` and the relevant profile. + +If the same stable device exposes native GATT and a tunnel, the Host represents +one device and activates native GATT only. It falls back to an already enrolled +tunnel after native failure; it never opens parallel approval or audio streams. + +Scanning a vendor service occurs only while the user is adding a third-party +device or reconnecting the currently enrolled device. Removing a descriptor +must leave native devices, the App core, and every generic conformance test +working. + +## Implement and verify + +Start from the vendor-neutral vector in +[`compatibility-tunnel-v1.json`](../protocol/vectors/compatibility-tunnel-v1.json). +Keep vendor UUIDs and opcodes in the descriptor and device adapter, never in +App core, Agent routing, accounts, or cloud code. + +```js +import { + encodeTunnelPacket, + parseTransportDescriptor, + parseTunnelPacket, +} from "@nexting-ai/device-reference/tunnel"; +``` + +The reference parser additionally rejects duplicate opcodes, wrong +characteristic direction, an ATT payload below 64 bytes, unknown fields, and +unknown strategies. diff --git a/devices/docs/conformance.md b/devices/docs/conformance.md index 9dd5449..06190e9 100644 --- a/devices/docs/conformance.md +++ b/devices/docs/conformance.md @@ -4,12 +4,12 @@ Compatibility is a product claim backed by a specific kind of evidence. Passing ## Evidence ladder -| Level | What it proves | What it does not prove | -| --- | --- | --- | -| **Protocol conformant** | An implementation matches shared valid/invalid vectors, framing limits, enums, and state semantics | A compiler target, radio, physical controls, or product security | -| **Core tested** | The portable implementation passes codec, stream, and approval-state tests under its supported runtime | BLE integration or a named board | -| **Build verified** | A pinned toolchain produced firmware for one exact target and retained an artifact | Pairing, delivery, buttons, LEDs, race handling, or revocation | -| **Board verified** | A named physical board and firmware commit completed the dated real-iPhone checklist | Certification of a different board, firmware, App, or production security design | +| Level | What it proves | What it does not prove | +| ----------------------- | ------------------------------------------------------------------------------------------------------ | -------------------------------------------------------------------------------- | +| **Protocol conformant** | An implementation matches shared valid/invalid vectors, framing limits, enums, and state semantics | A compiler target, radio, physical controls, or product security | +| **Core tested** | The portable implementation passes codec, stream, and approval-state tests under its supported runtime | BLE integration or a named board | +| **Build verified** | A pinned toolchain produced firmware for one exact target and retained an artifact | Pairing, delivery, buttons, LEDs, race handling, or revocation | +| **Board verified** | A named physical board and firmware commit completed the dated real-iPhone checklist | Certification of a different board, firmware, App, or production security design | Build verified is not Board verified. Board verified is not Nexting Compatible. @@ -66,11 +66,11 @@ The checklist covers encrypted traffic, enrollment, authorization, Allow/Deny, p ## Allowed wording -| Evidence | Allowed public wording | -| --- | --- | -| Protocol only | “Implements Nexting Device Protocol Experimental 0.2” with the tested version | -| Core tested | “Core tested against the Experimental 0.2 vectors” | -| Build verified | “Build verified for XIAO ESP32-C3 target `xiao_esp32c3/esp32c3` on Zephyr 4.3.0 / SDK 0.17.4” | +| Evidence | Allowed public wording | +| -------------- | -------------------------------------------------------------------------------------------------------------------- | +| Protocol only | “Implements Nexting Device Protocol Experimental 0.2” with the tested version | +| Core tested | “Core tested against the Experimental 0.2 vectors” | +| Build verified | “Build verified for XIAO ESP32-C3 target `xiao_esp32c3/esp32c3` on Zephyr 4.3.0 / SDK 0.17.4” | | Board verified | “Board verified,” followed by a link to the dated evidence record that names firmware, App, iPhone, and iOS versions | Do not say “secure,” “certified,” “production ready,” or “Nexting Compatible” from these gates alone. diff --git a/devices/docs/development.md b/devices/docs/development.md index a9e54f2..0ca49bc 100644 --- a/devices/docs/development.md +++ b/devices/docs/development.md @@ -58,12 +58,12 @@ npm run check:naming All four reference targets are Build verified in the pinned workflow: -| Board | Target | -| --- | --- | -| nRF52840 DK | `nrf52840dk/nrf52840` | -| XIAO nRF52840 / Sense | `xiao_ble/nrf52840/sense` | -| XIAO ESP32-C3 | `xiao_esp32c3/esp32c3` | -| XIAO ESP32-S3 | `xiao_esp32s3/esp32s3/procpu` | +| Board | Target | +| --------------------- | ----------------------------- | +| nRF52840 DK | `nrf52840dk/nrf52840` | +| XIAO nRF52840 / Sense | `xiao_ble/nrf52840/sense` | +| XIAO ESP32-C3 | `xiao_esp32c3/esp32c3` | +| XIAO ESP32-S3 | `xiao_esp32s3/esp32s3/procpu` | From a configured Zephyr workspace: diff --git a/devices/docs/device-audio.md b/devices/docs/device-audio.md new file mode 100644 index 0000000..1b0de85 --- /dev/null +++ b/devices/docs/device-audio.md @@ -0,0 +1,92 @@ +# Device audio (`device-audio/1`) + +`device-audio/1` is the standard path from a microphone on a physical device to +the official Nexting App. It is not a remote microphone switch and it is not a +renaming of `voice/1`: + +- `voice/1` asks the Host App to start, stop, or cancel the phone/computer + microphone; +- `device-audio/1` carries audio that the physical device sampled itself. + +Both paths enter the App's existing speech-to-text and selected-Agent send +pipeline. A device never receives Agent credentials, session IDs, cloud routes, +or transcripts through this profile. + +## Capability declaration + +Declare the profile only when the production firmware has a working microphone +hook and can sustain the exact advertised format: + +```json +{ + "profiles": ["approval/1", "device-audio/1"], + "audio": { + "source": "device_microphone", + "codecs": ["ima_adpcm"], + "sample_rates": [16000], + "channels": 1, + "frame_ms": [20], + "max_duration_ms": 120000, + "startup_buffer_ms": 200 + } +} +``` + +A stub, simulator, failed microphone initialization, or transport below the +64-byte ATT payload baseline must omit `device-audio/1`; it cannot advertise the +profile and fail later. + +## Privacy and physical-start rule + +The Host sends `audio_config` to say it is authorized and ready. That message, +credits, state messages, reconnects, and Agent actions cannot start sampling. +Only the device's physical voice input can create `audio_begin`. The device may +buffer at most 200 ms captured after that input; always-on capture and pre-roll +before the input are forbidden. + +Audio remains in bounded RAM on the device. The Host clears transport buffers +and temporary containers when the active stream ends. A submitted recording may +follow the Host App's existing voice-history, retry, retention, and deletion +policy; device integrations do not get a separate storage path. Cancel, Reject, +disconnect, timeout, and error paths erase the device-side stream. + +## Standard interaction + +- Voice key down starts capture immediately. +- Release before 450 ms latches recording; the next voice-key down submits. +- Release at or after 450 ms submits the held recording. +- Submit drains queued frames and ends the stream. +- Reject cancels and erases the stream. +- Maximum duration is 120 seconds. +- Reconnect never restores a recording. + +The device and App must visibly distinguish ready, recording, transcribing, +submitted, and error states. Silent recording is non-conformant. + +## Transport and failure limits + +The wire layout is normative in [`SPEC.md`](../SPEC.md); executable examples are +in [`device-audio-v1.json`](../protocol/vectors/device-audio-v1.json). + +- eight initial frame credits; +- ten queued frames (200 ms) maximum; +- 500 ms maximum continuous credit stall; +- four fragments per logical frame maximum; +- sequence `0...5999`, with sample index exactly `sequence × 320`; +- final sample count exactly `(last sequence + 1) × 320`; +- 64-byte negotiated ATT payload minimum; +- duplicate frames are ignored; +- one missing frame inserts 20 ms silence; +- three consecutive or ten total missing frames terminate the stream; +- bad CRC, unknown stream, inconsistent fragments, invalid ADPCM metadata, or + uncertain sample position terminate and erase the stream. + +Run the JavaScript/C conformance and deterministic lifecycle tests with: + +```sh +npm run test:reference +npm run test:simulator +cmake -S sdk/c -B sdk/c/.build +cmake --build sdk/c/.build --parallel +ctest --test-dir sdk/c/.build --output-on-failure +``` diff --git a/devices/docs/foundation-development.md b/devices/docs/foundation-development.md index cb492aa..52cdefb 100644 --- a/devices/docs/foundation-development.md +++ b/devices/docs/foundation-development.md @@ -53,14 +53,14 @@ Specifications and vectors flow down into implementations. Platform adapters nev ## Normative and reference files -| File | Owns | -| --- | --- | -| `SPEC.md` | BLE roles, wire behavior, state, limits, and version rules | -| `protocol/vectors/approval-v1.json` | Shared valid and hostile cases for every implementation | -| `schemas/message.schema.json` | Machine-readable message shape and enums | -| `reference/js/src/protocol.mjs` | Readable strict codec reference | -| `reference/js/src/framing.mjs` | Bounded newline stream reference | -| `reference/js/src/relay.mjs` | Host-authoritative one-prompt reference state | +| File | Owns | +| ----------------------------------- | ---------------------------------------------------------- | +| `SPEC.md` | BLE roles, wire behavior, state, limits, and version rules | +| `protocol/vectors/approval-v1.json` | Shared valid and hostile cases for every implementation | +| `schemas/message.schema.json` | Machine-readable message shape and enums | +| `reference/js/src/protocol.mjs` | Readable strict codec reference | +| `reference/js/src/framing.mjs` | Bounded newline stream reference | +| `reference/js/src/relay.mjs` | Host-authoritative one-prompt reference state | The schema describes shape, but UTF-8 byte ceilings and stream behavior remain normative in `SPEC.md` and the shared vectors. @@ -74,12 +74,12 @@ bounded state, and emits generic physical intent. ## Device core and adapter files -| File | Owns | -| --- | --- | -| `sdk/c/include/nexting_device.h` | Portable C ABI, capacities, messages, stream, and approval state | -| `sdk/c/src/nexting_device.c` | Strict codec, fixed-buffer stream, and approval state machine | -| `firmware/zephyr/src/main.c` | BLE/GATT, bonding, bond reset, GPIO, timers, and fixed transport buffers | -| `firmware/zephyr/boards/*.overlay` | Board-specific Allow, Deny, and Pending pin aliases | +| File | Owns | +| ---------------------------------- | ------------------------------------------------------------------------ | +| `sdk/c/include/nexting_device.h` | Portable C ABI, capacities, messages, stream, and approval state | +| `sdk/c/src/nexting_device.c` | Strict codec, fixed-buffer stream, and approval state machine | +| `firmware/zephyr/src/main.c` | BLE/GATT, bonding, bond reset, GPIO, timers, and fixed transport buffers | +| `firmware/zephyr/boards/*.overlay` | Board-specific Allow, Deny, and Pending pin aliases | A new RTOS or MCU adapter reuses the C core. It implements BLE transport, a monotonic millisecond clock, two unambiguous inputs, a Pending output, encrypted bonding, local bond revocation, and volatile-state cleanup. It does not copy the JSON parser or approval state machine. @@ -91,14 +91,14 @@ Build success proves compilation only. It does not prove Bluetooth delivery, bon ## What to change -| Change | Start here | Then update | Minimum evidence | -| --- | --- | --- | --- | -| Wire field, enum, limit, or state | `SPEC.md` | vectors, JS, Swift, C | JS + Swift + C sanitizer suites | -| Official App behavior | private Nexting App | public SPEC only when the device-facing contract changes | product integration tests | -| Device codec or state | `nexting_device.h` / `nexting_device.c` | vectors and reference behavior | C ASan/UBSan + firmware contract | -| Zephyr BLE, bond, timer, or GPIO | `firmware/zephyr/src/main.c` | board overlay when pins change | firmware contract + affected board builds | -| New chip or RTOS | new platform adapter | hardware support and implementation track | core tests + exact target build | -| Compatibility claim | conformance evidence | project status and changelog | evidence required by the claimed level | +| Change | Start here | Then update | Minimum evidence | +| --------------------------------- | --------------------------------------- | -------------------------------------------------------- | ----------------------------------------- | +| Wire field, enum, limit, or state | `SPEC.md` | vectors, JS, Swift, C | JS + Swift + C sanitizer suites | +| Official App behavior | private Nexting App | public SPEC only when the device-facing contract changes | product integration tests | +| Device codec or state | `nexting_device.h` / `nexting_device.c` | vectors and reference behavior | C ASan/UBSan + firmware contract | +| Zephyr BLE, bond, timer, or GPIO | `firmware/zephyr/src/main.c` | board overlay when pins change | firmware contract + affected board builds | +| New chip or RTOS | new platform adapter | hardware support and implementation track | core tests + exact target build | +| Compatibility claim | conformance evidence | project status and changelog | evidence required by the claimed level | ## Experimental 0.2: the capability set @@ -116,18 +116,18 @@ explicitly through `navigation/1`, `keys/1`, `rotary/1`, `voice/1`, `text/1`, The current set, modeled on dedicated Agent macropads: -| Capability | Direction | Product meaning | -| --- | --- | --- | -| `approval/1` (shipped) | both | One Allow/Deny request with TTL | -| `status/1` (shipped) | Host → device | Per-agent idle/thinking/working/complete/needs-input/error states for LEDs or screens, full replacement, volatile | -| `keys/1` (shipped) | both | Generic physical key events up; labels and light state down; the Host owns what each key means | -| `navigation/1` (shipped) | both | Bounded option lists down; cursor movement and selection up | -| `rotary/1` (shipped) | both | Relative dial/press events up; bounded label and state down | -| `text/1` (shipped) | Host → device | Bounded plain text for declared screen regions | -| `voice/1` (shipped) | both | Push-to-talk control only; capture and transcription stay on the Host microphone | -| `usage/1` (shipped) | Host → device | Informational model label and bounded usage counters | -| `config/1` (shipped) | both | Versioned atomic key, lighting, and display preferences | -| Battery and device info (shipped) | device → Host | Identity, capabilities, limits, and charge state | +| Capability | Direction | Product meaning | +| --------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------------------- | +| `approval/1` (shipped) | both | One Allow/Deny request with TTL | +| `status/1` (shipped) | Host → device | Per-agent idle/thinking/working/complete/needs-input/error states for LEDs or screens, full replacement, volatile | +| `keys/1` (shipped) | both | Generic physical key events up; labels and light state down; the Host owns what each key means | +| `navigation/1` (shipped) | both | Bounded option lists down; cursor movement and selection up | +| `rotary/1` (shipped) | both | Relative dial/press events up; bounded label and state down | +| `text/1` (shipped) | Host → device | Bounded plain text for declared screen regions | +| `voice/1` (shipped) | both | Push-to-talk control only; capture and transcription stay on the Host microphone | +| `usage/1` (shipped) | Host → device | Informational model label and bounded usage counters | +| `config/1` (shipped) | both | Versioned atomic key, lighting, and display preferences | +| Battery and device info (shipped) | device → Host | Identity, capabilities, limits, and charge state | Wi-Fi, HTTP, MQTT, USB HID, persistent permission grants, multi-prompt queues, production device certificates, OTA signing, manufacturing provisioning, and the Nexting Compatible badge still require explicit future contracts. diff --git a/devices/docs/hardware-support.md b/devices/docs/hardware-support.md index 8f9aa78..8266e15 100644 --- a/devices/docs/hardware-support.md +++ b/devices/docs/hardware-support.md @@ -4,23 +4,23 @@ Nexting Devices treats ESP32 and Nordic as equal product families. “Equal” m ## What the labels mean -| Label | Product meaning | -| --- | --- | -| Core tested | The chip adapter uses the shared C99 codec and state machine, whose desktop tests pass. This says nothing about its radio. | -| Build verified | A pinned toolchain produced firmware for the exact board target. No physical Bluetooth claim is implied. | -| Board verified | A named board completed the published iPhone, race, expiry, disconnect, reboot, and malformed-input checklist. | +| Label | Product meaning | +| ------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Core tested | The chip adapter uses the shared C99 codec and state machine, whose desktop tests pass. This says nothing about its radio. | +| Build verified | A pinned toolchain produced firmware for the exact board target. No physical Bluetooth claim is implied. | +| Board verified | A named board completed the published iPhone, race, expiry, disconnect, reboot, and malformed-input checklist. | | Nexting Compatible | A separately governed compatibility program has accepted the product, version, security posture, and evidence. This badge is not available during Experimental 0.2. | “Source available” is not “Build verified,” and “Build verified” is not “Board verified.” Each table entry is deliberately conservative. ## Official Experimental 0.2 references -| Family | Board | Runtime | Current status | Why it is first-class | -| --- | --- | --- | --- | --- | -| Nordic nRF52840 | Seeed XIAO nRF52840 / Sense | Zephyr | **Build verified** on Zephyr `4.3.0` / SDK `0.17.4`; board test pending | Small, battery-friendly, mature BLE, UF2-friendly developer board, same family as our current hardware work. | -| Nordic nRF52840 | nRF52840 DK | Zephyr | **Build verified** on Zephyr `4.3.0` / SDK `0.17.4`; board test pending | Best debug visibility and four built-in buttons/LEDs; our reference for bring-up failures. | -| Espressif ESP32-C3 | XIAO ESP32C3 | Zephyr | **Build verified** on Zephyr `4.3.0` / SDK `0.17.4`; board test pending | Low-cost RISC-V, BLE plus Wi-Fi, good community availability. | -| Espressif ESP32-S3 | XIAO ESP32S3 | Zephyr | **Build verified** on Zephyr `4.3.0` / SDK `0.17.4`; board test pending | More I/O and memory for displays, touch, richer desk controls, and future local UI. | +| Family | Board | Runtime | Current status | Why it is first-class | +| ------------------ | --------------------------- | ------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ | +| Nordic nRF52840 | Seeed XIAO nRF52840 / Sense | Zephyr | **Build verified** on Zephyr `4.3.0` / SDK `0.17.4`; board test pending | Small, battery-friendly, mature BLE, UF2-friendly developer board, same family as our current hardware work. | +| Nordic nRF52840 | nRF52840 DK | Zephyr | **Build verified** on Zephyr `4.3.0` / SDK `0.17.4`; board test pending | Best debug visibility and four built-in buttons/LEDs; our reference for bring-up failures. | +| Espressif ESP32-C3 | XIAO ESP32C3 | Zephyr | **Build verified** on Zephyr `4.3.0` / SDK `0.17.4`; board test pending | Low-cost RISC-V, BLE plus Wi-Fi, good community availability. | +| Espressif ESP32-S3 | XIAO ESP32S3 | Zephyr | **Build verified** on Zephyr `4.3.0` / SDK `0.17.4`; board test pending | More I/O and memory for displays, touch, richer desk controls, and future local UI. | All four use one Zephyr application and `sdk/c`. There is no Nordic parser and separate ESP parser to drift apart. @@ -28,16 +28,16 @@ All four use one Zephyr application and `sdk/c`. There is no Nordic parser and s These are product priorities, not compatibility claims. -| Priority | Family | Best use | Why it is attractive | What must be proved first | -| ---: | --- | --- | --- | --- | -| 1 | nRF54L15 / XIAO nRF54L15 and nRF54LM20A | Next-generation wearable and battery device | Newer Nordic low-power platform, modern radio/security, strong Zephyr direction. | Stable board/toolchain target, secure storage path, power and reconnect measurements. | -| 1 | ESP32-C6 | Low-cost connected panels and hubs | BLE 5.3, Wi-Fi 6, and 802.15.4 leave room for Matter/Thread products. | BLE bonding interop, memory budget, and real iPhone regression run. | -| 2 | nRF5340 | Display, audio, or multi-radio premium controls | Dual-core headroom and mature nRF Connect SDK. | Network-core build/release complexity and a justified product that needs it. | -| 2 | ESP32-H2 | Low-power BLE/Thread button | No Wi-Fi radio overhead; BLE plus 802.15.4 is a good control-device shape. | Board availability, Zephyr target build, and power data. | -| 2 | Silicon Labs EFR32BG24 / XIAO MG24 | OEM low-power products | Strong BLE/Matter platform and good energy profile. | SDK/license review, open build reproducibility, adapter maintainer. | -| 3 | Raspberry Pi Pico 2 W | Education and maker ecosystem | Accessible hardware and a large community. | Reliable BLE peripheral/bonding behavior through the external radio and a maintained BTstack adapter. | -| 3 | STM32WB0/WB55 | Industrial OEM designs | Established MCU vendor, BLE portfolio, industrial supply options. | Reproducible open toolchain, stack integration, signed-update ownership. | -| 3 | TI CC2340R5 | Cost-sensitive OEM button | Purpose-built low-power BLE economics. | SimpleLink adapter, CI licensing, secure bond storage, community ownership. | +| Priority | Family | Best use | Why it is attractive | What must be proved first | +| -------: | --------------------------------------- | ----------------------------------------------- | -------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | +| 1 | nRF54L15 / XIAO nRF54L15 and nRF54LM20A | Next-generation wearable and battery device | Newer Nordic low-power platform, modern radio/security, strong Zephyr direction. | Stable board/toolchain target, secure storage path, power and reconnect measurements. | +| 1 | ESP32-C6 | Low-cost connected panels and hubs | BLE 5.3, Wi-Fi 6, and 802.15.4 leave room for Matter/Thread products. | BLE bonding interop, memory budget, and real iPhone regression run. | +| 2 | nRF5340 | Display, audio, or multi-radio premium controls | Dual-core headroom and mature nRF Connect SDK. | Network-core build/release complexity and a justified product that needs it. | +| 2 | ESP32-H2 | Low-power BLE/Thread button | No Wi-Fi radio overhead; BLE plus 802.15.4 is a good control-device shape. | Board availability, Zephyr target build, and power data. | +| 2 | Silicon Labs EFR32BG24 / XIAO MG24 | OEM low-power products | Strong BLE/Matter platform and good energy profile. | SDK/license review, open build reproducibility, adapter maintainer. | +| 3 | Raspberry Pi Pico 2 W | Education and maker ecosystem | Accessible hardware and a large community. | Reliable BLE peripheral/bonding behavior through the external radio and a maintained BTstack adapter. | +| 3 | STM32WB0/WB55 | Industrial OEM designs | Established MCU vendor, BLE portfolio, industrial supply options. | Reproducible open toolchain, stack integration, signed-update ownership. | +| 3 | TI CC2340R5 | Cost-sensitive OEM button | Purpose-built low-power BLE economics. | SimpleLink adapter, CI licensing, secure bond storage, community ownership. | ## Explicitly not a v0.2 target @@ -47,6 +47,26 @@ These are product priorities, not compatibility claims. - nRF52832: technically possible with a 512-byte frame cap, but its tighter memory and older product position make it a community port, not an official reference. - Wi-Fi-only, HTTP, MQTT, USB HID, and cloud-direct devices: those would be new transports, not silent variations of the BLE profile. +## USB CDC developer track (MultiPad) + +The ILX MultiPad is a source-available STM32F103VET6 keyboard with a USB HID + +CDC composite interface. The public adapter and its contract test live in +[`firmware/multipad`](../firmware/multipad/README.md). This is a **developer +binding**, not an Experimental 0.2 BLE compatibility claim and not Nexting App +USB enrollment. The board variant, bootloader path, backup, and CDC proof must +be recorded before calling a physical unit Build verified or Board verified. + +## Third-party product track (AhaKey X1) + +The AhaKey X1 is a commercially shipping BLE vibecoding keyboard (four keys, +approval lever, LED strip, OLED). The integration kit — a C99 codec for the +published AhaKey protocol plus the Nexting mapping adapter and a Device Info +template — lives in [`firmware/ahakey-x1`](../firmware/ahakey-x1/README.md), +with the end-to-end walkthrough in +[the case document](cases/ahakey-x1-case.md). The codec and adapter are +**Core tested**; neither the native port nor the bridge dongle is Board +verified yet, and this entry is not a Nexting Compatible badge. + ## Hardware contract An Experimental 0.2 port needs: diff --git a/devices/docs/implementation-tracks.md b/devices/docs/implementation-tracks.md index 2b3af2d..c6f55f7 100644 --- a/devices/docs/implementation-tracks.md +++ b/devices/docs/implementation-tracks.md @@ -12,12 +12,12 @@ Choose this track when you want a working two-button developer reference before ### 1. Choose one exact target -| Board | Zephyr target | -| --- | --- | -| nRF52840 DK | `nrf52840dk/nrf52840` | -| XIAO nRF52840 / Sense | `xiao_ble/nrf52840/sense` | -| XIAO ESP32-C3 | `xiao_esp32c3/esp32c3` | -| XIAO ESP32-S3 | `xiao_esp32s3/esp32s3/procpu` | +| Board | Zephyr target | +| --------------------- | ----------------------------- | +| nRF52840 DK | `nrf52840dk/nrf52840` | +| XIAO nRF52840 / Sense | `xiao_ble/nrf52840/sense` | +| XIAO ESP32-C3 | `xiao_esp32c3/esp32c3` | +| XIAO ESP32-S3 | `xiao_esp32s3/esp32s3/procpu` | ### 2. Build from a configured Zephyr workspace diff --git a/devices/docs/interfaces.md b/devices/docs/interfaces.md index 361bda9..f444d14 100644 --- a/devices/docs/interfaces.md +++ b/devices/docs/interfaces.md @@ -1,18 +1,18 @@ # Public interfaces -Nexting Devices `0.2.0-experimental.2` exposes a bounded physical control +Nexting Devices `0.4.0-experimental.1` exposes a bounded physical control surface between a trusted Host and an authorized nearby device. This page helps developers discover the interfaces. [`SPEC.md`](../SPEC.md) remains normative. ## What is public - one Bluetooth LE GATT service; -- nine independently negotiated profiles over one newline-delimited Wire; +- ten independently negotiated profiles over bounded JSONL and binary audio; - a portable fixed-buffer C99 device SDK; - a JavaScript protocol reference for conformance and tooling; - shared vectors, reference firmware, and verification rules. -Experimental 0.2 does not expose a Nexting cloud API. There is no public TCP, UDP, HTTP, MQTT, or WebSocket endpoint, no device-to-Agent credential, and no account or session API in this repository. A device communicates with a user's authorized Host/App over BLE — and because the Host may reach the agent anywhere, every compatible device is remote-capable through its Host. +Experimental 0.4 does not expose a Nexting cloud API. There is no public TCP, UDP, HTTP, MQTT, or WebSocket endpoint, no device-to-Agent credential, and no account or session API in this repository. A device communicates with a user's authorized Host/App over BLE — and because the Host may reach the agent anywhere, every compatible device is remote-capable through its Host. Agent adapters and the official App implementation remain outside this public package. This repository does not provide an App SDK or an Agent connection @@ -22,12 +22,13 @@ API. The device is the BLE peripheral/GATT server. The Host/App is the central/GATT client. -| Surface | UUID | Direction | Product purpose | Required behavior | Implemented by | -| --- | --- | --- | --- | --- | --- | -| Primary Service | `6EADC0DE-0001-4A21-9C5E-1B7F3D9E42A0` | — | Discover a Nexting device | Advertise the service UUID | official App: private; device: `firmware/zephyr/src/main.c` | -| Downlink | `6EADC0DE-0002-4A21-9C5E-1B7F3D9E42A0` | Host → device | Present and resolve requests | Encrypted write; with-response is the default | official App: private; device: `main.c` + C stream | -| Uplink | `6EADC0DE-0003-4A21-9C5E-1B7F3D9E42A0` | device → Host | Report physical answers or errors | Encrypted notification subscription | device: `main.c`; official App: private | -| Device Info | `6EADC0DE-0004-4A21-9C5E-1B7F3D9E42A0` | device → Host | Negotiate version, profiles, model, firmware, limits, and declared capabilities such as `statusSlots` | Read before approval traffic | device: `main.c`; official App: private | +| Surface | UUID | Direction | Product purpose | Required behavior | Implemented by | +| --------------- | -------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------- | ----------------------------------------------------------- | +| Primary Service | `6EADC0DE-0001-4A21-9C5E-1B7F3D9E42A0` | — | Discover a Nexting device | Advertise the service UUID | official App: private; device: `firmware/zephyr/src/main.c` | +| Downlink | `6EADC0DE-0002-4A21-9C5E-1B7F3D9E42A0` | Host → device | Present and resolve requests | Encrypted write; with-response is the default | official App: private; device: `main.c` + C stream | +| Uplink | `6EADC0DE-0003-4A21-9C5E-1B7F3D9E42A0` | device → Host | Report physical answers or errors | Encrypted notification subscription | device: `main.c`; official App: private | +| Device Info | `6EADC0DE-0004-4A21-9C5E-1B7F3D9E42A0` | device → Host | Negotiate version, profiles, model, firmware, limits, and declared capabilities such as `statusSlots` | Read before approval traffic | device: `main.c`; official App: private | +| Audio Uplink | `6EADC0DE-0005-4A21-9C5E-1B7F3D9E42A0` | device → Host | Carry encrypted `device-audio/1` binary fragments | Optional encrypted notification | device adapter; official App: private | One compact UTF-8 JSON object plus `\n` is one logical frame. BLE fragments preserve byte order and never interleave logical frames. The default complete-frame ceiling is 4096 bytes including the newline; every compatible implementation supports at least 512 bytes. @@ -38,21 +39,23 @@ message's exact profile. ## Wire messages -| Message | Direction | Product meaning | Fail-closed rule | Source and implementations | -| --- | --- | --- | --- | --- | -| `present` | Host → device | Show one bounded Allow/Deny request with a relative TTL | Invalid fields do not change current state | vectors; `reference/js/src/protocol.mjs`; `nexting_device.c` | -| `answer` | device → Host | Report the user's locked Allow or Deny choice | Unknown, stale, expired, unauthorized, or changed choices do not commit | vectors; JS relay test fixture; C state | -| `resolved` | Host → device | End the current request as answered, expired, cancelled, or replaced | A nonmatching ID does not clear another request | vectors; JS reference; C state | -| `error` | either direction | Report a bounded protocol failure when useful | An error never carries authority to approve | vectors; JS reference; C99 core | -| `status` (profile `status/1`) | Host → device | Replace the full rendered state of up to 8 anonymous agent slots (idle, thinking, working, complete, needs_input, error, plus an optional 64-byte label) | A malformed frame is discarded whole; the previous rendered state stays; never touches approval state | `status-v1.json` vectors; JS reference; C99 core | -| `nav_present` / `nav_resolved` (`navigation/1`) | Host → device | Present or close a bounded option list | Invalid lists or cursor values do not alter the current menu | `navigation-v1.json`; JS reference; C99 core | -| `nav_move` / `nav_select` (`navigation/1`) | device → Host | Move a cursor or select an index | Sequence gate rejects replay and reordering; Host validates current request | `navigation-v1.json`; JS reference; C99 core | -| `keymap` / `key_event` (`keys/1`) | both | Render Host-owned key labels/light state and report generic physical key activity | Device never chooses Agent semantics; sequence gate rejects duplicate input | `keys-v1.json`; JS reference; C99 core | -| `rotary_map` / `rotary_event` / `rotary_press` (`rotary/1`) | both | Render a dial label and report relative turns or presses | Bounded relative delta only; no model or session ID crosses the Wire | `rotary-v1.json`; JS reference; C99 core | -| `voice_event` / `voice_state` (`voice/1`) | both | Start, stop, cancel, and acknowledge push-to-talk control | No audio or transcript; capture and transcription remain in the official App | `voice-v1.json`; JS reference; C99 core | -| `text` (`text/1`) | Host → device | Render bounded plain text in a declared display region | No markup or secret payload; malformed content leaves the old display intact | `text-v1.json`; JS reference; C99 core | -| `usage` / `usage_clear` (`usage/1`) | Host → device | Render a model label and bounded counters | Counters are display data, not billing authority | `usage-v1.json`; JS reference; C99 core | -| `config` / `config_result` (`config/1`) | both | Apply a versioned set of bounded preferences and report the result | Atomic: any invalid entry rejects the whole update and preserves current config | `config-v1.json`; JS reference; C99 core | +| Message | Direction | Product meaning | Fail-closed rule | Source and implementations | +| ----------------------------------------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `present` | Host → device | Show one bounded Allow/Deny request with a relative TTL | Invalid fields do not change current state | vectors; `reference/js/src/protocol.mjs`; `nexting_device.c` | +| `answer` | device → Host | Report the user's locked Allow or Deny choice | Unknown, stale, expired, unauthorized, or changed choices do not commit | vectors; JS relay test fixture; C state | +| `resolved` | Host → device | End the current request as answered, expired, cancelled, or replaced | A nonmatching ID does not clear another request | vectors; JS reference; C state | +| `error` | either direction | Report a bounded protocol failure when useful | An error never carries authority to approve | vectors; JS reference; C99 core | +| `status` (profile `status/1`) | Host → device | Replace the full rendered state of up to 8 anonymous agent slots (idle, thinking, working, complete, needs_input, error, plus an optional 64-byte label) | A malformed frame is discarded whole; the previous rendered state stays; never touches approval state | `status-v1.json` vectors; JS reference; C99 core | +| `nav_present` / `nav_resolved` (`navigation/1`) | Host → device | Present or close a bounded option list | Invalid lists or cursor values do not alter the current menu | `navigation-v1.json`; JS reference; C99 core | +| `nav_move` / `nav_select` (`navigation/1`) | device → Host | Move a cursor or select an index | Sequence gate rejects replay and reordering; Host validates current request | `navigation-v1.json`; JS reference; C99 core | +| `keymap` / `key_event` (`keys/1`) | both | Render Host-owned key labels/light state and report generic physical key activity | Device never chooses Agent semantics; sequence gate rejects duplicate input | `keys-v1.json`; JS reference; C99 core | +| `rotary_map` / `rotary_event` / `rotary_press` (`rotary/1`) | both | Render a dial label and report relative turns or presses | Bounded relative delta only; no model or session ID crosses the Wire | `rotary-v1.json`; JS reference; C99 core | +| `voice_event` / `voice_state` (`voice/1`) | both | Start, stop, cancel, and acknowledge push-to-talk control | No audio or transcript; capture and transcription remain in the official App | `voice-v1.json`; JS reference; C99 core | +| `audio_config` / `audio_credit` / `audio_state` (`device-audio/1`) | Host → device | Prepare/flow-control an already physically started device microphone and display state | No Host message can open the microphone | `device-audio-v1.json`; JS reference; C99 core | +| `audio_begin` / `audio_end` / `audio_cancel` (`device-audio/1`) | device → Host | Bound one physical-key-started microphone stream | Credits, CRC, duration, and volatile cleanup fail closed | audio vectors; JS/C codecs; simulator | +| `text` (`text/1`) | Host → device | Render bounded plain text in a declared display region | No markup or secret payload; malformed content leaves the old display intact | `text-v1.json`; JS reference; C99 core | +| `usage` / `usage_clear` (`usage/1`) | Host → device | Render a model label and bounded counters | Counters are display data, not billing authority | `usage-v1.json`; JS reference; C99 core | +| `config` / `config_result` (`config/1`) | both | Apply a versioned set of bounded preferences and report the result | Atomic: any invalid entry rejects the whole update and preserves current config | `config-v1.json`; JS reference; C99 core | Exact fields, enums, byte limits, canonical JSON rules, nesting limits, and state transitions are in [the specification](../SPEC.md). Executable examples @@ -65,19 +68,20 @@ live in `protocol/vectors/`, including ## Portable C99 device SDK -| Public API | Use it for | Implementation | -| --- | --- | --- | -| `nexting_device_decode` / `nexting_device_encode` | Strict fixed-capacity messages | `sdk/c/src/nexting_device.c` | -| `nexting_device_stream_init` / `nexting_device_stream_push` / `nexting_device_stream_reset` | Caller-owned bounded receive storage | `nexting_device.c` stream section | -| `nexting_device_state_init` | Start in Idle with no actionable request | `nexting_device.c` state section | -| `nexting_device_state_on_present` | Enter or replace Pending using a monotonic deadline | `nexting_device.c` state section | -| `nexting_device_state_choose` | Lock the first local choice and create an answer | `nexting_device.c` state section | -| `nexting_device_state_retry_answer` | Retry the same locked answer after the interval | `nexting_device.c` state section | -| `nexting_device_state_on_resolved` | Clear the matching request | `nexting_device.c` state section | -| `nexting_device_state_tick` | Expire a request from a monotonic clock | `nexting_device.c` state section | -| `nexting_device_state_disconnect` | Clear volatile request and retry state | `nexting_device.c` state section | -| `nexting_device_status_init` / `nexting_device_status_on_message` / `nexting_device_status_disconnect` | Volatile eight-slot agent-status rendering with full replacement and disconnect clear | `nexting_device.c` status section | -| interaction payloads in `nexting_device_message_t` | Decode and encode navigation, key, rotary, voice, text, usage, and config messages without allocation | `nexting_device.h` / `nexting_device.c` | +| Public API | Use it for | Implementation | +| ------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------- | --------------------------------------- | +| `nexting_device_decode` / `nexting_device_encode` | Strict fixed-capacity messages | `sdk/c/src/nexting_device.c` | +| `nexting_device_stream_init` / `nexting_device_stream_push` / `nexting_device_stream_reset` | Caller-owned bounded receive storage | `nexting_device.c` stream section | +| `nexting_device_state_init` | Start in Idle with no actionable request | `nexting_device.c` state section | +| `nexting_device_state_on_present` | Enter or replace Pending using a monotonic deadline | `nexting_device.c` state section | +| `nexting_device_state_choose` | Lock the first local choice and create an answer | `nexting_device.c` state section | +| `nexting_device_state_retry_answer` | Retry the same locked answer after the interval | `nexting_device.c` state section | +| `nexting_device_state_on_resolved` | Clear the matching request | `nexting_device.c` state section | +| `nexting_device_state_tick` | Expire a request from a monotonic clock | `nexting_device.c` state section | +| `nexting_device_state_disconnect` | Clear volatile request and retry state | `nexting_device.c` state section | +| `nexting_device_status_init` / `nexting_device_status_on_message` / `nexting_device_status_disconnect` | Volatile eight-slot agent-status rendering with full replacement and disconnect clear | `nexting_device.c` status section | +| interaction payloads in `nexting_device_message_t` | Decode and encode navigation, key, rotary, voice, text, usage, and config messages without allocation | `nexting_device.h` / `nexting_device.c` | +| `nexting_device_audio_encode_frame` / `nexting_device_audio_decode_frame` | Encode/decode independent 20 ms IMA ADPCM device-microphone frames | `nexting_device_audio.h` / `.c` | The caller owns every buffer. The core does not initialize Bluetooth, allocate memory, read GPIO, drive an LED, persist an approval, or provide a wall clock. The Zephyr reference firmware does not declare `statusSlots` until per-slot indicator rendering is implemented and board-verified, so Hosts send it no status traffic today. @@ -98,29 +102,29 @@ structure. Physical behavior still requires the board checklist. ## Required limits and guarantees -| Contract | Experimental 0.2 | -| --- | --- | -| Wire version | `1` | -| Profiles | `approval/1`, `status/1`, `navigation/1`, `keys/1`, `rotary/1`, `voice/1`, `text/1`, `usage/1`, `config/1` | -| Choices | exactly `allow` and `deny` | -| Request ID | 1–64 allowed ASCII bytes | -| Summary | at most 240 UTF-8 bytes | -| TTL | 1–300000 monotonic milliseconds | -| Logical frame | at most 4096 bytes including `\n` | -| Active prompts | one | -| Approval storage | volatile; clear on disconnect and reboot | -| Status slots | 0–8 per device, declared as `statusSlots` in Device Info | -| Status states | `idle`, `thinking`, `working`, `complete`, `needs_input`, `error` | -| Status label | optional, 1–64 UTF-8 bytes, no control characters | -| Status storage | volatile; full replacement per frame; clear on disconnect and reboot | +| Contract | Experimental 0.4 | +| ---------------- | ---------------------------------------------------------------------------------------------------------- | +| Wire version | `1` | +| Profiles | `approval/1`, `status/1`, `navigation/1`, `keys/1`, `rotary/1`, `voice/1`, `text/1`, `usage/1`, `config/1`, `device-audio/1` | +| Choices | exactly `allow` and `deny` | +| Request ID | 1–64 allowed ASCII bytes | +| Summary | at most 240 UTF-8 bytes | +| TTL | 1–300000 monotonic milliseconds | +| Logical frame | at most 4096 bytes including `\n` | +| Active prompts | one | +| Approval storage | volatile; clear on disconnect and reboot | +| Status slots | 0–8 per device, declared as `statusSlots` in Device Info | +| Status states | `idle`, `thinking`, `working`, `complete`, `needs_input`, `error` | +| Status label | optional, 1–64 UTF-8 bytes, no control characters | +| Status storage | volatile; full replacement per frame; clear on disconnect and reboot | ## Adding an interface A new transport, profile, field, language SDK, or platform is not public merely because code exists. First update the product behavior and failure behavior, then `SPEC.md`, shared vectors, reference implementation, affected SDKs, tests, version/CHANGELOG, and this catalog. -Typed Device Info counts describe hardware; the nine versioned profile strings +Typed Device Info counts describe hardware; the ten versioned profile strings negotiate behavior. A new profile still requires SPEC text, schema, vectors, all public codecs, docs, and evidence before devices may claim it. Wi-Fi, HTTP, -MQTT, USB, multiple approval prompts, device-microphone audio, and production +MQTT, USB, multiple approval prompts, remote microphone start, and production identity remain outside this release. Nothing may silently reuse an -Experimental 0.2 compatibility claim. +Experimental 0.4 compatibility claim. diff --git a/devices/docs/migration-0.1-to-0.2.md b/devices/docs/migration-0.1-to-0.2.md index 4611c3b..498d059 100644 --- a/devices/docs/migration-0.1-to-0.2.md +++ b/devices/docs/migration-0.1-to-0.2.md @@ -37,12 +37,12 @@ support, and vendor facts remain read-only values reported by the device. ## Compatibility -| Pair | Expected behavior | -| --- | --- | -| Official App + valid 0.1 device | Approval/status continue; new metadata rows are absent | -| Older App + 0.2 device | Required fields continue; unknown bounded optional fields are ignored | -| Official App + malformed optional vendor block | Core Device Info remains; vendor block is dropped | -| Unsupported wire/profile | Connection fails closed as before | +| Pair | Expected behavior | +| ---------------------------------------------- | --------------------------------------------------------------------- | +| Official App + valid 0.1 device | Approval/status continue; new metadata rows are absent | +| Older App + 0.2 device | Required fields continue; unknown bounded optional fields are ignored | +| Official App + malformed optional vendor block | Core Device Info remains; vendor block is dropped | +| Unsupported wire/profile | Connection fails closed as before | The 0.2 label describes the SDK and Device Info contract. It does not change wire major `1`, self-certify a board, or make production firmware public. diff --git a/devices/docs/multipad-usb.md b/devices/docs/multipad-usb.md new file mode 100644 index 0000000..8673287 --- /dev/null +++ b/devices/docs/multipad-usb.md @@ -0,0 +1,118 @@ +# MultiPad USB CDC 开发指南 + +这是一条给已经买到 ILX MultiPad 的开发者路径。它和 BLE 参考板是两种 +不同的传输:MultiPad 先作为普通 USB HID 键盘工作,再通过 USB CDC ACM +通道接收配置和 Nexting JSON。它不直接连接 Claude Code、Codex 或 Nexting +云;Host/App 仍然负责授权、会话和最终动作。 + +如果这是你第一次把公开硬件接入 Nexting,请先读[第一个案例:ILX MultiPad](cases/multipad-first-case.md)。 +本页是硬件路径和风险清单;案例页是从上游源码到新 HEX 的完整开发顺序。 + +## 先知道你手上的硬件是哪一版 + +先看[上游仓库](https://github.com/iLx11/multi-pad)与 +[上游固件](https://github.com/iLx11/key-pad-application),本目录只提供 +Nexting 适配层,不复制上游 GPL 固件。 + +| 事实 | 当前证据 | 对烧录的影响 | +| -------------- | ------------------------------------ | ----------------------------- | +| MCU | 上游工程的 STM32F103VET6 配置 | 使用 STM32 工具链,不使用 UF2 | +| 正常 USB | HID + CDC ACM composite | 普通 Type-C 不等于 bootloader | +| Module PCB | 有串口模块、SERIAL 开关、RESET/BOOT | 可按上游步骤进串口 bootloader | +| FPC PCB | 移除串口和按键 | 需要 SWD/J-Link | +| App bootloader | 上游源码没有 DFU/IAP/应用 bootloader | 烧错后不能指望 USB 自救 | + +上游工程的烧录配置是 `Config/stlink.cfg`:ST-Link 使用 SWD,目标是 STM32F1。 +`leden.ioc` 明确把 `PA13` 配为 `SWDIO`、`PA14` 配为 `SWCLK`,但上游仓库没有 +原理图或焊盘坐标文件,所以不能从仓库臆造物理焊盘顺序。看到焊盘后按丝印或 +万用表确认 `3V3/GND/SWDIO/SWCLK`,不要把 Type-C CDC 当作烧录器。 + +拆机前不要猜显示屏、电池、序列号或开关位置。请先拔掉 Type-C,拆下四个 +背面螺丝,拿起后盖时不要拉扯屏幕排线,并拍下 MCU、PCB 版本、BOOT/RESET +和 SERIAL 标记。本目录的 +`firmware/multipad/nexting-multipad-device-info.template.json` 只声明了 +上游源码能证明的按键/旋钮数量;未知字段保持缺省。 + +## 软件准备 + +```sh +git clone https://github.com/Nexting-ai/nexting.git +cd nexting/devices + +# 先编译与运行不接硬件的 C99 适配器测试 +npm run test:multipad + +# 先看安全检查,不会写设备 +sh firmware/multipad/tools/flash-multipad.sh --help +python3 firmware/multipad/tools/multipad-cdc-smoke.py --help +``` + +`nexting_multipad_adapter.c/.h` 是唯一需要移植到上游工程的薄层。它复用 +`sdk/c` 的 framing、审批 TTL、回答锁定、1 秒重发、resolved 清理、状态全量 +替换和断连清理;它不新增一套 JSON 解析器,也不携带 App/Agent 逻辑。 + +### 接到上游 USB 回调 + +把 adapter 源文件和 `sdk/c/src/nexting_device.c` 加入上游 CMake 工程,在 +`USER/Usb/usb_user.c` 的 CDC 接收入口先保留老协议,再加这一段: + +```c +if (nexting_multipad_accepts(&nexting_adapter, Buf, *Len)) { + (void)nexting_multipad_receive(&nexting_adapter, Buf, *Len); + return USBD_OK; +} +``` + +两个审批键调用 `nexting_multipad_choose()`;系统毫秒节拍调用 +`nexting_multipad_tick()`;USB 断连调用 `nexting_multipad_disconnect()`。显示 +和 USB 写入由你自己的板级回调实现。这样 HID 的键盘功能与原有 `AA BB xx` +配置命令仍然保留,Nexting 帧只在以 `{` 开始的 CDC 流上生效。 + +## 烧录前检查清单 + +1. **备份**:先让设备进入上游支持的串口 bootloader,再读取原始 flash;备份 + 文件必须不存在,脚本不会覆盖旧备份。 +2. **确认路径**:Module PCB 才能走串口;FPC PCB 直接停止,准备 SWD。 +3. **校验固件**:使用与你的适配器构建对应的 Intel HEX,不能把 `.bin`、UF2 + 或未知版本混用。 +4. **预览 CDC**:设备仍能被系统识别为 `MultiPad_Device` 时,先运行: + + ```sh + python3 firmware/multipad/tools/multipad-cdc-smoke.py /dev/cu.usbmodemXXXX + ``` + + 这只发送上游已知的 `AA BB CC` 回显,不写 flash。只有适配器固件已经烧入 + 后,才加 `--present` 发送 Nexting 测试帧。 + +5. **显式写入**:只有确认设备处于串口 bootloader、备份成功、端口和 HEX 都 + 正确时才执行: + + ```sh + sh firmware/multipad/tools/flash-multipad.sh write \ + --port /dev/cu.usbmodemXXXX \ + --hex /path/to/nexting-multipad.hex \ + --confirm --allow-write + ``` + +脚本没有自动搜索端口,也不会替你按 RESET/BOOT;这两个动作必须依据拆机后 +的 PCB 证据完成。这样可以避免把普通 CDC 误当作 bootloader。 + +## App 与 Host 边界 + +本路径完成的是**固件和公开 SDK 适配**。现有 Nexting App 的公开设备路径是 +加密绑定的 BLE;它不会因为你烧入 USB CDC 就自动显示 MultiPad。要让某个 +桌面 Host 或未来 App 使用 USB,需要该 Host 自己拥有 USB 权限、授权、端口 +选择和断连策略,并通过同一套 `approval/1`、`status/1` 向量验证。不要把 USB +端口、Agent 会话 ID、云地址或凭证写进固件。 + +## 完成标准 + +- `npm run test:multipad` 通过; +- 上游工程按其原许可证独立构建; +- 原始 flash 有可恢复备份; +- CDC 回显通过,Nexting `present` 能得到设备 `answer`; +- 断连后审批和状态都清空; +- Device Info 只声明拆机后确实存在的能力。 + +在最后一项证据拿到前,不要在 GitHub README 或 App 中写“已支持 MultiPad”或 +“可直接 Type-C 烧录”。 diff --git a/devices/docs/porting-guide.md b/devices/docs/porting-guide.md index 4b09a83..fba195b 100644 --- a/devices/docs/porting-guide.md +++ b/devices/docs/porting-guide.md @@ -6,13 +6,13 @@ Before adding a platform, read [the public foundation](foundation-development.md ## The five pieces -| Piece | Reuse or implement | Responsibility | -| --- | --- | --- | -| Protocol and state | Reuse `sdk/c` | JSON, UTF-8 bounds, newline framing, TTL, choice locking, retry, resolution. | -| BLE adapter | Implement | Four GATT UUIDs, encrypted writes, notifications, Device Info, connection lifecycle. | -| Inputs | Implement | Convert two trustworthy local actions into `ALLOW` or `DENY`; debounce before calling the core. | -| Output | Implement | Show Pending/Idle without treating an LED as authority. | -| Product security | Implement and document | Pairing UX, bond storage/revocation, update path, physical threat model. | +| Piece | Reuse or implement | Responsibility | +| ------------------ | ---------------------- | ----------------------------------------------------------------------------------------------- | +| Protocol and state | Reuse `sdk/c` | JSON, UTF-8 bounds, newline framing, TTL, choice locking, retry, resolution. | +| BLE adapter | Implement | Four GATT UUIDs, encrypted writes, notifications, Device Info, connection lifecycle. | +| Inputs | Implement | Convert two trustworthy local actions into `ALLOW` or `DENY`; debounce before calling the core. | +| Output | Implement | Show Pending/Idle without treating an LED as authority. | +| Product security | Implement and document | Pairing UX, bond storage/revocation, update path, physical threat model. | Do not copy the JavaScript reference into firmware and do not parse the protocol with substring searches. The C99 core exists to make ports behave identically. diff --git a/devices/docs/project-status.md b/devices/docs/project-status.md index bfe46c0..3c8c51b 100644 --- a/devices/docs/project-status.md +++ b/devices/docs/project-status.md @@ -1,32 +1,36 @@ # Project status -Snapshot: 2026-07-29. Release `0.2.0-experimental.2` is implemented as the open +Snapshot: 2026-09-07. Release `0.4.0-experimental.1` is implemented as the open Devices SDK inside the existing `Nexting-ai/nexting` repository under `devices/`. ## Current evidence -| Area | Evidence | Status | -| --- | --- | --- | -| Wire and vectors | Nine negotiated profiles, bounded Device Info 0.2, JSON Schema, valid and hostile vectors | Passing; wire major remains `1` | -| JavaScript reference | Protocol, framing, relay, Device Info, documentation, and export tests | Passing | -| Portable C99 SDK | Fixed-buffer nine-profile protocol, approval/status state, Device Info, ASan/UBSan suites | Passing | -| Cloud metadata | Account-owned custom name, optional number, and notes by stable instance key; owner-only RLS | Route/service tests pass | -| Public export | Allowlisted deterministic `devices/` export, README marker block, SHA-256 manifest, hostile-path/content/symlink tests | Passing | -| nRF52840 DK | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | -| XIAO nRF52840 / Sense | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | -| XIAO ESP32-C3 | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | -| XIAO ESP32-S3 | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | +| Area | Evidence | Status | +| --------------------- | ---------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| Wire and vectors | Ten negotiated profiles, bounded Device Info, strict audio/tunnel schemas, valid and hostile vectors | Passing; wire major remains `1` | +| JavaScript reference | Protocol, audio, tunnel, framing, relay, Device Info, documentation, and export tests | Passing | +| Portable C99 SDK | Fixed-buffer ten-profile protocol, IMA ADPCM/CRC32C frames, Device Info, ASan/UBSan suites | Passing | +| Device-audio simulator | Physical-only start, press/hold, credits, loss, timeout, duration, cancellation, and RAM cleanup | Core tested; no board claim | +| Cloud metadata | Account-owned custom name, optional number, and notes by stable instance key; owner-only RLS | Route/service tests pass | +| Public export | Allowlisted deterministic `devices/` export, README marker block, SHA-256 manifest, hostile-path/content/symlink tests | Passing | +| nRF52840 DK | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | +| XIAO nRF52840 / Sense | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | +| XIAO ESP32-C3 | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | +| XIAO ESP32-S3 | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | +| AhaKey X1 | CH582-facing tunnel/audio port, physical-only lifecycle, C codec, descriptor, injected hardware tests | Core tested; production PCM hook and board evidence pending | -## What 0.2 adds +## What 0.3 adds - Frozen `navigation/1`, `keys/1`, `rotary/1`, `voice/1`, `text/1`, `usage/1`, and `config/1` interaction contracts alongside `approval/1` and `status/1`. - Strict profile negotiation and sequence gates so undeclared, replayed, or out-of-order physical input is discarded. -- Official-App-only microphone capture for push-to-talk: `voice/1` carries control - events and acknowledgement, never audio or transcripts. +- `voice/1` remains Host-microphone control. `device-audio/1` independently + carries microphone audio sampled by hardware after a physical input. +- Compatibility Tunnel v1 maps constrained existing BLE command transports to + the same logical contracts without vendor code in the official App core. - Atomic remote configuration. One invalid setting rejects the complete update without changing the active configuration. - Optional typed identity and hardware capabilities: buttons, approval/custom @@ -46,7 +50,7 @@ Devices SDK inside the existing `Nexting-ai/nexting` repository under ## Remaining physical evidence -No Android or iPhone BLE device was attached to the 2026-07-29 verification +No Android or iPhone BLE device was attached to the 2026-08-13 verification environment. Therefore: - no real-radio, pairing, reconnect, battery, button, or cross-platform diff --git a/devices/docs/reference-approval-controller.md b/devices/docs/reference-approval-controller.md index 439a2bd..e6780fb 100644 --- a/devices/docs/reference-approval-controller.md +++ b/devices/docs/reference-approval-controller.md @@ -10,10 +10,10 @@ firmware, live Agent mapping, or physical security. You need a Seeed XIAO nRF52840 or XIAO nRF52840 Sense, two normally-open momentary buttons, jumper wires, and a data-capable USB-C cable. -| Button | Connect | -| --- | --- | -| Allow | D0 to GND | -| Deny | D1 to GND | +| Button | Connect | +| ------ | --------- | +| Allow | D0 to GND | +| Deny | D1 to GND | The reference firmware enables internal pull-ups. Do not connect either input to 3.3 V. diff --git a/devices/docs/use-cases.md b/devices/docs/use-cases.md index c399218..a0c9a31 100644 --- a/devices/docs/use-cases.md +++ b/devices/docs/use-cases.md @@ -44,15 +44,15 @@ shared vectors, and C99, Swift, Kotlin, and JavaScript codecs. Product actions still require a trusted Host adapter; a generic key event does not itself authorize or invoke an Agent command. -| Scenario | Needs | Product shape | -| --- | --- | --- | +| Scenario | Needs | Product shape | +| --------------------- | --------------------- | ------------------------------------------------------------------------------------------------------- | | Full command macropad | `keys/1` + `status/1` | 8–13 generic keys mapped by the Host to approve, decline, fork, send, or fast, with status backlighting | -| Menu navigator | `navigation/1` | Stick or wheel for bounded options; Host validates the selected request | -| Rotary controller | `rotary/1` | Relative dial events for a Host-owned session or model list, without exposing internal IDs | -| Reader device | `text/1` | Bounded plain text for a declared screen; no markup, file content, or secrets | -| Talk-to-agent remote | `voice/1` | Push-to-talk control while the Host microphone performs capture and transcription; no BLE audio | -| Usage display | `usage/1` | Model label and bounded counters that are informational rather than billing authority | -| Reconfigurable pad | `config/1` + `keys/1` | Atomic key, lighting, and display preferences downloaded from the Host without reflashing | +| Menu navigator | `navigation/1` | Stick or wheel for bounded options; Host validates the selected request | +| Rotary controller | `rotary/1` | Relative dial events for a Host-owned session or model list, without exposing internal IDs | +| Reader device | `text/1` | Bounded plain text for a declared screen; no markup, file content, or secrets | +| Talk-to-agent remote | `voice/1` | Push-to-talk control while the Host microphone performs capture and transcription; no BLE audio | +| Usage display | `usage/1` | Model label and bounded counters that are informational rather than billing authority | +| Reconfigurable pad | `config/1` + `keys/1` | Atomic key, lighting, and display preferences downloaded from the Host without reflashing | ## Rules every scenario inherits diff --git a/devices/firmware/ahakey-x1/CMakeLists.txt b/devices/firmware/ahakey-x1/CMakeLists.txt new file mode 100644 index 0000000..4ad6b9c --- /dev/null +++ b/devices/firmware/ahakey-x1/CMakeLists.txt @@ -0,0 +1,45 @@ +cmake_minimum_required(VERSION 3.20) +project(nexting_ahakey_adapter LANGUAGES C) + +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) + +set(NEXTING_DEVICES_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../..") + +add_library(nexting_ahakey_adapter STATIC + ahakey_proto.c + nexting_ahakey_adapter.c + nexting_ahakey_audio.c + developer-kit/nexting_ahakey_port.c + "${NEXTING_DEVICES_ROOT}/sdk/c/src/nexting_device.c" + "${NEXTING_DEVICES_ROOT}/sdk/c/src/nexting_device_audio.c" +) +target_include_directories(nexting_ahakey_adapter + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}" + "${CMAKE_CURRENT_SOURCE_DIR}/developer-kit" + "${NEXTING_DEVICES_ROOT}/sdk/c/include" +) +target_compile_options(nexting_ahakey_adapter PRIVATE -Wall -Wextra -Wpedantic -Werror) + +enable_testing() +add_executable(test_ahakey_proto tests/test_ahakey_proto.c) +target_link_libraries(test_ahakey_proto PRIVATE nexting_ahakey_adapter) +target_compile_options(test_ahakey_proto PRIVATE -Wall -Wextra -Wpedantic -Werror) +add_test(NAME ahakey_proto COMMAND test_ahakey_proto) + +add_executable(test_nexting_ahakey tests/test_ahakey_adapter.c) +target_link_libraries(test_nexting_ahakey PRIVATE nexting_ahakey_adapter) +target_compile_options(test_nexting_ahakey PRIVATE -Wall -Wextra -Wpedantic -Werror) +add_test(NAME nexting_ahakey_adapter COMMAND test_nexting_ahakey) + +add_executable(test_nexting_ahakey_audio tests/test_ahakey_audio.c) +target_link_libraries(test_nexting_ahakey_audio PRIVATE nexting_ahakey_adapter) +target_compile_options(test_nexting_ahakey_audio PRIVATE -Wall -Wextra -Wpedantic -Werror) +add_test(NAME nexting_ahakey_audio COMMAND test_nexting_ahakey_audio) + +add_executable(test_nexting_ahakey_port tests/test_ahakey_port.c) +target_link_libraries(test_nexting_ahakey_port PRIVATE nexting_ahakey_adapter) +target_compile_options(test_nexting_ahakey_port PRIVATE -Wall -Wextra -Wpedantic -Werror) +add_test(NAME nexting_ahakey_port COMMAND test_nexting_ahakey_port) diff --git a/devices/firmware/ahakey-x1/README.md b/devices/firmware/ahakey-x1/README.md new file mode 100644 index 0000000..6477221 --- /dev/null +++ b/devices/firmware/ahakey-x1/README.md @@ -0,0 +1,113 @@ +# AhaKey X1 integration kit + +[简体中文](#简体中文) + +Portable glue that turns the AhaKey X1 vibecoding keyboard into a Nexting +device. The kit speaks both sides of the conversation: + +- `ahakey_proto.h/.c` — a C99 codec for the published AhaKey X1 BLE + configuration protocol (GATT service `0x7340`, frame layout + `AA BB cmd data CC DD`), pinned to AhakeyAI/desktop commit + `c16630329cd0a53fcbcdd4d528015257a19449f6` (`docs/ble-protocol.md`). +- `nexting_ahakey_adapter.h/.c` — the mapping between the portable Nexting + C99 state machine (`sdk/c`) and the X1 hardware concepts: approval keys, + the physical lever, the LED strip, and the OLED. +- `nexting_ahakey_audio.h/.c` — physical-only microphone capture, IMA ADPCM, + bounded RAM, credits, fragmentation, timeout, and wipe-on-failure. +- `developer-kit/nexting_ahakey_port.h/.c` — the production CH582-facing hook + boundary over the existing `0x7343`/`0x7344` characteristics. +- `transport-descriptor.nexting-device.json` — the data-only App descriptor; + all AhaKey UUIDs and opcodes stay here and in firmware, outside App core. +- `nexting-ahakey-device-info.template.json` — the Device Info declaration + the integrated device publishes to the Host. + +The case document [docs/cases/ahakey-x1-case.md](../../docs/cases/ahakey-x1-case.md) +walks through both integration modes end to end. + +## Capability mapping + +| X1 hardware | Nexting interface | Mapping | +| ------------------------------- | ------------------------ | --------------------------------------------------- | +| Key configured as F18 | `approval/1` → Allow | Answers the pending request | +| Key configured as F19 | `approval/1` → Deny | Answers the pending request | +| Keys configured as F13–F16 | `keys/1` slots 0–3 | `key_event` press with a rising sequence | +| Physical lever | `keys/1` slot 7 | Press when it leaves rest, release when it returns | +| LED strip | `status/1` | Agent state → AhaKey IDE state (`0x90`) | +| OLED 160×80 | `text/1` | Title + content for the display channel | +| Battery service `0x180F` | Device Info | `battery_service: true` | +| On-device microphone | `device-audio/1` | PCM16 → IMA ADPCM → encrypted BLE → Nexting App | +| Approval pending (any source) | LED override | Forces the permission-request state while pending | + +A pending approval overrides the strip with the waiting-for-approval light. +Otherwise the strip renders the lowest-numbered occupied status slot and +ignores the rest, per the status/1 rule: an active agent shows the +tool-running state, needs-input shows waiting-for-approval, a completed +agent shows task-completed, an error shows the notification state, and +nothing occupied turns the strip off. Keys and the lever emit events only +for slots the Host keymap has declared and enabled. + +## Integration modes + +- **Preferred — Compatibility Tunnel firmware:** link the developer kit into + X1 firmware. It preserves the original `0x7340` service and adds four fixed + data opcodes. The App loads the generic descriptor; no AhaKey parser enters + the App. +- **Alternative — native Nexting GATT:** expose the standard Nexting service + and audio characteristic directly. The same audio state machine is reused. +- **Stock-firmware bridge:** remains useful for keys, lever, LED, and OLED, + but cannot claim device audio unless stock firmware exposes the keyboard's + real PCM stream to the bridge. + +All modes use the same adapter and the same Device Info identity; the +end-to-end wiring, callbacks, and verification steps are in +[docs/cases/ahakey-x1-case.md](../../docs/cases/ahakey-x1-case.md). The +adapter never sees Agent credentials, accounts, or cloud addresses; +authorization and the final approval action stay with the Host. + +## Build and test + +```sh +npm run test:ahakey +``` + +This builds the codec and adapter with `-Wall -Wextra -Wpedantic -Werror` +and runs the host-side CTest suites, including the published wire captures +from the AhaKey protocol document. + +## Evidence wording + +The codec, audio state machine, tunnel, and adapter are **Core tested** (host +CTest). That label says +nothing about radio behavior; real-keyboard and real-phone verification +follows the checklist in the case document before any stronger claim. See +[docs/conformance.md](../../docs/conformance.md) for the allowed wording. + +## 简体中文 + +把 AhaKey X1 vibecoding 键盘接入 Nexting 的移植套件。套件同时讲两种语言: + +- `ahakey_proto.h/.c` — AhaKey X1 公开 BLE 配置协议(GATT 服务 `0x7340`, + 帧格式 `AA BB cmd data CC DD`)的 C99 编解码,协议文档 pin 在 + AhakeyAI/desktop 的 `c16630329cd0a53fcbcdd4d528015257a19449f6`。 +- `nexting_ahakey_adapter.h/.c` — Nexting C99 协议状态机(`sdk/c`)与 + X1 硬件概念之间的映射:审批按键、物理拨杆、LED 灯条和 OLED。 +- `nexting_ahakey_audio.h/.c` 与 `developer-kit/` — 把设备本身麦克风的 + PCM16 编码成 IMA ADPCM,经加密 BLE 上传;包含固定内存、信用流控、 + 分片、超时、断连擦除,以及 CH582 生产固件需要实现的窄接口。 +- `nexting-ahakey-device-info.template.json` — 设备向 Host 发布的 + Device Info 声明模板。 + +推荐模式是把 Compatibility Tunnel 编进 X1 固件:保留原厂 `0x7340` +服务,只增加 A0–A3 四个固定数据 opcode。Nexting App 只加载声明式 +描述符,不理解 AhaKey 私有命令。原生 Nexting GATT 也可复用同一音频 +状态机。stock 固件桥接模式可支持按键、拨杆、灯和屏幕,但只有 stock +固件真正开放设备 PCM 时才能声明设备音频。所有模式共用同一个适配器 +与 Device Info 身份;端到端接 +线、回调与验证步骤见案例文档 +[docs/cases/ahakey-x1-case.md](../../docs/cases/ahakey-x1-case.md)。 +适配器不接触 Agent 凭证、账号或云端地址;授权与最终审批动作始终在 +Host 侧。 +编解码、音频状态机、Tunnel 与适配器当前为 **Core tested**(主机侧 +CTest 通过);真键盘、 +真手机验证按案例文档清单推进,措辞边界见 +[docs/conformance.md](../../docs/conformance.md)。 diff --git a/devices/firmware/ahakey-x1/ahakey_proto.c b/devices/firmware/ahakey-x1/ahakey_proto.c new file mode 100644 index 0000000..45d4039 --- /dev/null +++ b/devices/firmware/ahakey-x1/ahakey_proto.c @@ -0,0 +1,118 @@ +#include "ahakey_proto.h" + +#include + +ahakey_proto_result_t ahakey_proto_decode(const uint8_t *bytes, size_t length, + ahakey_proto_frame_t *frame) { + if (bytes == NULL || frame == NULL || length < AHAKEY_PROTO_MIN_FRAME_BYTES || + length > AHAKEY_PROTO_MAX_FRAME_BYTES) + return AHAKEY_PROTO_BAD_FRAME; + if (bytes[0] != AHAKEY_PROTO_HEADER_0 || bytes[1] != AHAKEY_PROTO_HEADER_1 || + bytes[length - 2U] != AHAKEY_PROTO_TAIL_0 || + bytes[length - 1U] != AHAKEY_PROTO_TAIL_1) + return AHAKEY_PROTO_BAD_FRAME; + frame->cmd = bytes[2]; + frame->data_length = length - AHAKEY_PROTO_MIN_FRAME_BYTES; + if (frame->data_length != 0U) + memcpy(frame->data, bytes + 3U, frame->data_length); + return AHAKEY_PROTO_OK; +} + +ahakey_proto_result_t ahakey_proto_build(uint8_t cmd, const uint8_t *data, + size_t data_length, uint8_t *output, + size_t output_capacity, + size_t *output_length) { + if (output == NULL || (data == NULL && data_length != 0U) || + data_length > AHAKEY_PROTO_MAX_DATA_BYTES) + return AHAKEY_PROTO_BAD_FRAME; + const size_t frame_length = AHAKEY_PROTO_MIN_FRAME_BYTES + data_length; + if (output_capacity < frame_length) + return AHAKEY_PROTO_BUFFER_TOO_SMALL; + output[0] = AHAKEY_PROTO_HEADER_0; + output[1] = AHAKEY_PROTO_HEADER_1; + output[2] = cmd; + if (data_length != 0U) + memcpy(output + 3U, data, data_length); + output[3U + data_length] = AHAKEY_PROTO_TAIL_0; + output[4U + data_length] = AHAKEY_PROTO_TAIL_1; + if (output_length != NULL) + *output_length = frame_length; + return AHAKEY_PROTO_OK; +} + +ahakey_proto_result_t ahakey_proto_build_update_state(ahakey_state_t state, + uint8_t *output, + size_t output_capacity, + size_t *output_length) { + if (state > AHAKEY_STATE_SESSION_END) + return AHAKEY_PROTO_BAD_FRAME; + const uint8_t data = (uint8_t)state; + return ahakey_proto_build(AHAKEY_PROTO_CMD_UPDATE_STATE, &data, 1U, output, + output_capacity, output_length); +} + +static bool is_printable_ascii(const char *text, size_t length) { + for (size_t i = 0; i < length; ++i) { + const uint8_t byte = (uint8_t)text[i]; + if (byte < 0x20U || byte > 0x7EU) + return false; + } + return true; +} + +ahakey_proto_result_t ahakey_proto_build_key_description( + uint8_t mode, uint8_t key_index, const char *ascii, uint8_t *output, + size_t output_capacity, size_t *output_length) { + if (ascii == NULL || mode >= AHAKEY_PROTO_MODE_COUNT || + key_index >= AHAKEY_PROTO_KEY_COUNT) + return AHAKEY_PROTO_BAD_FRAME; + const size_t text_length = strlen(ascii); + if (text_length == 0U || + text_length > AHAKEY_PROTO_KEY_DESCRIPTION_MAX_BYTES || + !is_printable_ascii(ascii, text_length)) + return AHAKEY_PROTO_BAD_FRAME; + uint8_t data[3U + AHAKEY_PROTO_KEY_DESCRIPTION_MAX_BYTES]; + data[0] = AHAKEY_PROTO_KEY_SUB_DESCRIPTION; + data[1] = mode; + data[2] = key_index; + memcpy(data + 3U, ascii, text_length); + return ahakey_proto_build(AHAKEY_PROTO_CMD_UPDATE_CUSTOM_KEY, data, + 3U + text_length, output, output_capacity, + output_length); +} + +ahakey_proto_result_t ahakey_proto_build_change_name(const uint8_t *name, + size_t name_length, + uint8_t *output, + size_t output_capacity, + size_t *output_length) { + if (name == NULL || name_length == 0U || + name_length > AHAKEY_PROTO_NAME_MAX_BYTES) + return AHAKEY_PROTO_BAD_FRAME; + return ahakey_proto_build(AHAKEY_PROTO_CMD_CHANGE_NAME, name, name_length, + output, output_capacity, output_length); +} + +ahakey_proto_result_t ahakey_proto_parse_response( + const ahakey_proto_frame_t *frame, uint8_t expected_cmd, uint8_t *status) { + if (frame == NULL || status == NULL || frame->cmd != expected_cmd || + frame->data_length == 0U) + return AHAKEY_PROTO_BAD_FRAME; + *status = frame->data[0]; + return AHAKEY_PROTO_OK; +} + +ahakey_proto_result_t ahakey_proto_parse_status( + const ahakey_proto_frame_t *frame, ahakey_proto_status_t *status) { + if (frame == NULL || status == NULL || + frame->cmd != AHAKEY_PROTO_CMD_STATUS_QUERY || frame->data_length < 7U) + return AHAKEY_PROTO_BAD_FRAME; + status->battery_percent = frame->data[0]; + status->signal_strength = frame->data[1]; + status->firmware_major = frame->data[2]; + status->firmware_minor = frame->data[3]; + status->work_mode = frame->data[4]; + status->light_mode = frame->data[5]; + status->switch_state = frame->data[6]; + return AHAKEY_PROTO_OK; +} diff --git a/devices/firmware/ahakey-x1/ahakey_proto.h b/devices/firmware/ahakey-x1/ahakey_proto.h new file mode 100644 index 0000000..6f95edb --- /dev/null +++ b/devices/firmware/ahakey-x1/ahakey_proto.h @@ -0,0 +1,148 @@ +#ifndef AHAKEY_PROTO_H +#define AHAKEY_PROTO_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Portable codec for the AhaKey X1 BLE configuration protocol (GATT service + * 0x7340), as published by the AhaKey team in AhakeyAI/desktop + * docs/ble-protocol.md. This file only builds and parses frames; the board + * port owns GATT, HID, and timing. + * + * Frame layout: [0xAA 0xBB] [cmd:1] [data:N] [0xCC 0xDD] (minimum 5 bytes). + * Responses arrive on the 0x7344 notify characteristic as + * [0xAA 0xBB] [cmd_echo:1] [status:1] [data:N] [0xCC 0xDD], except the + * status-query response, whose data starts immediately after the echo. + */ + +#define AHAKEY_PROTO_HEADER_0 0xAAU +#define AHAKEY_PROTO_HEADER_1 0xBBU +#define AHAKEY_PROTO_TAIL_0 0xCCU +#define AHAKEY_PROTO_TAIL_1 0xDDU +#define AHAKEY_PROTO_MIN_FRAME_BYTES 5U +#define AHAKEY_PROTO_MAX_DATA_BYTES 104U +#define AHAKEY_PROTO_MAX_FRAME_BYTES \ + (AHAKEY_PROTO_MIN_FRAME_BYTES + AHAKEY_PROTO_MAX_DATA_BYTES) + +#define AHAKEY_PROTO_NAME_MAX_BYTES 21U +#define AHAKEY_PROTO_KEY_DESCRIPTION_MAX_BYTES 20U +#define AHAKEY_PROTO_KEY_BINDING_MAX_BYTES 98U +#define AHAKEY_PROTO_MODE_COUNT 3U +#define AHAKEY_PROTO_KEY_COUNT 4U + +typedef enum { + AHAKEY_PROTO_CMD_STATUS_QUERY = 0x00, + AHAKEY_PROTO_CMD_CHANGE_NAME = 0x01, + AHAKEY_PROTO_CMD_CHANGE_APPEARANCE = 0x02, + AHAKEY_PROTO_CMD_SAVE_CONFIG = 0x04, + AHAKEY_PROTO_CMD_UPDATE_CUSTOM_KEY = 0x73, + AHAKEY_PROTO_CMD_PREPARE_WRITE = 0x80, + AHAKEY_PROTO_CMD_WRITE_RESULT = 0x81, + AHAKEY_PROTO_CMD_UPDATE_PIC = 0x82, + AHAKEY_PROTO_CMD_READ_PIC_STATE = 0x83, + AHAKEY_PROTO_CMD_UPDATE_STATE = 0x90 +} ahakey_proto_cmd_t; + +typedef enum { + AHAKEY_PROTO_KEY_SUB_SHORTCUT = 0x73, + AHAKEY_PROTO_KEY_SUB_MACRO = 0x74, + AHAKEY_PROTO_KEY_SUB_DESCRIPTION = 0x75 +} ahakey_proto_key_subtype_t; + +/* IDE state values written with UPDATE_STATE (0x90) to drive the LED strip. */ +typedef enum { + AHAKEY_STATE_NOTIFICATION = 0, + AHAKEY_STATE_PERMISSION_REQUEST = 1, + AHAKEY_STATE_POST_TOOL_USE = 2, + AHAKEY_STATE_PRE_TOOL_USE = 3, + AHAKEY_STATE_SESSION_START = 4, + AHAKEY_STATE_STOP = 5, + AHAKEY_STATE_TASK_COMPLETED = 6, + AHAKEY_STATE_USER_PROMPT_SUBMIT = 7, + AHAKEY_STATE_SESSION_END = 8 +} ahakey_state_t; + +typedef enum { + AHAKEY_PROTO_OK = 0, + AHAKEY_PROTO_BAD_FRAME, + AHAKEY_PROTO_BUFFER_TOO_SMALL +} ahakey_proto_result_t; + +typedef struct { + uint8_t cmd; + uint8_t data[AHAKEY_PROTO_MAX_DATA_BYTES]; + size_t data_length; +} ahakey_proto_frame_t; + +typedef struct { + uint8_t battery_percent; + uint8_t signal_strength; + uint8_t firmware_major; + uint8_t firmware_minor; + uint8_t work_mode; + uint8_t light_mode; + /* Physical approval lever: 0 = at rest (auto), non-zero = switched (ask). */ + uint8_t switch_state; +} ahakey_proto_status_t; + +/* + * Validate one complete frame and copy out cmd + data. The input must hold + * exactly one frame including header and tail. + */ +ahakey_proto_result_t ahakey_proto_decode(const uint8_t *bytes, size_t length, + ahakey_proto_frame_t *frame); + +/* Build any command frame. data may be NULL when data_length is 0. */ +ahakey_proto_result_t ahakey_proto_build(uint8_t cmd, const uint8_t *data, + size_t data_length, uint8_t *output, + size_t output_capacity, + size_t *output_length); + +/* UPDATE_STATE (0x90): sync one IDE state to the keyboard LED strip. */ +ahakey_proto_result_t ahakey_proto_build_update_state(ahakey_state_t state, + uint8_t *output, + size_t output_capacity, + size_t *output_length); + +/* + * Key description (UPDATE_CUSTOM_KEY subtype 0x75): an ASCII label of at most + * 20 bytes shown on the OLED for one key in one mode. Non-ASCII or overlong + * input is rejected, never truncated silently. + */ +ahakey_proto_result_t ahakey_proto_build_key_description( + uint8_t mode, uint8_t key_index, const char *ascii, uint8_t *output, + size_t output_capacity, size_t *output_length); + +/* CHANGE_NAME (0x01): UTF-8 name of at most 21 bytes; needs SAVE_CONFIG. */ +ahakey_proto_result_t ahakey_proto_build_change_name(const uint8_t *name, + size_t name_length, + uint8_t *output, + size_t output_capacity, + size_t *output_length); + +/* + * Parse the response to a command. For most commands the first data byte is a + * status byte (0 = success); the status query (0x00) is the exception and its + * payload starts immediately. Use ahakey_proto_parse_status for that one. + */ +ahakey_proto_result_t ahakey_proto_parse_response( + const ahakey_proto_frame_t *frame, uint8_t expected_cmd, uint8_t *status); + +/* + * Parse the status-query payload into fields. The published payload carries + * 7 fields plus one reserved byte; 7 or more data bytes are accepted. + */ +ahakey_proto_result_t ahakey_proto_parse_status( + const ahakey_proto_frame_t *frame, ahakey_proto_status_t *status); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/devices/firmware/ahakey-x1/developer-kit/README.md b/devices/firmware/ahakey-x1/developer-kit/README.md new file mode 100644 index 0000000..0e4110b --- /dev/null +++ b/devices/firmware/ahakey-x1/developer-kit/README.md @@ -0,0 +1,69 @@ +# AhaKey production-firmware port + +This directory is the narrow interface the AhaKey firmware team implements. +It does not require the Nexting App to understand AhaKey commands and it does +not put Agent credentials or routing into the keyboard. + +## What is already implemented + +`nexting_ahakey_port.c` owns the compatibility tunnel, Device Info handshake, +physical-key audio lifecycle, IMA ADPCM encoding, fragmentation, eight-credit +flow control, a ten-frame RAM queue, 500 ms stall cancellation, the two-minute +limit, and disconnect/error wiping. The portable implementation and hostile +edges run in host CTest. + +The tunnel coexists with the stock `0x7340` service: + +- writes beginning `A0` or `A3` on `0x7343` are Nexting control/flow chunks; +- notifications beginning `A1` or `A2` on `0x7344` are Nexting control/audio; +- existing `AA BB ... CC DD` traffic is forwarded unchanged to the stock + parser. + +This direct one-byte prefix is a firmware extension. Do not wrap Nexting +payloads in the stock `AA BB ... CC DD` configuration frame. + +## Board hooks to wire + +Fill `nexting_ahakey_port_ops_t` with the existing firmware primitives: + +1. `notify_7344` — enqueue one notification and return false if the controller + cannot accept it. +2. `vendor_7343_write` — call the existing stock command parser. +3. `microphone_start` — configure the device microphone for signed PCM16, + 16 kHz, mono, and exact 320-sample callbacks. It must not expose pre-roll. +4. `microphone_stop` — stop cleanly when the user submits. +5. `microphone_cancel` — stop immediately and wipe DMA/ring buffers. +6. `render_audio_state` — visibly render ready, receiving, transcribing, + submitted, and error. + +Call: + +```c +nexting_ahakey_port_on_7343_write(&port, value, length, bonded, encrypted); +nexting_ahakey_port_publish_device_info(&port, bonded, encrypted); +nexting_ahakey_port_voice_pressed(&port); +nexting_ahakey_port_voice_released(&port); +nexting_ahakey_port_on_pcm(&port, pcm_320); +nexting_ahakey_port_tick(&port); +``` + +The BLE connection must negotiate an ATT payload of at least 64 bytes. +For the supplied descriptor use MTU 247 / ATT payload 244 when the controller +supports it, and initialize the port with `att_payload_bytes = 244`. + +## Required production substitutions + +Generate Device Info from +`../nexting-ahakey-device-info.template.json`. Replace both the firmware +version and the per-device UUID, then serialize it as one compact JSON line +without a trailing newline (the port adds the JSONL terminator). The template +placeholders intentionally fail Host validation. Include `device-audio/1` only +after the real microphone hook passes the physical checklist. + +The public AhaKey firmware repository at commit +`7e950321f8a4c2f860a3f4d67de85ef32bf42d7c` publishes release HEX files and +CH582 flashing instructions, but not the buildable v1.1.0 production source or +a public PCM callback. Therefore this repository can provide and test the +complete port boundary, but cannot name the final CH582 ADC/I2S/PDM symbol or +claim board verification until AhaKey supplies that private source/hook and a +device is measured. diff --git a/devices/firmware/ahakey-x1/developer-kit/nexting_ahakey_port.c b/devices/firmware/ahakey-x1/developer-kit/nexting_ahakey_port.c new file mode 100644 index 0000000..5d71332 --- /dev/null +++ b/devices/firmware/ahakey-x1/developer-kit/nexting_ahakey_port.c @@ -0,0 +1,175 @@ +#include "nexting_ahakey_port.h" + +#include + +static uint64_t port_now_ms(void *context) { + nexting_ahakey_port_t *port = (nexting_ahakey_port_t *)context; + return port->ops.now_ms(port->context); +} + +static bool notify_logical(nexting_ahakey_port_t *port, uint8_t opcode, + const uint8_t *bytes, size_t length) { + if (port == NULL || bytes == NULL || length == 0U || + port->ops.notify_7344 == NULL || port->att_payload_bytes <= 1U) + return false; + const size_t chunk_capacity = port->att_payload_bytes - 1U; + uint8_t packet[NEXTING_AHAKEY_TUNNEL_MAX_ATT_PAYLOAD]; + size_t offset = 0U; + while (offset < length) { + const size_t remaining = length - offset; + const size_t chunk_length = + remaining < chunk_capacity ? remaining : chunk_capacity; + packet[0] = opcode; + memcpy(packet + 1U, bytes + offset, chunk_length); + if (!port->ops.notify_7344(packet, chunk_length + 1U, port->context)) { + memset(packet, 0, sizeof packet); + return false; + } + offset += chunk_length; + } + memset(packet, 0, sizeof packet); + return true; +} + +static void write_control(const uint8_t *bytes, size_t length, void *context) { + (void)notify_logical((nexting_ahakey_port_t *)context, + NEXTING_AHAKEY_TUNNEL_CONTROL_UP, bytes, length); +} + +static bool write_audio(const uint8_t *bytes, size_t length, void *context) { + return notify_logical((nexting_ahakey_port_t *)context, + NEXTING_AHAKEY_TUNNEL_AUDIO_UP, bytes, length); +} + +static bool microphone_start(uint32_t sample_rate, uint8_t channels, + uint16_t frame_ms, void *context) { + nexting_ahakey_port_t *port = (nexting_ahakey_port_t *)context; + return port->ops.microphone_start(sample_rate, channels, frame_ms, + port->context); +} + +static void microphone_stop(void *context) { + nexting_ahakey_port_t *port = (nexting_ahakey_port_t *)context; + port->ops.microphone_stop(port->context); +} + +static void microphone_cancel(void *context) { + nexting_ahakey_port_t *port = (nexting_ahakey_port_t *)context; + port->ops.microphone_cancel(port->context); +} + +static void render_audio_state(nexting_device_audio_state_t state, + void *context) { + nexting_ahakey_port_t *port = (nexting_ahakey_port_t *)context; + if (port->ops.render_audio_state != NULL) + port->ops.render_audio_state(state, port->context); +} + +static void render_approval(const nexting_device_state_t *state, + void *context) { + nexting_ahakey_port_t *port = (nexting_ahakey_port_t *)context; + if (port->ops.render_approval != NULL) + port->ops.render_approval(state, port->context); +} + +static void render_led(ahakey_state_t state, void *context) { + nexting_ahakey_port_t *port = (nexting_ahakey_port_t *)context; + if (port->ops.render_led != NULL) + port->ops.render_led(state, port->context); +} + +static void render_text(const nexting_device_text_payload_t *text, + void *context) { + nexting_ahakey_port_t *port = (nexting_ahakey_port_t *)context; + if (port->ops.render_text != NULL) + port->ops.render_text(text, port->context); +} + +bool nexting_ahakey_port_init(nexting_ahakey_port_t *port, + const nexting_ahakey_port_ops_t *ops, + size_t att_payload_bytes, + const uint8_t *device_info, + size_t device_info_length, void *context) { + if (port == NULL || ops == NULL || ops->now_ms == NULL || + ops->notify_7344 == NULL || ops->vendor_7343_write == NULL || + ops->microphone_start == NULL || ops->microphone_stop == NULL || + ops->microphone_cancel == NULL || ops->render_audio_state == NULL || + device_info == NULL || + device_info_length == 0U || + device_info_length > NEXTING_DEVICE_DEFAULT_MAX_MESSAGE_BYTES || + memchr(device_info, '\n', device_info_length) != NULL || + memchr(device_info, '\r', device_info_length) != NULL || + att_payload_bytes < 64U || + att_payload_bytes > NEXTING_AHAKEY_TUNNEL_MAX_ATT_PAYLOAD) + return false; + memset(port, 0, sizeof *port); + port->ops = *ops; + port->device_info = device_info; + port->device_info_length = device_info_length; + port->att_payload_bytes = att_payload_bytes; + port->context = context; + nexting_ahakey_init(&port->adapter, port_now_ms, write_control, + render_approval, render_led, render_text, port); + return nexting_ahakey_bind_device_audio( + &port->adapter, att_payload_bytes - 1U, write_audio, microphone_start, + microphone_stop, microphone_cancel, render_audio_state, port); +} + +bool nexting_ahakey_port_on_7343_write(nexting_ahakey_port_t *port, + const uint8_t *bytes, size_t length, + bool bonded, bool encrypted) { + if (port == NULL || bytes == NULL || length == 0U) + return false; + const uint8_t opcode = bytes[0]; + if (opcode != NEXTING_AHAKEY_TUNNEL_CONTROL_DOWN && + opcode != NEXTING_AHAKEY_TUNNEL_FLOW_DOWN) + return port->ops.vendor_7343_write(bytes, length, port->context); + if (!bonded || !encrypted || length == 1U) + return false; + return nexting_ahakey_receive(&port->adapter, bytes + 1U, length - 1U) == + NEXTING_DEVICE_OK; +} + +bool nexting_ahakey_port_publish_device_info(nexting_ahakey_port_t *port, + bool bonded, bool encrypted) { + if (port == NULL || !bonded || !encrypted) + return false; + if (!notify_logical(port, NEXTING_AHAKEY_TUNNEL_CONTROL_UP, + port->device_info, port->device_info_length)) + return false; + static const uint8_t newline = '\n'; + return notify_logical(port, NEXTING_AHAKEY_TUNNEL_CONTROL_UP, &newline, 1U); +} + +bool nexting_ahakey_port_voice_pressed(nexting_ahakey_port_t *port) { + return port != NULL && nexting_ahakey_voice_pressed(&port->adapter); +} + +bool nexting_ahakey_port_voice_released(nexting_ahakey_port_t *port) { + return port != NULL && nexting_ahakey_voice_released(&port->adapter); +} + +bool nexting_ahakey_port_voice_cancel(nexting_ahakey_port_t *port) { + return port != NULL && nexting_ahakey_voice_cancel(&port->adapter); +} + +bool nexting_ahakey_port_on_pcm( + nexting_ahakey_port_t *port, + const int16_t samples[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME]) { + return port != NULL && nexting_ahakey_on_pcm(&port->adapter, samples); +} + +void nexting_ahakey_port_on_microphone_error(nexting_ahakey_port_t *port) { + if (port != NULL) + nexting_ahakey_on_audio_hardware_error(&port->adapter); +} + +void nexting_ahakey_port_tick(nexting_ahakey_port_t *port) { + if (port != NULL) + nexting_ahakey_tick(&port->adapter); +} + +void nexting_ahakey_port_disconnect(nexting_ahakey_port_t *port) { + if (port != NULL) + nexting_ahakey_disconnect(&port->adapter); +} diff --git a/devices/firmware/ahakey-x1/developer-kit/nexting_ahakey_port.h b/devices/firmware/ahakey-x1/developer-kit/nexting_ahakey_port.h new file mode 100644 index 0000000..50d1685 --- /dev/null +++ b/devices/firmware/ahakey-x1/developer-kit/nexting_ahakey_port.h @@ -0,0 +1,81 @@ +#ifndef NEXTING_AHAKEY_PORT_H +#define NEXTING_AHAKEY_PORT_H + +#include +#include +#include + +#include "nexting_ahakey_adapter.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define NEXTING_AHAKEY_TUNNEL_CONTROL_DOWN 0xA0U +#define NEXTING_AHAKEY_TUNNEL_CONTROL_UP 0xA1U +#define NEXTING_AHAKEY_TUNNEL_AUDIO_UP 0xA2U +#define NEXTING_AHAKEY_TUNNEL_FLOW_DOWN 0xA3U +#define NEXTING_AHAKEY_TUNNEL_MAX_ATT_PAYLOAD 512U + +typedef struct { + uint64_t (*now_ms)(void *context); + /* Notify raw bytes on the existing encrypted 0x7344 characteristic. */ + bool (*notify_7344)(const uint8_t *bytes, size_t length, void *context); + /* Preserve stock AA BB ... CC DD traffic on the 0x7343 write path. */ + bool (*vendor_7343_write)(const uint8_t *bytes, size_t length, + void *context); + /* Production PCM driver: 16 kHz, signed 16-bit, mono, exact 320 samples. */ + bool (*microphone_start)(uint32_t sample_rate, uint8_t channels, + uint16_t frame_ms, void *context); + void (*microphone_stop)(void *context); + void (*microphone_cancel)(void *context); + nexting_ahakey_render_approval_fn render_approval; + nexting_ahakey_render_led_fn render_led; + nexting_ahakey_render_text_fn render_text; + nexting_ahakey_audio_render_state_fn render_audio_state; +} nexting_ahakey_port_ops_t; + +typedef struct { + nexting_ahakey_t adapter; + nexting_ahakey_port_ops_t ops; + const uint8_t *device_info; + size_t device_info_length; + size_t att_payload_bytes; + void *context; +} nexting_ahakey_port_t; + +/* device_info storage must remain valid for the life of port. */ +bool nexting_ahakey_port_init(nexting_ahakey_port_t *port, + const nexting_ahakey_port_ops_t *ops, + size_t att_payload_bytes, + const uint8_t *device_info, + size_t device_info_length, void *context); + +/* + * Call from the 0x7343 write callback. Direct A0/A3 tunnel packets are + * consumed; stock AA BB vendor frames are forwarded unchanged. + */ +bool nexting_ahakey_port_on_7343_write(nexting_ahakey_port_t *port, + const uint8_t *bytes, size_t length, + bool bonded, bool encrypted); + +/* Call after encrypted 0x7344 notifications are enabled. */ +bool nexting_ahakey_port_publish_device_info(nexting_ahakey_port_t *port, + bool bonded, bool encrypted); + +/* Board ISR/task entry points. PCM must be post-key input, never pre-roll. */ +bool nexting_ahakey_port_voice_pressed(nexting_ahakey_port_t *port); +bool nexting_ahakey_port_voice_released(nexting_ahakey_port_t *port); +bool nexting_ahakey_port_voice_cancel(nexting_ahakey_port_t *port); +bool nexting_ahakey_port_on_pcm( + nexting_ahakey_port_t *port, + const int16_t samples[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME]); +void nexting_ahakey_port_on_microphone_error(nexting_ahakey_port_t *port); +void nexting_ahakey_port_tick(nexting_ahakey_port_t *port); +void nexting_ahakey_port_disconnect(nexting_ahakey_port_t *port); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/devices/firmware/ahakey-x1/nexting-ahakey-device-info.template.json b/devices/firmware/ahakey-x1/nexting-ahakey-device-info.template.json new file mode 100644 index 0000000..0387afc --- /dev/null +++ b/devices/firmware/ahakey-x1/nexting-ahakey-device-info.template.json @@ -0,0 +1,53 @@ +{ + "protocol": "nexting-device", + "spec": "0.4.0-experimental.1", + "wire": [1], + "profiles": ["approval/1", "status/1", "keys/1", "text/1", "device-audio/1"], + "model": "ahakey-x1", + "fw": "REPLACE-WITH-PRODUCTION-FIRMWARE-VERSION", + "max_message_bytes": 4096, + "max_summary_bytes": 240, + "device_id": "REPLACE-WITH-PER-DEVICE-UUID", + "manufacturer": "AhaKey", + "display_name": "AhaKey X1", + "button_count": 4, + "approval_button_count": 2, + "custom_button_count": 2, + "statusSlots": 1, + "battery_service": true, + "display": { "type": "oled", "width": 160, "height": 80 }, + "audio": { + "source": "device_microphone", + "codecs": ["ima_adpcm"], + "sample_rates": [16000], + "channels": 1, + "frame_ms": [20], + "max_duration_ms": 120000, + "startup_buffer_ms": 200 + }, + "vendor": { + "namespace": "ai.ahakey.x1", + "facts": [ + { + "key": "integration", + "label": "Integration", + "value": "Nexting Compatibility Tunnel in AhaKey firmware" + }, + { + "key": "key_mapping", + "label": "Key mapping", + "value": "F18 allow, F19 deny, F13-F16 slots 0-3" + }, + { + "key": "lever", + "label": "Approval lever", + "value": "keys/1 slot 7 press/release" + }, + { + "key": "audio_path", + "label": "Audio path", + "value": "On-device microphone to IMA ADPCM over encrypted BLE" + } + ] + } +} diff --git a/devices/firmware/ahakey-x1/nexting_ahakey_adapter.c b/devices/firmware/ahakey-x1/nexting_ahakey_adapter.c new file mode 100644 index 0000000..dc2827e --- /dev/null +++ b/devices/firmware/ahakey-x1/nexting_ahakey_adapter.c @@ -0,0 +1,303 @@ +#include "nexting_ahakey_adapter.h" + +#include + +static uint64_t adapter_now_ms(const nexting_ahakey_t *adapter) { + if (adapter == NULL || adapter->now_ms == NULL) + return 0; + return adapter->now_ms(adapter->context); +} + +static uint64_t audio_now_ms(void *context) { + return adapter_now_ms((const nexting_ahakey_t *)context); +} + +static void audio_write_control(const nexting_device_message_t *message, + void *context); + +static ahakey_state_t led_for_agent_state(nexting_device_agent_state_t state) { + switch (state) { + case NEXTING_DEVICE_AGENT_STATE_NEEDS_INPUT: + return AHAKEY_STATE_PERMISSION_REQUEST; + case NEXTING_DEVICE_AGENT_STATE_WORKING: + case NEXTING_DEVICE_AGENT_STATE_THINKING: + return AHAKEY_STATE_PRE_TOOL_USE; + case NEXTING_DEVICE_AGENT_STATE_COMPLETE: + return AHAKEY_STATE_TASK_COMPLETED; + case NEXTING_DEVICE_AGENT_STATE_ERROR: + return AHAKEY_STATE_NOTIFICATION; + case NEXTING_DEVICE_AGENT_STATE_IDLE: + case NEXTING_DEVICE_AGENT_STATE_NONE: + default: + return AHAKEY_STATE_STOP; + } +} + +/* + * A pending approval outranks status on the shared strip (the approval + * channel renders separately from status). Otherwise the lowest-numbered + * occupied slot is rendered and the rest are ignored, per the status/1 + * rule for devices with fewer indicators than slots. + */ +ahakey_state_t nexting_ahakey_resolve_led(const nexting_ahakey_t *adapter) { + if (adapter == NULL) + return AHAKEY_STATE_STOP; + if (adapter->approval.phase == NEXTING_DEVICE_PHASE_PENDING) + return AHAKEY_STATE_PERMISSION_REQUEST; + for (size_t i = 0; i < NEXTING_DEVICE_STATUS_MAX_AGENTS; ++i) { + if (adapter->status.occupied[i]) + return led_for_agent_state(adapter->status.slots[i].state); + } + return AHAKEY_STATE_STOP; +} + +static void render_led_if_changed(nexting_ahakey_t *adapter) { + if (adapter == NULL) + return; + const ahakey_state_t resolved = nexting_ahakey_resolve_led(adapter); + if (resolved == adapter->led) + return; + adapter->led = resolved; + if (adapter->render_led != NULL) + adapter->render_led(resolved, adapter->context); +} + +static void render_approval(const nexting_ahakey_t *adapter) { + if (adapter != NULL && adapter->render_approval != NULL) + adapter->render_approval(&adapter->approval, adapter->context); +} + +static void handle_message(const nexting_device_message_t *message, + void *context) { + nexting_ahakey_t *adapter = (nexting_ahakey_t *)context; + if (adapter == NULL || message == NULL) + return; + + switch (message->type) { + case NEXTING_DEVICE_MESSAGE_PRESENT: + if (nexting_device_state_on_present(&adapter->approval, message, + adapter_now_ms(adapter)) == + NEXTING_DEVICE_OK) + render_approval(adapter); + break; + case NEXTING_DEVICE_MESSAGE_RESOLVED: + if (nexting_device_state_on_resolved(&adapter->approval, message) == + NEXTING_DEVICE_OK) + render_approval(adapter); + break; + case NEXTING_DEVICE_MESSAGE_STATUS: + (void)nexting_device_status_on_message(&adapter->status, message); + break; + case NEXTING_DEVICE_MESSAGE_KEYMAP: + /* keys/1 keymap is a full volatile replacement of the enabled set. */ + memset(adapter->key_enabled, 0, sizeof adapter->key_enabled); + for (size_t i = 0; i < message->interaction.keymap.key_count; ++i) { + const nexting_device_key_presentation_t *key = + &message->interaction.keymap.keys[i]; + if (key->slot < NEXTING_DEVICE_KEYS_MAX && key->enabled) + adapter->key_enabled[key->slot] = true; + } + break; + case NEXTING_DEVICE_MESSAGE_TEXT: + if (adapter->render_text != NULL) + adapter->render_text(&message->interaction.text, adapter->context); + break; + case NEXTING_DEVICE_MESSAGE_AUDIO_CONFIG: + case NEXTING_DEVICE_MESSAGE_AUDIO_CREDIT: + case NEXTING_DEVICE_MESSAGE_AUDIO_CANCEL: + case NEXTING_DEVICE_MESSAGE_AUDIO_STATE: + (void)nexting_ahakey_audio_on_message(&adapter->audio, message); + break; + default: + /* Answers and errors are host-facing; a device does not render them. */ + break; + } + render_led_if_changed(adapter); +} + +/* Only answers and key events go uplink; both encode well under 256 bytes. */ +#define NEXTING_AHAKEY_UPLINK_CAPACITY 256U + +static void write_message(nexting_ahakey_t *adapter, + const nexting_device_message_t *message) { + char wire[NEXTING_AHAKEY_UPLINK_CAPACITY]; + size_t wire_length = 0; + if (adapter == NULL || message == NULL || adapter->write_frame == NULL || + nexting_device_encode(message, wire, sizeof wire, &wire_length) != + NEXTING_DEVICE_OK) + return; + adapter->write_frame((const uint8_t *)wire, wire_length, adapter->context); +} + +static void audio_write_control(const nexting_device_message_t *message, + void *context) { + write_message((nexting_ahakey_t *)context, message); +} + +void nexting_ahakey_init(nexting_ahakey_t *adapter, + nexting_ahakey_now_ms_fn now_ms, + nexting_ahakey_write_frame_fn write_frame, + nexting_ahakey_render_approval_fn render_approval, + nexting_ahakey_render_led_fn render_led, + nexting_ahakey_render_text_fn render_text, + void *context) { + if (adapter == NULL) + return; + memset(adapter, 0, sizeof *adapter); + nexting_device_state_init(&adapter->approval); + nexting_device_status_init(&adapter->status); + nexting_device_stream_init(&adapter->stream, adapter->stream_storage, + sizeof adapter->stream_storage); + adapter->led = AHAKEY_STATE_STOP; + adapter->next_sequence = 1U; + adapter->now_ms = now_ms; + adapter->write_frame = write_frame; + adapter->render_approval = render_approval; + adapter->render_led = render_led; + adapter->render_text = render_text; + adapter->context = context; + nexting_ahakey_audio_init(&adapter->audio, audio_now_ms, + audio_write_control, adapter); +} + +nexting_device_result_t nexting_ahakey_receive(nexting_ahakey_t *adapter, + const uint8_t *bytes, + size_t length) { + if (adapter == NULL || (bytes == NULL && length != 0)) + return NEXTING_DEVICE_BAD_MESSAGE; + return nexting_device_stream_push(&adapter->stream, bytes, length, + handle_message, adapter); +} + +nexting_device_result_t nexting_ahakey_choose(nexting_ahakey_t *adapter, + nexting_device_choice_t choice) { + if (adapter == NULL || adapter->write_frame == NULL) + return NEXTING_DEVICE_BAD_MESSAGE; + nexting_device_message_t answer = {0}; + nexting_device_result_t result = nexting_device_state_choose( + &adapter->approval, choice, adapter_now_ms(adapter), &answer); + if (result == NEXTING_DEVICE_OK) { + write_message(adapter, &answer); + render_approval(adapter); + render_led_if_changed(adapter); + } + return result; +} + +static bool emit_key_event(nexting_ahakey_t *adapter, uint8_t slot, + nexting_device_gesture_t gesture) { + /* A device emits no event for a disabled or undeclared slot. */ + if (adapter == NULL || adapter->write_frame == NULL || + slot >= NEXTING_DEVICE_KEYS_MAX || !adapter->key_enabled[slot]) + return false; + nexting_device_message_t event = {0}; + event.type = NEXTING_DEVICE_MESSAGE_KEY_EVENT; + event.interaction.slot = slot; + event.interaction.gesture = gesture; + event.interaction.sequence = adapter->next_sequence; + adapter->next_sequence += 1U; + write_message(adapter, &event); + return true; +} + +bool nexting_ahakey_hid_key(nexting_ahakey_t *adapter, uint8_t usage_id, + bool pressed) { + if (adapter == NULL || !pressed) + return false; + if (usage_id == NEXTING_AHAKEY_HID_ALLOW) + return nexting_ahakey_choose(adapter, NEXTING_DEVICE_CHOICE_ALLOW) == + NEXTING_DEVICE_OK; + if (usage_id == NEXTING_AHAKEY_HID_DENY) + return nexting_ahakey_choose(adapter, NEXTING_DEVICE_CHOICE_DENY) == + NEXTING_DEVICE_OK; + if (usage_id >= NEXTING_AHAKEY_HID_F13 && + usage_id < NEXTING_AHAKEY_HID_F13 + NEXTING_AHAKEY_KEY_SLOT_COUNT) + return emit_key_event(adapter, usage_id - NEXTING_AHAKEY_HID_F13, + NEXTING_DEVICE_GESTURE_PRESS); + return false; +} + +bool nexting_ahakey_lever(nexting_ahakey_t *adapter, uint8_t switch_state) { + if (adapter == NULL) + return false; + if (!adapter->lever_known) { + adapter->lever_known = true; + adapter->lever_state = switch_state; + return false; + } + if (switch_state == adapter->lever_state) + return false; + adapter->lever_state = switch_state; + return emit_key_event(adapter, NEXTING_AHAKEY_LEVER_SLOT, + switch_state != 0U ? NEXTING_DEVICE_GESTURE_PRESS + : NEXTING_DEVICE_GESTURE_RELEASE); +} + +bool nexting_ahakey_bind_device_audio( + nexting_ahakey_t *adapter, size_t maximum_packet_bytes, + nexting_ahakey_audio_write_packet_fn write_packet, + nexting_ahakey_audio_hw_start_fn hardware_start, + nexting_ahakey_audio_hw_stop_fn hardware_stop, + nexting_ahakey_audio_hw_cancel_fn hardware_cancel, + nexting_ahakey_audio_render_state_fn render_state, void *audio_context) { + if (adapter == NULL) + return false; + return nexting_ahakey_audio_bind( + &adapter->audio, maximum_packet_bytes, write_packet, hardware_start, + hardware_stop, hardware_cancel, render_state, audio_context); +} + +bool nexting_ahakey_voice_pressed(nexting_ahakey_t *adapter) { + return adapter != NULL && nexting_ahakey_audio_voice_pressed(&adapter->audio); +} + +bool nexting_ahakey_voice_released(nexting_ahakey_t *adapter) { + return adapter != NULL && + nexting_ahakey_audio_voice_released(&adapter->audio); +} + +bool nexting_ahakey_voice_cancel(nexting_ahakey_t *adapter) { + return adapter != NULL && + nexting_ahakey_audio_cancel_physical(&adapter->audio); +} + +bool nexting_ahakey_on_pcm( + nexting_ahakey_t *adapter, + const int16_t samples[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME]) { + return adapter != NULL && nexting_ahakey_audio_on_pcm(&adapter->audio, samples); +} + +void nexting_ahakey_on_audio_hardware_error(nexting_ahakey_t *adapter) { + if (adapter != NULL) + nexting_ahakey_audio_on_hardware_error(&adapter->audio); +} + +void nexting_ahakey_tick(nexting_ahakey_t *adapter) { + if (adapter == NULL) + return; + const uint64_t now_ms = adapter_now_ms(adapter); + if (nexting_device_state_tick(&adapter->approval, now_ms)) { + render_approval(adapter); + render_led_if_changed(adapter); + } + nexting_device_message_t answer = {0}; + if (nexting_device_state_retry_answer(&adapter->approval, now_ms, &answer)) + write_message(adapter, &answer); + nexting_ahakey_audio_tick(&adapter->audio); +} + +void nexting_ahakey_disconnect(nexting_ahakey_t *adapter) { + if (adapter == NULL) + return; + nexting_device_state_disconnect(&adapter->approval); + nexting_device_status_disconnect(&adapter->status); + nexting_device_stream_reset(&adapter->stream); + nexting_ahakey_audio_disconnect(&adapter->audio); + adapter->lever_known = false; + /* The Host keymap is volatile: it is re-presented after reconnect. */ + memset(adapter->key_enabled, 0, sizeof adapter->key_enabled); + adapter->led = AHAKEY_STATE_STOP; + render_approval(adapter); + if (adapter->render_led != NULL) + adapter->render_led(AHAKEY_STATE_STOP, adapter->context); +} diff --git a/devices/firmware/ahakey-x1/nexting_ahakey_adapter.h b/devices/firmware/ahakey-x1/nexting_ahakey_adapter.h new file mode 100644 index 0000000..dae8c24 --- /dev/null +++ b/devices/firmware/ahakey-x1/nexting_ahakey_adapter.h @@ -0,0 +1,141 @@ +#ifndef NEXTING_AHAKEY_ADAPTER_H +#define NEXTING_AHAKEY_ADAPTER_H + +#include +#include +#include + +#include "ahakey_proto.h" +#include "nexting_ahakey_audio.h" +#include "nexting_device.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * Mapping layer between the portable Nexting C99 state machine and the + * AhaKey X1 hardware concepts. The adapter is transport-agnostic: a native + * X1 firmware port feeds it Nexting wire bytes from the GATT downlink and + * renders to its own LED/OLED, while a bridge dongle feeds it the same bytes + * and forwards the rendered states to a stock keyboard over the published + * AhaKey 0x7340 protocol. + * + * Stock-firmware key mapping (configure with the vendor tool): + * F18 (0x6D) -> approval Allow, F19 (0x6E) -> approval Deny, + * F13-F16 (0x68-0x6B) -> keys/1 slots 0-3. + * The physical lever reports through key slot NEXTING_AHAKEY_LEVER_SLOT: + * press when it leaves the rest position, release when it returns. + */ + +#define NEXTING_AHAKEY_STREAM_CAPACITY \ + (NEXTING_DEVICE_DEFAULT_MAX_MESSAGE_BYTES + 1U) +#define NEXTING_AHAKEY_LEVER_SLOT 7U +#define NEXTING_AHAKEY_KEY_SLOT_COUNT 4U + +#define NEXTING_AHAKEY_HID_F13 0x68U +#define NEXTING_AHAKEY_HID_F14 0x69U +#define NEXTING_AHAKEY_HID_F15 0x6AU +#define NEXTING_AHAKEY_HID_F16 0x6BU +#define NEXTING_AHAKEY_HID_ALLOW 0x6DU /* F18 */ +#define NEXTING_AHAKEY_HID_DENY 0x6EU /* F19 */ + +typedef uint64_t (*nexting_ahakey_now_ms_fn)(void *context); +typedef void (*nexting_ahakey_write_frame_fn)(const uint8_t *bytes, + size_t length, void *context); +typedef void (*nexting_ahakey_render_approval_fn)( + const nexting_device_state_t *state, void *context); +typedef void (*nexting_ahakey_render_led_fn)(ahakey_state_t state, + void *context); +typedef void (*nexting_ahakey_render_text_fn)( + const nexting_device_text_payload_t *text, void *context); + +typedef struct { + nexting_device_state_t approval; + nexting_device_status_state_t status; + nexting_device_stream_t stream; + uint8_t stream_storage[NEXTING_AHAKEY_STREAM_CAPACITY]; + ahakey_state_t led; + uint8_t lever_state; + bool lever_known; + uint32_t next_sequence; + /* Last Host keymap replacement; keys/1 events emit only for enabled slots. */ + bool key_enabled[NEXTING_DEVICE_KEYS_MAX]; + nexting_ahakey_audio_t audio; + nexting_ahakey_now_ms_fn now_ms; + nexting_ahakey_write_frame_fn write_frame; + nexting_ahakey_render_approval_fn render_approval; + nexting_ahakey_render_led_fn render_led; + nexting_ahakey_render_text_fn render_text; + void *context; +} nexting_ahakey_t; + +void nexting_ahakey_init(nexting_ahakey_t *adapter, + nexting_ahakey_now_ms_fn now_ms, + nexting_ahakey_write_frame_fn write_frame, + nexting_ahakey_render_approval_fn render_approval, + nexting_ahakey_render_led_fn render_led, + nexting_ahakey_render_text_fn render_text, + void *context); + +/* Feed Nexting wire bytes (JSONL) from the Host downlink. */ +nexting_device_result_t nexting_ahakey_receive(nexting_ahakey_t *adapter, + const uint8_t *bytes, + size_t length); + +/* Answer the pending approval; writes the answer frame on success. */ +nexting_device_result_t nexting_ahakey_choose(nexting_ahakey_t *adapter, + nexting_device_choice_t choice); + +/* + * Route one stock-firmware HID usage ID. F18/F19 answer the pending + * approval; F13-F16 emit keys/1 events for slots 0-3 (only when the Host + * keymap has declared and enabled that slot); anything else is ignored and + * returns false. + */ +bool nexting_ahakey_hid_key(nexting_ahakey_t *adapter, uint8_t usage_id, + bool pressed); + +/* + * Report the polled lever (SwitchState from the AhaKey status response). + * The first report only establishes the baseline; later changes emit a + * keys/1 event on NEXTING_AHAKEY_LEVER_SLOT when the Host keymap has + * declared and enabled that slot. + */ +bool nexting_ahakey_lever(nexting_ahakey_t *adapter, uint8_t switch_state); + +/* + * Bind the real device microphone and BLE audio-notify port. The App receives + * this microphone's PCM; these APIs never start a phone microphone. + */ +bool nexting_ahakey_bind_device_audio( + nexting_ahakey_t *adapter, size_t maximum_packet_bytes, + nexting_ahakey_audio_write_packet_fn write_packet, + nexting_ahakey_audio_hw_start_fn hardware_start, + nexting_ahakey_audio_hw_stop_fn hardware_stop, + nexting_ahakey_audio_hw_cancel_fn hardware_cancel, + nexting_ahakey_audio_render_state_fn render_state, void *audio_context); +bool nexting_ahakey_voice_pressed(nexting_ahakey_t *adapter); +bool nexting_ahakey_voice_released(nexting_ahakey_t *adapter); +bool nexting_ahakey_voice_cancel(nexting_ahakey_t *adapter); +bool nexting_ahakey_on_pcm( + nexting_ahakey_t *adapter, + const int16_t samples[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME]); +void nexting_ahakey_on_audio_hardware_error(nexting_ahakey_t *adapter); + +void nexting_ahakey_tick(nexting_ahakey_t *adapter); +void nexting_ahakey_disconnect(nexting_ahakey_t *adapter); + +/* + * Resolve the LED state the hardware should show right now: a pending + * approval outranks status (the approval channel renders on the same + * strip), otherwise the lowest-numbered occupied status slot wins and + * higher slots are ignored, per the status/1 rendering rule. + */ +ahakey_state_t nexting_ahakey_resolve_led(const nexting_ahakey_t *adapter); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/devices/firmware/ahakey-x1/nexting_ahakey_audio.c b/devices/firmware/ahakey-x1/nexting_ahakey_audio.c new file mode 100644 index 0000000..3f5816e --- /dev/null +++ b/devices/firmware/ahakey-x1/nexting_ahakey_audio.c @@ -0,0 +1,452 @@ +#include "nexting_ahakey_audio.h" + +#include + +static uint64_t audio_now_ms(const nexting_ahakey_audio_t *audio) { + if (audio == NULL || audio->now_ms == NULL) + return 0U; + return audio->now_ms(audio->protocol_context); +} + +static void render(nexting_ahakey_audio_t *audio, + nexting_device_audio_state_t state) { + if (audio != NULL && audio->render_state != NULL) + audio->render_state(state, audio->hardware_context); +} + +static void emit(nexting_ahakey_audio_t *audio, + const nexting_device_message_t *message) { + if (audio != NULL && audio->write_control != NULL) + audio->write_control(message, audio->protocol_context); +} + +static void wipe_queue(nexting_ahakey_audio_t *audio) { + if (audio == NULL) + return; + memset(audio->queued_frames, 0, sizeof audio->queued_frames); + audio->queue_head = 0U; + audio->queue_count = 0U; + audio->stall_active = false; + audio->stall_started_ms = 0U; +} + +static void reset_stream(nexting_ahakey_audio_t *audio, + nexting_ahakey_audio_phase_t next_phase) { + wipe_queue(audio); + audio->phase = next_phase; + audio->next_sequence = 0U; + audio->sent_frame_count = 0U; + audio->last_ack_sequence = 0U; + audio->has_ack = false; + audio->key_down = false; + audio->latched = false; + audio->capture_started_ms = 0U; + audio->drain_reason = NEXTING_DEVICE_AUDIO_END_NONE; +} + +static void emit_cancel(nexting_ahakey_audio_t *audio, + nexting_device_audio_cancel_reason_t reason) { + nexting_device_message_t message = {0}; + message.type = NEXTING_DEVICE_MESSAGE_AUDIO_CANCEL; + message.interaction.audio.epoch = audio->epoch; + message.interaction.audio.has_stream_id = true; + message.interaction.audio.stream_id = audio->stream_id; + message.interaction.audio.cancel_reason = reason; + emit(audio, &message); +} + +static void cancel_stream(nexting_ahakey_audio_t *audio, + nexting_device_audio_cancel_reason_t reason, + bool notify_host) { + if (audio == NULL) + return; + const bool was_active = audio->phase == NEXTING_AHAKEY_AUDIO_CAPTURING || + audio->phase == NEXTING_AHAKEY_AUDIO_DRAINING; + if (was_active) + audio->phase = NEXTING_AHAKEY_AUDIO_DRAINING; + if (was_active && audio->hardware_cancel != NULL) + audio->hardware_cancel(audio->hardware_context); + if (notify_host && was_active) + emit_cancel(audio, reason); + const nexting_ahakey_audio_phase_t next_phase = + audio->phase == NEXTING_AHAKEY_AUDIO_UNBOUND + ? NEXTING_AHAKEY_AUDIO_UNBOUND + : (audio->phase == NEXTING_AHAKEY_AUDIO_UNCONFIGURED + ? NEXTING_AHAKEY_AUDIO_UNCONFIGURED + : NEXTING_AHAKEY_AUDIO_READY); + reset_stream(audio, next_phase); + if (next_phase == NEXTING_AHAKEY_AUDIO_READY) + render(audio, NEXTING_DEVICE_AUDIO_STATE_READY); +} + +static void write_u32(uint8_t *bytes, size_t offset, uint32_t value) { + bytes[offset] = (uint8_t)value; + bytes[offset + 1U] = (uint8_t)(value >> 8U); + bytes[offset + 2U] = (uint8_t)(value >> 16U); + bytes[offset + 3U] = (uint8_t)(value >> 24U); +} + +static bool send_frame(nexting_ahakey_audio_t *audio, const uint8_t *frame) { + if (audio == NULL || frame == NULL || audio->write_packet == NULL) + return false; + if (audio->maximum_packet_bytes >= NEXTING_DEVICE_AUDIO_FRAME_BYTES) + return audio->write_packet(frame, NEXTING_DEVICE_AUDIO_FRAME_BYTES, + audio->hardware_context); + if (audio->maximum_packet_bytes <= NEXTING_AHAKEY_AUDIO_FRAGMENT_HEADER_BYTES) + return false; + const size_t payload_bytes = + audio->maximum_packet_bytes - NEXTING_AHAKEY_AUDIO_FRAGMENT_HEADER_BYTES; + const size_t fragment_count = + (NEXTING_DEVICE_AUDIO_FRAME_BYTES + payload_bytes - 1U) / payload_bytes; + if (fragment_count == 0U || + fragment_count > NEXTING_AHAKEY_AUDIO_MAX_FRAGMENTS) + return false; + + uint8_t packet[NEXTING_DEVICE_AUDIO_FRAME_BYTES]; + for (size_t index = 0U; index < fragment_count; ++index) { + const size_t lower = index * payload_bytes; + const size_t remaining = NEXTING_DEVICE_AUDIO_FRAME_BYTES - lower; + const size_t length = remaining < payload_bytes ? remaining : payload_bytes; + packet[0] = NEXTING_DEVICE_AUDIO_WIRE_VERSION; + packet[1] = 2U; + packet[2] = (uint8_t)index; + packet[3] = (uint8_t)fragment_count; + write_u32(packet, 4U, audio->stream_id); + write_u32(packet, 8U, audio->sent_frame_count); + memcpy(packet + NEXTING_AHAKEY_AUDIO_FRAGMENT_HEADER_BYTES, frame + lower, + length); + if (!audio->write_packet( + packet, NEXTING_AHAKEY_AUDIO_FRAGMENT_HEADER_BYTES + length, + audio->hardware_context)) + return false; + } + memset(packet, 0, sizeof packet); + return true; +} + +static void finish_end(nexting_ahakey_audio_t *audio) { + if (audio == NULL || audio->next_sequence == 0U) + return; + nexting_device_message_t message = {0}; + message.type = NEXTING_DEVICE_MESSAGE_AUDIO_END; + message.interaction.audio.epoch = audio->epoch; + message.interaction.audio.has_stream_id = true; + message.interaction.audio.stream_id = audio->stream_id; + message.interaction.audio.last_sequence = audio->next_sequence - 1U; + message.interaction.audio.sample_count = + audio->next_sequence * NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME; + message.interaction.audio.end_reason = audio->drain_reason; + emit(audio, &message); + reset_stream(audio, NEXTING_AHAKEY_AUDIO_WAITING_RESULT); + render(audio, NEXTING_DEVICE_AUDIO_STATE_TRANSCRIBING); +} + +static bool flush_queue(nexting_ahakey_audio_t *audio) { + if (audio == NULL) + return false; + while (audio->credits > 0U && audio->queue_count > 0U) { + uint8_t *frame = audio->queued_frames[audio->queue_head]; + if (!send_frame(audio, frame)) { + cancel_stream(audio, NEXTING_DEVICE_AUDIO_CANCEL_TRANSPORT, true); + return false; + } + memset(frame, 0, NEXTING_DEVICE_AUDIO_FRAME_BYTES); + audio->queue_head = + (uint8_t)((audio->queue_head + 1U) % NEXTING_AHAKEY_AUDIO_QUEUE_FRAMES); + audio->queue_count -= 1U; + audio->credits -= 1U; + audio->sent_frame_count += 1U; + } + if (audio->queue_count == 0U) { + audio->stall_active = false; + audio->stall_started_ms = 0U; + if (audio->phase == NEXTING_AHAKEY_AUDIO_DRAINING) + finish_end(audio); + } else if (audio->credits == 0U && !audio->stall_active) { + audio->stall_active = true; + audio->stall_started_ms = audio_now_ms(audio); + } + return true; +} + +static bool begin_stream(nexting_ahakey_audio_t *audio) { + if (audio == NULL || audio->phase != NEXTING_AHAKEY_AUDIO_READY || + audio->hardware_start == NULL) + return false; + reset_stream(audio, NEXTING_AHAKEY_AUDIO_READY); + audio->credits = NEXTING_AHAKEY_AUDIO_INITIAL_CREDITS; + audio->next_stream_nonce += 1U; + audio->stream_id = audio->epoch ^ (uint32_t)audio_now_ms(audio) ^ + (audio->next_stream_nonce * 0x9E3779B9U); + if (audio->stream_id == 0U) + audio->stream_id = audio->next_stream_nonce; + if (!audio->hardware_start(NEXTING_DEVICE_AUDIO_SAMPLE_RATE, 1U, + NEXTING_DEVICE_AUDIO_FRAME_MS, + audio->hardware_context)) { + render(audio, NEXTING_DEVICE_AUDIO_STATE_ERROR); + return false; + } + audio->phase = NEXTING_AHAKEY_AUDIO_CAPTURING; + audio->key_down = true; + audio->capture_started_ms = audio_now_ms(audio); + + nexting_device_message_t message = {0}; + message.type = NEXTING_DEVICE_MESSAGE_AUDIO_BEGIN; + message.interaction.audio.epoch = audio->epoch; + message.interaction.audio.has_stream_id = true; + message.interaction.audio.stream_id = audio->stream_id; + message.interaction.audio.codec = + NEXTING_DEVICE_AUDIO_CONTROL_CODEC_IMA_ADPCM; + message.interaction.audio.sample_rate = NEXTING_DEVICE_AUDIO_SAMPLE_RATE; + message.interaction.audio.channels = 1U; + message.interaction.audio.frame_ms = NEXTING_DEVICE_AUDIO_FRAME_MS; + emit(audio, &message); + render(audio, NEXTING_DEVICE_AUDIO_STATE_RECEIVING); + return true; +} + +static bool stop_stream(nexting_ahakey_audio_t *audio, + nexting_device_audio_end_reason_t reason) { + if (audio == NULL || audio->phase != NEXTING_AHAKEY_AUDIO_CAPTURING) + return false; + audio->phase = NEXTING_AHAKEY_AUDIO_DRAINING; + audio->key_down = false; + audio->latched = false; + audio->drain_reason = reason; + if (audio->hardware_stop != NULL) + audio->hardware_stop(audio->hardware_context); + if (audio->next_sequence == 0U) { + cancel_stream(audio, NEXTING_DEVICE_AUDIO_CANCEL_LOCAL, true); + return false; + } + return flush_queue(audio); +} + +void nexting_ahakey_audio_init( + nexting_ahakey_audio_t *audio, nexting_ahakey_audio_now_ms_fn now_ms, + nexting_ahakey_audio_write_control_fn write_control, + void *protocol_context) { + if (audio == NULL) + return; + memset(audio, 0, sizeof *audio); + audio->phase = NEXTING_AHAKEY_AUDIO_UNBOUND; + audio->now_ms = now_ms; + audio->write_control = write_control; + audio->protocol_context = protocol_context; +} + +bool nexting_ahakey_audio_bind( + nexting_ahakey_audio_t *audio, size_t maximum_packet_bytes, + nexting_ahakey_audio_write_packet_fn write_packet, + nexting_ahakey_audio_hw_start_fn hardware_start, + nexting_ahakey_audio_hw_stop_fn hardware_stop, + nexting_ahakey_audio_hw_cancel_fn hardware_cancel, + nexting_ahakey_audio_render_state_fn render_state, void *hardware_context) { + if (audio == NULL || write_packet == NULL || hardware_start == NULL || + hardware_stop == NULL || hardware_cancel == NULL || + maximum_packet_bytes < 60U || maximum_packet_bytes > 511U) + return false; + const size_t fragment_payload = + maximum_packet_bytes - NEXTING_AHAKEY_AUDIO_FRAGMENT_HEADER_BYTES; + if (maximum_packet_bytes < NEXTING_DEVICE_AUDIO_FRAME_BYTES && + (NEXTING_DEVICE_AUDIO_FRAME_BYTES + fragment_payload - 1U) / + fragment_payload > + NEXTING_AHAKEY_AUDIO_MAX_FRAGMENTS) + return false; + audio->maximum_packet_bytes = maximum_packet_bytes; + audio->write_packet = write_packet; + audio->hardware_start = hardware_start; + audio->hardware_stop = hardware_stop; + audio->hardware_cancel = hardware_cancel; + audio->render_state = render_state; + audio->hardware_context = hardware_context; + reset_stream(audio, NEXTING_AHAKEY_AUDIO_UNCONFIGURED); + return true; +} + +bool nexting_ahakey_audio_on_message(nexting_ahakey_audio_t *audio, + const nexting_device_message_t *message) { + if (audio == NULL || message == NULL) + return false; + if (message->type == NEXTING_DEVICE_MESSAGE_AUDIO_CONFIG) { + if (audio->phase == NEXTING_AHAKEY_AUDIO_UNBOUND) + return false; + cancel_stream(audio, NEXTING_DEVICE_AUDIO_CANCEL_HOST, false); + audio->epoch = message->interaction.audio.epoch; + audio->credits = message->interaction.audio.credits; + reset_stream(audio, NEXTING_AHAKEY_AUDIO_READY); + render(audio, NEXTING_DEVICE_AUDIO_STATE_READY); + return true; + } + if (audio->phase == NEXTING_AHAKEY_AUDIO_UNBOUND || + audio->phase == NEXTING_AHAKEY_AUDIO_UNCONFIGURED || + message->interaction.audio.epoch != audio->epoch) + return false; + if (message->type == NEXTING_DEVICE_MESSAGE_AUDIO_CREDIT) { + if ((audio->phase != NEXTING_AHAKEY_AUDIO_CAPTURING && + audio->phase != NEXTING_AHAKEY_AUDIO_DRAINING) || + !message->interaction.audio.has_stream_id || + message->interaction.audio.stream_id != audio->stream_id || + message->interaction.audio.ack_sequence >= audio->sent_frame_count) + return false; + if (audio->has_ack && + message->interaction.audio.ack_sequence <= audio->last_ack_sequence) + return true; + audio->has_ack = true; + audio->last_ack_sequence = message->interaction.audio.ack_sequence; + const unsigned restored = + (unsigned)audio->credits + message->interaction.audio.credits; + audio->credits = (uint8_t)(restored > 32U ? 32U : restored); + audio->stall_active = false; + audio->stall_started_ms = 0U; + return flush_queue(audio); + } + if (message->type == NEXTING_DEVICE_MESSAGE_AUDIO_CANCEL) { + if (!message->interaction.audio.has_stream_id || + message->interaction.audio.stream_id != audio->stream_id) + return false; + cancel_stream(audio, NEXTING_DEVICE_AUDIO_CANCEL_HOST, false); + return true; + } + if (message->type == NEXTING_DEVICE_MESSAGE_AUDIO_STATE) { + if (message->interaction.audio.state == NEXTING_DEVICE_AUDIO_STATE_READY) { + if (audio->phase != NEXTING_AHAKEY_AUDIO_READY) + return false; + render(audio, NEXTING_DEVICE_AUDIO_STATE_READY); + return true; + } + if (!message->interaction.audio.has_stream_id || + message->interaction.audio.stream_id != audio->stream_id) + return false; + if (message->interaction.audio.state == + NEXTING_DEVICE_AUDIO_STATE_RECEIVING) { + if (audio->phase != NEXTING_AHAKEY_AUDIO_CAPTURING) + return false; + render(audio, NEXTING_DEVICE_AUDIO_STATE_RECEIVING); + return true; + } + if (message->interaction.audio.state == + NEXTING_DEVICE_AUDIO_STATE_TRANSCRIBING) { + if (audio->phase != NEXTING_AHAKEY_AUDIO_WAITING_RESULT) + return false; + render(audio, NEXTING_DEVICE_AUDIO_STATE_TRANSCRIBING); + return true; + } + if (message->interaction.audio.state != + NEXTING_DEVICE_AUDIO_STATE_SUBMITTED && + message->interaction.audio.state != NEXTING_DEVICE_AUDIO_STATE_ERROR) + return false; + if (audio->phase != NEXTING_AHAKEY_AUDIO_WAITING_RESULT) + return false; + render(audio, message->interaction.audio.state); + reset_stream(audio, NEXTING_AHAKEY_AUDIO_READY); + return true; + } + return false; +} + +bool nexting_ahakey_audio_voice_pressed(nexting_ahakey_audio_t *audio) { + if (audio == NULL) + return false; + if (audio->phase == NEXTING_AHAKEY_AUDIO_READY) + return begin_stream(audio); + if (audio->phase == NEXTING_AHAKEY_AUDIO_CAPTURING && audio->latched && + !audio->key_down) + return stop_stream(audio, NEXTING_DEVICE_AUDIO_END_SUBMITTED); + return false; +} + +bool nexting_ahakey_audio_voice_released(nexting_ahakey_audio_t *audio) { + if (audio == NULL || audio->phase != NEXTING_AHAKEY_AUDIO_CAPTURING || + !audio->key_down) + return false; + audio->key_down = false; + if (audio_now_ms(audio) - audio->capture_started_ms < + NEXTING_AHAKEY_AUDIO_HOLD_MS) { + audio->latched = true; + return true; + } + return stop_stream(audio, NEXTING_DEVICE_AUDIO_END_SUBMITTED); +} + +bool nexting_ahakey_audio_cancel_physical(nexting_ahakey_audio_t *audio) { + if (audio == NULL || (audio->phase != NEXTING_AHAKEY_AUDIO_CAPTURING && + audio->phase != NEXTING_AHAKEY_AUDIO_DRAINING)) + return false; + cancel_stream(audio, NEXTING_DEVICE_AUDIO_CANCEL_LOCAL, true); + return true; +} + +bool nexting_ahakey_audio_on_pcm( + nexting_ahakey_audio_t *audio, + const int16_t samples[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME]) { + if (audio == NULL || samples == NULL || + audio->phase != NEXTING_AHAKEY_AUDIO_CAPTURING) + return false; + if (audio->next_sequence >= NEXTING_DEVICE_AUDIO_MAX_FRAMES) { + (void)stop_stream(audio, NEXTING_DEVICE_AUDIO_END_MAX_DURATION); + return false; + } + if (audio->queue_count >= NEXTING_AHAKEY_AUDIO_QUEUE_FRAMES) { + cancel_stream(audio, NEXTING_DEVICE_AUDIO_CANCEL_TRANSPORT, true); + return false; + } + const uint8_t tail = (uint8_t)((audio->queue_head + audio->queue_count) % + NEXTING_AHAKEY_AUDIO_QUEUE_FRAMES); + size_t encoded_length = 0U; + if (nexting_device_audio_encode_frame( + audio->epoch, audio->stream_id, audio->next_sequence, + audio->next_sequence * NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME, + samples, NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME, 0U, + audio->queued_frames[tail], NEXTING_DEVICE_AUDIO_FRAME_BYTES, + &encoded_length) != NEXTING_DEVICE_AUDIO_OK || + encoded_length != NEXTING_DEVICE_AUDIO_FRAME_BYTES) { + cancel_stream(audio, NEXTING_DEVICE_AUDIO_CANCEL_HARDWARE, true); + return false; + } + audio->queue_count += 1U; + audio->next_sequence += 1U; + if (!flush_queue(audio)) + return false; + if (audio->next_sequence == NEXTING_DEVICE_AUDIO_MAX_FRAMES && + audio->phase == NEXTING_AHAKEY_AUDIO_CAPTURING) + return stop_stream(audio, NEXTING_DEVICE_AUDIO_END_MAX_DURATION); + return true; +} + +void nexting_ahakey_audio_on_hardware_error(nexting_ahakey_audio_t *audio) { + cancel_stream(audio, NEXTING_DEVICE_AUDIO_CANCEL_HARDWARE, true); + render(audio, NEXTING_DEVICE_AUDIO_STATE_ERROR); +} + +void nexting_ahakey_audio_tick(nexting_ahakey_audio_t *audio) { + if (audio == NULL) + return; + const uint64_t now_ms = audio_now_ms(audio); + if (audio->phase == NEXTING_AHAKEY_AUDIO_CAPTURING && + now_ms - audio->capture_started_ms >= 120000U) { + (void)stop_stream(audio, NEXTING_DEVICE_AUDIO_END_MAX_DURATION); + return; + } + if ((audio->phase == NEXTING_AHAKEY_AUDIO_CAPTURING || + audio->phase == NEXTING_AHAKEY_AUDIO_DRAINING) && + audio->queue_count > 0U && audio->credits == 0U && + audio->stall_active && + now_ms - audio->stall_started_ms >= NEXTING_AHAKEY_AUDIO_STALL_MS) + cancel_stream(audio, NEXTING_DEVICE_AUDIO_CANCEL_TRANSPORT, true); +} + +void nexting_ahakey_audio_disconnect(nexting_ahakey_audio_t *audio) { + if (audio == NULL) + return; + const bool was_active = audio->phase == NEXTING_AHAKEY_AUDIO_CAPTURING || + audio->phase == NEXTING_AHAKEY_AUDIO_DRAINING; + audio->phase = NEXTING_AHAKEY_AUDIO_DRAINING; + if (was_active && audio->hardware_cancel != NULL) + audio->hardware_cancel(audio->hardware_context); + audio->epoch = 0U; + reset_stream(audio, audio->write_packet == NULL + ? NEXTING_AHAKEY_AUDIO_UNBOUND + : NEXTING_AHAKEY_AUDIO_UNCONFIGURED); + render(audio, NEXTING_DEVICE_AUDIO_STATE_NONE); +} diff --git a/devices/firmware/ahakey-x1/nexting_ahakey_audio.h b/devices/firmware/ahakey-x1/nexting_ahakey_audio.h new file mode 100644 index 0000000..912c3dd --- /dev/null +++ b/devices/firmware/ahakey-x1/nexting_ahakey_audio.h @@ -0,0 +1,119 @@ +#ifndef NEXTING_AHAKEY_AUDIO_H +#define NEXTING_AHAKEY_AUDIO_H + +#include +#include +#include + +#include "nexting_device.h" +#include "nexting_device_audio.h" + +#ifdef __cplusplus +extern "C" { +#endif + +#define NEXTING_AHAKEY_AUDIO_QUEUE_FRAMES 10U +#define NEXTING_AHAKEY_AUDIO_INITIAL_CREDITS 8U +#define NEXTING_AHAKEY_AUDIO_STALL_MS 500U +#define NEXTING_AHAKEY_AUDIO_HOLD_MS 450U +#define NEXTING_AHAKEY_AUDIO_FRAGMENT_HEADER_BYTES 12U +#define NEXTING_AHAKEY_AUDIO_MAX_FRAGMENTS 4U + +typedef enum { + NEXTING_AHAKEY_AUDIO_UNBOUND = 0, + NEXTING_AHAKEY_AUDIO_UNCONFIGURED, + NEXTING_AHAKEY_AUDIO_READY, + NEXTING_AHAKEY_AUDIO_CAPTURING, + NEXTING_AHAKEY_AUDIO_DRAINING, + NEXTING_AHAKEY_AUDIO_WAITING_RESULT +} nexting_ahakey_audio_phase_t; + +typedef uint64_t (*nexting_ahakey_audio_now_ms_fn)(void *context); +typedef void (*nexting_ahakey_audio_write_control_fn)( + const nexting_device_message_t *message, void *context); +typedef bool (*nexting_ahakey_audio_write_packet_fn)(const uint8_t *bytes, + size_t length, + void *context); +typedef bool (*nexting_ahakey_audio_hw_start_fn)(uint32_t sample_rate, + uint8_t channels, + uint16_t frame_ms, + void *context); +typedef void (*nexting_ahakey_audio_hw_stop_fn)(void *context); +typedef void (*nexting_ahakey_audio_hw_cancel_fn)(void *context); +typedef void (*nexting_ahakey_audio_render_state_fn)( + nexting_device_audio_state_t state, void *context); + +typedef struct { + nexting_ahakey_audio_phase_t phase; + uint32_t epoch; + uint32_t stream_id; + uint32_t next_stream_nonce; + uint32_t next_sequence; + uint32_t sent_frame_count; + uint32_t last_ack_sequence; + bool has_ack; + bool key_down; + bool latched; + bool stall_active; + uint8_t credits; + uint8_t queue_head; + uint8_t queue_count; + uint64_t capture_started_ms; + uint64_t stall_started_ms; + nexting_device_audio_end_reason_t drain_reason; + size_t maximum_packet_bytes; + uint8_t queued_frames[NEXTING_AHAKEY_AUDIO_QUEUE_FRAMES] + [NEXTING_DEVICE_AUDIO_FRAME_BYTES]; + nexting_ahakey_audio_now_ms_fn now_ms; + nexting_ahakey_audio_write_control_fn write_control; + nexting_ahakey_audio_write_packet_fn write_packet; + nexting_ahakey_audio_hw_start_fn hardware_start; + nexting_ahakey_audio_hw_stop_fn hardware_stop; + nexting_ahakey_audio_hw_cancel_fn hardware_cancel; + nexting_ahakey_audio_render_state_fn render_state; + void *protocol_context; + void *hardware_context; +} nexting_ahakey_audio_t; + +/* Initializes the protocol half. Audio remains unavailable until bind(). */ +void nexting_ahakey_audio_init( + nexting_ahakey_audio_t *audio, nexting_ahakey_audio_now_ms_fn now_ms, + nexting_ahakey_audio_write_control_fn write_control, + void *protocol_context); + +/* + * Binds the board port. write_packet receives a complete logical audio packet: + * either a 192-byte frame or one standard fragment. A compatibility port adds + * its descriptor opcode (A2 for the supplied X1 descriptor) outside this core. + */ +bool nexting_ahakey_audio_bind( + nexting_ahakey_audio_t *audio, size_t maximum_packet_bytes, + nexting_ahakey_audio_write_packet_fn write_packet, + nexting_ahakey_audio_hw_start_fn hardware_start, + nexting_ahakey_audio_hw_stop_fn hardware_stop, + nexting_ahakey_audio_hw_cancel_fn hardware_cancel, + nexting_ahakey_audio_render_state_fn render_state, void *hardware_context); + +/* Handles audio_config, audio_credit, audio_cancel, and audio_state. */ +bool nexting_ahakey_audio_on_message(nexting_ahakey_audio_t *audio, + const nexting_device_message_t *message); + +/* Physical-key lifecycle. No Host message can call these functions. */ +bool nexting_ahakey_audio_voice_pressed(nexting_ahakey_audio_t *audio); +bool nexting_ahakey_audio_voice_released(nexting_ahakey_audio_t *audio); +bool nexting_ahakey_audio_cancel_physical(nexting_ahakey_audio_t *audio); + +/* Called by the production microphone driver for each exact 20 ms PCM block. */ +bool nexting_ahakey_audio_on_pcm( + nexting_ahakey_audio_t *audio, + const int16_t samples[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME]); +void nexting_ahakey_audio_on_hardware_error(nexting_ahakey_audio_t *audio); + +void nexting_ahakey_audio_tick(nexting_ahakey_audio_t *audio); +void nexting_ahakey_audio_disconnect(nexting_ahakey_audio_t *audio); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/devices/firmware/ahakey-x1/tests/test_ahakey_adapter.c b/devices/firmware/ahakey-x1/tests/test_ahakey_adapter.c new file mode 100644 index 0000000..0906186 --- /dev/null +++ b/devices/firmware/ahakey-x1/tests/test_ahakey_adapter.c @@ -0,0 +1,218 @@ +#include "nexting_ahakey_adapter.h" + +#include +#include + +typedef struct { + uint64_t now_ms; + char tx[512]; + size_t tx_length; + size_t approval_renders; + size_t text_renders; + nexting_device_phase_t phase; + ahakey_state_t led; + size_t led_renders; + char text_title[NEXTING_DEVICE_TEXT_TITLE_CAPACITY]; + char text_content[64]; +} test_context_t; + +static uint64_t now_ms(void *context) { + return ((test_context_t *)context)->now_ms; +} + +static void write_frame(const uint8_t *bytes, size_t length, void *context) { + test_context_t *test = (test_context_t *)context; + assert(length < sizeof test->tx); + memcpy(test->tx, bytes, length); + test->tx[length] = '\0'; + test->tx_length = length; +} + +static void render_approval(const nexting_device_state_t *state, + void *context) { + test_context_t *test = (test_context_t *)context; + test->approval_renders += 1U; + test->phase = state->phase; +} + +static void render_led(ahakey_state_t state, void *context) { + test_context_t *test = (test_context_t *)context; + test->led = state; + test->led_renders += 1U; +} + +static void render_text(const nexting_device_text_payload_t *text, + void *context) { + test_context_t *test = (test_context_t *)context; + test->text_renders += 1U; + memset(test->text_title, 0, sizeof test->text_title); + memset(test->text_content, 0, sizeof test->text_content); + if (text->has_title) + memcpy(test->text_title, text->title, sizeof test->text_title - 1U); + memcpy(test->text_content, text->content, sizeof test->text_content - 1U); +} + +static void receive(nexting_ahakey_t *adapter, const char *wire) { + assert(nexting_ahakey_receive(adapter, (const uint8_t *)wire, + strlen(wire)) == NEXTING_DEVICE_OK); +} + +int main(void) { + test_context_t context = {0}; + nexting_ahakey_t adapter; + nexting_ahakey_init(&adapter, now_ms, write_frame, render_approval, + render_led, render_text, &context); + + /* Idle hardware shows nothing; the LED starts dark. */ + assert(nexting_ahakey_resolve_led(&adapter) == AHAKEY_STATE_STOP); + + /* Approval present drives the permission-request LED over any status. */ + receive(&adapter, + "{\"v\":1,\"t\":\"present\",\"id\":\"ak1\",\"sum\":\"Allow push?\"," + "\"opt\":[\"allow\",\"deny\"],\"ttl\":30000}\n"); + assert(context.phase == NEXTING_DEVICE_PHASE_PENDING); + assert(context.led == AHAKEY_STATE_PERMISSION_REQUEST); + + /* F18 answers allow with the same request id. */ + context.now_ms = 100; + assert(nexting_ahakey_hid_key(&adapter, NEXTING_AHAKEY_HID_ALLOW, true)); + assert(strcmp(context.tx, + "{\"v\":1,\"t\":\"answer\",\"id\":\"ak1\",\"ch\":\"allow\"}\n") == + 0); + assert(nexting_ahakey_resolve_led(&adapter) == AHAKEY_STATE_STOP); + + /* Resolved clears the request. */ + receive(&adapter, + "{\"v\":1,\"t\":\"resolved\",\"id\":\"ak1\",\"r\":\"answered\"}\n"); + assert(context.phase == NEXTING_DEVICE_PHASE_IDLE); + + /* Deny flows through the same state machine with F19. */ + receive(&adapter, + "{\"v\":1,\"t\":\"present\",\"id\":\"ak2\",\"sum\":\"Deny push?\"," + "\"opt\":[\"allow\",\"deny\"],\"ttl\":30000}\n"); + assert(nexting_ahakey_hid_key(&adapter, NEXTING_AHAKEY_HID_DENY, true)); + assert(strcmp(context.tx, + "{\"v\":1,\"t\":\"answer\",\"id\":\"ak2\",\"ch\":\"deny\"}\n") == + 0); + + /* Status maps agent states onto the LED strip. */ + receive(&adapter, + "{\"v\":1,\"t\":\"status\",\"agents\":[{\"slot\":0," + "\"state\":\"working\",\"label\":\"build\"}]}\n"); + assert(context.led == AHAKEY_STATE_PRE_TOOL_USE); + receive(&adapter, + "{\"v\":1,\"t\":\"status\",\"agents\":[{\"slot\":0," + "\"state\":\"needs_input\",\"label\":\"build\"}]}\n"); + assert(context.led == AHAKEY_STATE_PERMISSION_REQUEST); + receive(&adapter, + "{\"v\":1,\"t\":\"status\",\"agents\":[{\"slot\":0," + "\"state\":\"complete\",\"label\":\"build\"}]}\n"); + assert(context.led == AHAKEY_STATE_TASK_COMPLETED); + receive(&adapter, + "{\"v\":1,\"t\":\"status\",\"agents\":[]}\n"); + assert(context.led == AHAKEY_STATE_STOP); + + /* The lowest-numbered occupied slot wins; higher slots are ignored. */ + receive(&adapter, + "{\"v\":1,\"t\":\"status\",\"agents\":[{\"slot\":0," + "\"state\":\"working\",\"label\":\"a\"},{\"slot\":1," + "\"state\":\"error\",\"label\":\"b\"}]}\n"); + assert(context.led == AHAKEY_STATE_PRE_TOOL_USE); + receive(&adapter, + "{\"v\":1,\"t\":\"status\",\"agents\":[{\"slot\":1," + "\"state\":\"error\",\"label\":\"b\"}]}\n"); + assert(context.led == AHAKEY_STATE_NOTIFICATION); + + /* No key events before the Host keymap declares and enables slots. */ + assert(!nexting_ahakey_hid_key(&adapter, NEXTING_AHAKEY_HID_F13, true)); + assert(!nexting_ahakey_lever(&adapter, 0)); + assert(!nexting_ahakey_lever(&adapter, 1)); + + /* Keymap enables slots 0, 3, and 7; everything else stays silent. */ + receive(&adapter, + "{\"v\":1,\"t\":\"keymap\",\"rev\":4,\"keys\":[{\"slot\":0," + "\"label\":\"One\",\"enabled\":true,\"light\":\"solid\"},{\"slot\":3," + "\"label\":\"Four\",\"enabled\":true,\"light\":\"solid\"},{\"slot\":7," + "\"label\":\"Lever\",\"enabled\":true,\"light\":\"solid\"}]}\n"); + assert(nexting_ahakey_hid_key(&adapter, NEXTING_AHAKEY_HID_F13, true)); + assert(strcmp(context.tx, + "{\"v\":1,\"t\":\"key_event\",\"slot\":0,\"event\":\"press\"," + "\"seq\":1}\n") == 0); + assert(nexting_ahakey_hid_key(&adapter, NEXTING_AHAKEY_HID_F16, true)); + assert(strcmp(context.tx, + "{\"v\":1,\"t\":\"key_event\",\"slot\":3,\"event\":\"press\"," + "\"seq\":2}\n") == 0); + /* Undeclared and disabled slots emit nothing; releases are ignored. */ + assert(!nexting_ahakey_hid_key(&adapter, NEXTING_AHAKEY_HID_F14, true)); + assert(!nexting_ahakey_hid_key(&adapter, NEXTING_AHAKEY_HID_F13, false)); + assert(!nexting_ahakey_hid_key(&adapter, 0x2CU, true)); + + /* The lever reports on its declared slot only on change. */ + assert(!nexting_ahakey_lever(&adapter, 1)); /* steady state, silent */ + assert(nexting_ahakey_lever(&adapter, 0)); + assert(strcmp(context.tx, + "{\"v\":1,\"t\":\"key_event\",\"slot\":7," + "\"event\":\"release\",\"seq\":3}\n") == 0); + + /* A replacement keymap fully replaces the enabled set. */ + receive(&adapter, + "{\"v\":1,\"t\":\"keymap\",\"rev\":5,\"keys\":[]}\n"); + assert(!nexting_ahakey_hid_key(&adapter, NEXTING_AHAKEY_HID_F13, true)); + receive(&adapter, + "{\"v\":1,\"t\":\"keymap\",\"rev\":6,\"keys\":[{\"slot\":7," + "\"label\":\"Lever\",\"enabled\":true,\"light\":\"solid\"}]}\n"); + assert(nexting_ahakey_lever(&adapter, 1)); + assert(strcmp(context.tx, + "{\"v\":1,\"t\":\"key_event\",\"slot\":7,\"event\":\"press\"," + "\"seq\":4}\n") == 0); + + /* TTL expiry clears the pending approval and drops the override LED. */ + receive(&adapter, + "{\"v\":1,\"t\":\"present\",\"id\":\"ak4\",\"sum\":\"Short\"," + "\"opt\":[\"allow\",\"deny\"],\"ttl\":500}\n"); + assert(context.led == AHAKEY_STATE_PERMISSION_REQUEST); + context.now_ms = 1000; + nexting_ahakey_tick(&adapter); + assert(context.phase == NEXTING_DEVICE_PHASE_IDLE); + assert(context.led == AHAKEY_STATE_NOTIFICATION); /* slot-1 error remains */ + + /* The answer retry re-sends the same frame until resolved. */ + receive(&adapter, + "{\"v\":1,\"t\":\"present\",\"id\":\"ak5\",\"sum\":\"Retry\"," + "\"opt\":[\"allow\",\"deny\"],\"ttl\":30000}\n"); + context.now_ms = 2000; + assert(nexting_ahakey_hid_key(&adapter, NEXTING_AHAKEY_HID_ALLOW, true)); + const char *want_answer = + "{\"v\":1,\"t\":\"answer\",\"id\":\"ak5\",\"ch\":\"allow\"}\n"; + assert(strcmp(context.tx, want_answer) == 0); + context.now_ms = 4000; + nexting_ahakey_tick(&adapter); + assert(strcmp(context.tx, want_answer) == 0); + receive(&adapter, + "{\"v\":1,\"t\":\"resolved\",\"id\":\"ak5\",\"r\":\"answered\"}\n"); + context.now_ms = 6000; + context.tx[0] = '\0'; + nexting_ahakey_tick(&adapter); + assert(context.tx[0] == '\0'); /* resolved stops the retry */ + + /* text/1 reaches the display callback with title and content. */ + receive(&adapter, + "{\"v\":1,\"t\":\"text\",\"channel\":0,\"title\":\"Current task\"," + "\"content\":\"Waiting for approval\"}\n"); + assert(context.text_renders == 1U); + assert(strcmp(context.text_title, "Current task") == 0); + assert(strcmp(context.text_content, "Waiting for approval") == 0); + + /* Disconnect clears approval, status, and the lever baseline; LED dark. */ + receive(&adapter, + "{\"v\":1,\"t\":\"present\",\"id\":\"ak3\",\"sum\":\"Later\"," + "\"opt\":[\"allow\",\"deny\"],\"ttl\":30000}\n"); + nexting_ahakey_disconnect(&adapter); + assert(context.phase == NEXTING_DEVICE_PHASE_IDLE); + assert(context.led == AHAKEY_STATE_STOP); + assert(!nexting_ahakey_lever(&adapter, 0)); /* baseline re-established */ + /* The volatile keymap is gone too: F13 was enabled before disconnect. */ + assert(!nexting_ahakey_hid_key(&adapter, NEXTING_AHAKEY_HID_F13, true)); + + return 0; +} diff --git a/devices/firmware/ahakey-x1/tests/test_ahakey_audio.c b/devices/firmware/ahakey-x1/tests/test_ahakey_audio.c new file mode 100644 index 0000000..840f1e2 --- /dev/null +++ b/devices/firmware/ahakey-x1/tests/test_ahakey_audio.c @@ -0,0 +1,214 @@ +#include "nexting_ahakey_adapter.h" + +#include +#include +#include + +typedef struct { + uint64_t now_ms; + char control[512]; + size_t control_length; + uint8_t packets[128][NEXTING_DEVICE_AUDIO_FRAME_BYTES]; + size_t packet_lengths[128]; + size_t packet_count; + size_t starts; + size_t stops; + size_t cancels; + nexting_device_audio_state_t rendered_state; +} audio_test_context_t; + +static uint64_t now_ms(void *context) { + return ((audio_test_context_t *)context)->now_ms; +} + +static void write_control(const uint8_t *bytes, size_t length, void *context) { + audio_test_context_t *test = (audio_test_context_t *)context; + assert(length < sizeof test->control); + memcpy(test->control, bytes, length); + test->control[length] = '\0'; + test->control_length = length; +} + +static bool write_packet(const uint8_t *bytes, size_t length, void *context) { + audio_test_context_t *test = (audio_test_context_t *)context; + assert(test->packet_count < 128U); + assert(length <= NEXTING_DEVICE_AUDIO_FRAME_BYTES); + memcpy(test->packets[test->packet_count], bytes, length); + test->packet_lengths[test->packet_count] = length; + test->packet_count += 1U; + return true; +} + +static bool hardware_start(uint32_t sample_rate, uint8_t channels, + uint16_t frame_ms, void *context) { + audio_test_context_t *test = (audio_test_context_t *)context; + assert(sample_rate == 16000U); + assert(channels == 1U); + assert(frame_ms == 20U); + test->starts += 1U; + return true; +} + +static void hardware_stop(void *context) { + ((audio_test_context_t *)context)->stops += 1U; +} + +static void hardware_cancel(void *context) { + ((audio_test_context_t *)context)->cancels += 1U; +} + +static void render_state(nexting_device_audio_state_t state, void *context) { + ((audio_test_context_t *)context)->rendered_state = state; +} + +static void receive(nexting_ahakey_t *adapter, const char *wire) { + assert(nexting_ahakey_receive(adapter, (const uint8_t *)wire, + strlen(wire)) == NEXTING_DEVICE_OK); +} + +static void configure(nexting_ahakey_t *adapter, uint32_t epoch) { + char wire[256]; + const int length = snprintf( + wire, sizeof wire, + "{\"v\":1,\"t\":\"audio_config\",\"epoch\":%u," + "\"codec\":\"ima_adpcm\",\"sample_rate\":16000,\"channels\":1," + "\"frame_ms\":20,\"max_duration_ms\":120000," + "\"startup_buffer_ms\":200,\"credits\":8}\n", + epoch); + assert(length > 0 && (size_t)length < sizeof wire); + receive(adapter, wire); +} + +static void credit(nexting_ahakey_t *adapter, uint32_t epoch, + uint32_t stream_id, uint32_t ack_sequence, + uint8_t credits) { + char wire[192]; + const int length = snprintf( + wire, sizeof wire, + "{\"v\":1,\"t\":\"audio_credit\",\"epoch\":%u," + "\"stream_id\":%u,\"ack_seq\":%u,\"credits\":%u}\n", + epoch, stream_id, ack_sequence, (unsigned)credits); + assert(length > 0 && (size_t)length < sizeof wire); + receive(adapter, wire); +} + +static void result_state(nexting_ahakey_t *adapter, uint32_t epoch, + uint32_t stream_id, const char *state) { + char wire[160]; + const int length = snprintf( + wire, sizeof wire, + "{\"v\":1,\"t\":\"audio_state\",\"epoch\":%u," + "\"stream_id\":%u,\"state\":\"%s\"}\n", + epoch, stream_id, state); + assert(length > 0 && (size_t)length < sizeof wire); + receive(adapter, wire); +} + +int main(void) { + audio_test_context_t test = {0}; + nexting_ahakey_t adapter; + nexting_ahakey_init(&adapter, now_ms, write_control, NULL, NULL, NULL, &test); + assert(nexting_ahakey_bind_device_audio( + &adapter, 64U, write_packet, hardware_start, hardware_stop, + hardware_cancel, render_state, &test)); + + /* Host readiness configures buffers but cannot start the microphone. */ + configure(&adapter, 77U); + assert(test.starts == 0U); + assert(adapter.audio.phase == NEXTING_AHAKEY_AUDIO_READY); + assert(test.rendered_state == NEXTING_DEVICE_AUDIO_STATE_READY); + + /* Only the physical key starts capture and announces a device stream. */ + test.now_ms = 100U; + assert(nexting_ahakey_voice_pressed(&adapter)); + assert(test.starts == 1U); + assert(strstr(test.control, "\"t\":\"audio_begin\"") != NULL); + assert(test.rendered_state == NEXTING_DEVICE_AUDIO_STATE_RECEIVING); + const uint32_t first_stream = adapter.audio.stream_id; + + int16_t pcm[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME]; + for (size_t i = 0U; i < NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME; ++i) + pcm[i] = (int16_t)(i * 3U); + + /* ATT payload 64 fragments each complete 192-byte frame into four packets. */ + assert(nexting_ahakey_on_pcm(&adapter, pcm)); + assert(test.packet_count == 4U); + assert(test.packets[0][0] == 1U && test.packets[0][1] == 2U); + assert(test.packets[0][2] == 0U && test.packets[3][2] == 3U); + assert(test.packets[0][3] == 4U); + + /* Eight credits are bounded; frame nine waits until a new cumulative ACK. */ + for (size_t i = 1U; i < 9U; ++i) + assert(nexting_ahakey_on_pcm(&adapter, pcm)); + assert(test.packet_count == 32U); + assert(adapter.audio.queue_count == 1U); + credit(&adapter, 77U, first_stream, 0U, 1U); + assert(test.packet_count == 36U); + assert(adapter.audio.queue_count == 0U); + /* Replaying the same ACK cannot mint another credit. */ + credit(&adapter, 77U, first_stream, 0U, 1U); + assert(adapter.audio.credits == 0U); + + /* A short release latches; the next physical press drains and submits. */ + test.now_ms = 200U; + assert(nexting_ahakey_voice_released(&adapter)); + assert(adapter.audio.phase == NEXTING_AHAKEY_AUDIO_CAPTURING); + assert(nexting_ahakey_voice_pressed(&adapter)); + assert(test.stops == 1U); + assert(strstr(test.control, "\"t\":\"audio_end\"") != NULL); + assert(strstr(test.control, "\"last_seq\":8") != NULL); + assert(test.rendered_state == NEXTING_DEVICE_AUDIO_STATE_TRANSCRIBING); + assert(adapter.audio.phase == NEXTING_AHAKEY_AUDIO_WAITING_RESULT); + assert(!nexting_ahakey_voice_pressed(&adapter)); + result_state(&adapter, 77U, first_stream, "submitted"); + assert(adapter.audio.phase == NEXTING_AHAKEY_AUDIO_READY); + assert(test.rendered_state == NEXTING_DEVICE_AUDIO_STATE_SUBMITTED); + + /* Ten queued frames are the hard RAM bound; overflow cancels and wipes. */ + test.packet_count = 0U; + configure(&adapter, 88U); + test.now_ms = 1000U; + assert(nexting_ahakey_voice_pressed(&adapter)); + for (size_t i = 0U; i < 18U; ++i) + assert(nexting_ahakey_on_pcm(&adapter, pcm)); + assert(adapter.audio.queue_count == 10U); + assert(!nexting_ahakey_on_pcm(&adapter, pcm)); + assert(test.cancels == 1U); + assert(adapter.audio.queue_count == 0U); + assert(strstr(test.control, "\"reason\":\"transport\"") != NULL); + + /* A continuous no-credit stall also fails closed by 500 ms. */ + configure(&adapter, 99U); + test.now_ms = 2000U; + assert(nexting_ahakey_voice_pressed(&adapter)); + for (size_t i = 0U; i < 9U; ++i) + assert(nexting_ahakey_on_pcm(&adapter, pcm)); + assert(adapter.audio.queue_count == 1U); + test.now_ms = 2500U; + nexting_ahakey_tick(&adapter); + assert(test.cancels == 2U); + assert(adapter.audio.queue_count == 0U); + + /* Disconnect erases the epoch and never restores an interrupted stream. */ + configure(&adapter, 100U); + assert(nexting_ahakey_voice_pressed(&adapter)); + nexting_ahakey_disconnect(&adapter); + assert(adapter.audio.phase == NEXTING_AHAKEY_AUDIO_UNCONFIGURED); + assert(adapter.audio.epoch == 0U); + assert(!nexting_ahakey_voice_pressed(&adapter)); + + /* Zero is a valid wire epoch, not an internal unconfigured sentinel. */ + audio_test_context_t zero_epoch_test = {0}; + nexting_ahakey_t zero_epoch_adapter; + nexting_ahakey_init(&zero_epoch_adapter, now_ms, write_control, NULL, NULL, + NULL, &zero_epoch_test); + assert(nexting_ahakey_bind_device_audio( + &zero_epoch_adapter, 64U, write_packet, hardware_start, hardware_stop, + hardware_cancel, render_state, &zero_epoch_test)); + configure(&zero_epoch_adapter, 0U); + assert(nexting_ahakey_voice_pressed(&zero_epoch_adapter)); + assert(nexting_ahakey_voice_cancel(&zero_epoch_adapter)); + assert(zero_epoch_adapter.audio.phase == NEXTING_AHAKEY_AUDIO_READY); + + return 0; +} diff --git a/devices/firmware/ahakey-x1/tests/test_ahakey_port.c b/devices/firmware/ahakey-x1/tests/test_ahakey_port.c new file mode 100644 index 0000000..ce8a3f0 --- /dev/null +++ b/devices/firmware/ahakey-x1/tests/test_ahakey_port.c @@ -0,0 +1,132 @@ +#include "nexting_ahakey_port.h" + +#include +#include + +typedef struct { + uint64_t now_ms; + uint8_t notifications[64][NEXTING_AHAKEY_TUNNEL_MAX_ATT_PAYLOAD]; + size_t notification_lengths[64]; + size_t notification_count; + size_t vendor_writes; + size_t microphone_starts; +} port_test_context_t; + +static uint64_t now_ms(void *context) { + return ((port_test_context_t *)context)->now_ms; +} + +static bool notify_7344(const uint8_t *bytes, size_t length, void *context) { + port_test_context_t *test = (port_test_context_t *)context; + assert(test->notification_count < 64U); + assert(length <= NEXTING_AHAKEY_TUNNEL_MAX_ATT_PAYLOAD); + memcpy(test->notifications[test->notification_count], bytes, length); + test->notification_lengths[test->notification_count] = length; + test->notification_count += 1U; + return true; +} + +static bool vendor_write(const uint8_t *bytes, size_t length, void *context) { + port_test_context_t *test = (port_test_context_t *)context; + assert(bytes != NULL && length > 0U); + test->vendor_writes += 1U; + return true; +} + +static bool microphone_start(uint32_t sample_rate, uint8_t channels, + uint16_t frame_ms, void *context) { + port_test_context_t *test = (port_test_context_t *)context; + assert(sample_rate == 16000U && channels == 1U && frame_ms == 20U); + test->microphone_starts += 1U; + return true; +} + +static void microphone_noop(void *context) { (void)context; } + +static void render_audio_state(nexting_device_audio_state_t state, + void *context) { + (void)state; + (void)context; +} + +static bool tunnel_write(nexting_ahakey_port_t *port, uint8_t opcode, + const char *wire) { + const size_t length = strlen(wire); + size_t offset = 0U; + while (offset < length) { + const size_t remaining = length - offset; + const size_t chunk_length = remaining < 63U ? remaining : 63U; + uint8_t packet[64]; + packet[0] = opcode; + memcpy(packet + 1U, wire + offset, chunk_length); + if (!nexting_ahakey_port_on_7343_write( + port, packet, chunk_length + 1U, true, true)) + return false; + offset += chunk_length; + } + return true; +} + +int main(void) { + static const uint8_t info[] = + "{\"protocol\":\"nexting-device\",\"device_id\":" + "\"123e4567-e89b-12d3-a456-426614174000\"}"; + port_test_context_t test = {0}; + const nexting_ahakey_port_ops_t ops = { + now_ms, notify_7344, vendor_write, microphone_start, + microphone_noop, microphone_noop, NULL, NULL, + NULL, render_audio_state, + }; + nexting_ahakey_port_t port; + nexting_ahakey_port_t rejected_port; + static const uint8_t multiline_info[] = "{\n}"; + assert(!nexting_ahakey_port_init( + &rejected_port, &ops, 64U, multiline_info, + sizeof multiline_info - 1U, &test)); + assert(nexting_ahakey_port_init(&port, &ops, 64U, info, + sizeof info - 1U, &test)); + + /* Enrollment metadata is never exposed before bond + encryption. */ + assert(!nexting_ahakey_port_publish_device_info(&port, false, true)); + assert(test.notification_count == 0U); + assert(nexting_ahakey_port_publish_device_info(&port, true, true)); + assert(test.notification_count == 3U); + assert(test.notifications[0][0] == NEXTING_AHAKEY_TUNNEL_CONTROL_UP); + assert(test.notifications[2][0] == NEXTING_AHAKEY_TUNNEL_CONTROL_UP); + assert(test.notifications[2][1] == '\n'); + + /* Stock AA BB commands stay on the original vendor parser. */ + const uint8_t vendor[] = {0xAAU, 0xBBU, 0x00U, 0xCCU, 0xDDU}; + assert(nexting_ahakey_port_on_7343_write(&port, vendor, sizeof vendor, + false, false)); + assert(test.vendor_writes == 1U); + + static const char config[] = + "{\"v\":1,\"t\":\"audio_config\",\"epoch\":5," + "\"codec\":\"ima_adpcm\",\"sample_rate\":16000,\"channels\":1," + "\"frame_ms\":20,\"max_duration_ms\":120000," + "\"startup_buffer_ms\":200,\"credits\":8}\n"; + uint8_t rejected[2] = {NEXTING_AHAKEY_TUNNEL_CONTROL_DOWN, '{'}; + assert(!nexting_ahakey_port_on_7343_write(&port, rejected, + sizeof rejected, true, false)); + assert(tunnel_write(&port, NEXTING_AHAKEY_TUNNEL_CONTROL_DOWN, config)); + assert(port.adapter.audio.phase == NEXTING_AHAKEY_AUDIO_READY); + + /* The tunnel config did not start capture; only this physical edge does. */ + assert(test.microphone_starts == 0U); + assert(nexting_ahakey_port_voice_pressed(&port)); + assert(test.microphone_starts == 1U); + const size_t after_begin = test.notification_count; + assert(test.notifications[after_begin - 1U][0] == + NEXTING_AHAKEY_TUNNEL_CONTROL_UP); + + int16_t pcm[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME] = {0}; + assert(nexting_ahakey_port_on_pcm(&port, pcm)); + assert(test.notification_count == after_begin + 4U); + for (size_t i = after_begin; i < test.notification_count; ++i) + assert(test.notifications[i][0] == NEXTING_AHAKEY_TUNNEL_AUDIO_UP); + + nexting_ahakey_port_disconnect(&port); + assert(port.adapter.audio.phase == NEXTING_AHAKEY_AUDIO_UNCONFIGURED); + return 0; +} diff --git a/devices/firmware/ahakey-x1/tests/test_ahakey_proto.c b/devices/firmware/ahakey-x1/tests/test_ahakey_proto.c new file mode 100644 index 0000000..1377dca --- /dev/null +++ b/devices/firmware/ahakey-x1/tests/test_ahakey_proto.c @@ -0,0 +1,132 @@ +#include "ahakey_proto.h" + +#include +#include + +static void test_round_trip(uint8_t cmd, const uint8_t *data, + size_t data_length) { + uint8_t wire[AHAKEY_PROTO_MAX_FRAME_BYTES]; + size_t wire_length = 0; + assert(ahakey_proto_build(cmd, data, data_length, wire, sizeof wire, + &wire_length) == AHAKEY_PROTO_OK); + assert(wire_length == AHAKEY_PROTO_MIN_FRAME_BYTES + data_length); + assert(wire[0] == 0xAAU && wire[1] == 0xBBU); + assert(wire[wire_length - 2U] == 0xCCU); + assert(wire[wire_length - 1U] == 0xDDU); + ahakey_proto_frame_t frame; + assert(ahakey_proto_decode(wire, wire_length, &frame) == AHAKEY_PROTO_OK); + assert(frame.cmd == cmd); + assert(frame.data_length == data_length); + if (data_length != 0U) + assert(memcmp(frame.data, data, data_length) == 0); +} + +int main(void) { + /* Status query matches the published capture AA BB 00 CC DD. */ + uint8_t wire[AHAKEY_PROTO_MAX_FRAME_BYTES]; + size_t wire_length = 0; + assert(ahakey_proto_build(AHAKEY_PROTO_CMD_STATUS_QUERY, NULL, 0U, wire, + sizeof wire, &wire_length) == AHAKEY_PROTO_OK); + static const uint8_t expect_query[] = {0xAA, 0xBB, 0x00, 0xCC, 0xDD}; + assert(wire_length == sizeof expect_query); + assert(memcmp(wire, expect_query, sizeof expect_query) == 0); + + /* Published capture: battery 74%, signal 50, fw 1.0, mode/light/switch 0. */ + static const uint8_t capture[] = {0xAA, 0xBB, 0x00, 0x4A, 0x32, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xCC, + 0xDD}; + ahakey_proto_frame_t frame; + assert(ahakey_proto_decode(capture, sizeof capture, &frame) == + AHAKEY_PROTO_OK); + ahakey_proto_status_t status; + assert(ahakey_proto_parse_status(&frame, &status) == AHAKEY_PROTO_OK); + assert(status.battery_percent == 74U); + assert(status.signal_strength == 50U); + assert(status.firmware_major == 1U); + assert(status.firmware_minor == 0U); + assert(status.work_mode == 0U); + assert(status.light_mode == 0U); + assert(status.switch_state == 0U); + + /* UPDATE_STATE carries exactly one ClaudeState byte. */ + assert(ahakey_proto_build_update_state(AHAKEY_STATE_PERMISSION_REQUEST, + wire, sizeof wire, + &wire_length) == AHAKEY_PROTO_OK); + static const uint8_t expect_state[] = {0xAA, 0xBB, 0x90, 0x01, 0xCC, 0xDD}; + assert(wire_length == sizeof expect_state); + assert(memcmp(wire, expect_state, sizeof expect_state) == 0); + assert(ahakey_proto_build_update_state((ahakey_state_t)9, wire, sizeof wire, + &wire_length) == + AHAKEY_PROTO_BAD_FRAME); + + /* Key description: subtype, mode, key index, then ASCII. */ + assert(ahakey_proto_build_key_description(0, 0, "EchoWrite", wire, + sizeof wire, + &wire_length) == AHAKEY_PROTO_OK); + assert(ahakey_proto_decode(wire, wire_length, &frame) == AHAKEY_PROTO_OK); + assert(frame.cmd == AHAKEY_PROTO_CMD_UPDATE_CUSTOM_KEY); + assert(frame.data[0] == AHAKEY_PROTO_KEY_SUB_DESCRIPTION); + assert(frame.data[1] == 0U); + assert(frame.data[2] == 0U); + assert(frame.data_length == 3U + 9U); + assert(memcmp(frame.data + 3U, "EchoWrite", 9U) == 0); + + /* Descriptions reject overlong, non-ASCII, and out-of-range selectors. */ + assert(ahakey_proto_build_key_description( + 0, 0, "this-description-is-way-too-long", wire, sizeof wire, + &wire_length) == AHAKEY_PROTO_BAD_FRAME); + assert(ahakey_proto_build_key_description(0, 0, "中文", wire, sizeof wire, + &wire_length) == + AHAKEY_PROTO_BAD_FRAME); + assert(ahakey_proto_build_key_description(3, 0, "ok", wire, sizeof wire, + &wire_length) == + AHAKEY_PROTO_BAD_FRAME); + assert(ahakey_proto_build_key_description(0, 4, "ok", wire, sizeof wire, + &wire_length) == + AHAKEY_PROTO_BAD_FRAME); + + /* Names are bounded at 21 bytes and save config is an empty frame. */ + assert(ahakey_proto_build_change_name((const uint8_t *)"Nexting X1", 10U, + wire, sizeof wire, + &wire_length) == AHAKEY_PROTO_OK); + assert(ahakey_proto_build_change_name( + (const uint8_t *)"a-name-that-is-far-too-long", 27U, wire, + sizeof wire, &wire_length) == AHAKEY_PROTO_BAD_FRAME); + assert(ahakey_proto_build(AHAKEY_PROTO_CMD_SAVE_CONFIG, NULL, 0U, wire, + sizeof wire, &wire_length) == AHAKEY_PROTO_OK); + static const uint8_t expect_save[] = {0xAA, 0xBB, 0x04, 0xCC, 0xDD}; + assert(memcmp(wire, expect_save, sizeof expect_save) == 0); + + /* Generic responses expose the status byte after the command echo. */ + static const uint8_t ack[] = {0xAA, 0xBB, 0x90, 0x00, 0xCC, 0xDD}; + assert(ahakey_proto_decode(ack, sizeof ack, &frame) == AHAKEY_PROTO_OK); + uint8_t response_status = 0xFFU; + assert(ahakey_proto_parse_response(&frame, AHAKEY_PROTO_CMD_UPDATE_STATE, + &response_status) == AHAKEY_PROTO_OK); + assert(response_status == 0U); + assert(ahakey_proto_parse_response(&frame, AHAKEY_PROTO_CMD_SAVE_CONFIG, + &response_status) == + AHAKEY_PROTO_BAD_FRAME); + + /* Malformed frames are rejected, never guessed. */ + static const uint8_t bad_tail[] = {0xAA, 0xBB, 0x00, 0xCC, 0xDE}; + assert(ahakey_proto_decode(bad_tail, sizeof bad_tail, &frame) == + AHAKEY_PROTO_BAD_FRAME); + static const uint8_t bad_head[] = {0xAB, 0xBB, 0x00, 0xCC, 0xDD}; + assert(ahakey_proto_decode(bad_head, sizeof bad_head, &frame) == + AHAKEY_PROTO_BAD_FRAME); + static const uint8_t too_short[] = {0xAA, 0xBB, 0xCC, 0xDD}; + assert(ahakey_proto_decode(too_short, sizeof too_short, &frame) == + AHAKEY_PROTO_BAD_FRAME); + assert(ahakey_proto_build(0x00, NULL, 0U, wire, 4U, &wire_length) == + AHAKEY_PROTO_BUFFER_TOO_SMALL); + + /* Round-trip every documented command class. */ + test_round_trip(AHAKEY_PROTO_CMD_UPDATE_PIC, + (const uint8_t[]){0x00, 0x00, 0x00, 0x01, 0x00, 0x64, 0x00}, + 7U); + test_round_trip(AHAKEY_PROTO_CMD_READ_PIC_STATE, + (const uint8_t[]){0x00}, 1U); + + return 0; +} diff --git a/devices/firmware/ahakey-x1/transport-descriptor.nexting-device.json b/devices/firmware/ahakey-x1/transport-descriptor.nexting-device.json new file mode 100644 index 0000000..e103d90 --- /dev/null +++ b/devices/firmware/ahakey-x1/transport-descriptor.nexting-device.json @@ -0,0 +1,39 @@ +{ + "version": 1, + "integration_id": "ai.ahakey.x1", + "display_name": "AhaKey X1", + "discovery": { + "service_uuid": "00007340-0000-1000-8000-00805f9b34fb" + }, + "envelope": { + "strategy": "opcode-prefix-v1", + "max_payload_bytes": 243 + }, + "channels": { + "control_down": { + "characteristic_uuid": "00007343-0000-1000-8000-00805f9b34fb", + "property": "write", + "opcode": 160 + }, + "control_up": { + "characteristic_uuid": "00007344-0000-1000-8000-00805f9b34fb", + "property": "notify", + "opcode": 161 + }, + "audio_up": { + "characteristic_uuid": "00007344-0000-1000-8000-00805f9b34fb", + "property": "notify", + "opcode": 162 + }, + "flow_down": { + "characteristic_uuid": "00007343-0000-1000-8000-00805f9b34fb", + "property": "write", + "opcode": 163 + } + }, + "requirements": { + "bonded": true, + "encrypted": true, + "min_att_payload": 64 + } +} diff --git a/devices/firmware/multipad/CMakeLists.txt b/devices/firmware/multipad/CMakeLists.txt new file mode 100644 index 0000000..6e6262e --- /dev/null +++ b/devices/firmware/multipad/CMakeLists.txt @@ -0,0 +1,25 @@ +cmake_minimum_required(VERSION 3.20) +project(nexting_multipad_adapter LANGUAGES C) + +set(CMAKE_C_STANDARD 99) +set(CMAKE_C_STANDARD_REQUIRED ON) +set(CMAKE_C_EXTENSIONS OFF) + +set(NEXTING_DEVICES_ROOT "${CMAKE_CURRENT_SOURCE_DIR}/../..") + +add_library(nexting_multipad_adapter STATIC + nexting_multipad_adapter.c + "${NEXTING_DEVICES_ROOT}/sdk/c/src/nexting_device.c" +) +target_include_directories(nexting_multipad_adapter + PUBLIC + "${CMAKE_CURRENT_SOURCE_DIR}" + "${NEXTING_DEVICES_ROOT}/sdk/c/include" +) +target_compile_options(nexting_multipad_adapter PRIVATE -Wall -Wextra -Wpedantic -Werror) + +enable_testing() +add_executable(test_nexting_multipad tests/test_adapter.c) +target_link_libraries(test_nexting_multipad PRIVATE nexting_multipad_adapter) +target_compile_options(test_nexting_multipad PRIVATE -Wall -Wextra -Wpedantic -Werror) +add_test(NAME nexting_multipad_adapter COMMAND test_nexting_multipad) diff --git a/devices/firmware/multipad/README.md b/devices/firmware/multipad/README.md new file mode 100644 index 0000000..5b6a933 --- /dev/null +++ b/devices/firmware/multipad/README.md @@ -0,0 +1,111 @@ +# ILX MultiPad USB CDC adapter + +This directory is the public, source-first integration layer for the open-source +[ILX MultiPad](https://github.com/iLx11/multi-pad). It is intentionally **not** +a copy of the vendor repository and it is not Nexting production firmware. + +The [first-case walkthrough](../../docs/cases/multipad-first-case.md) is the +canonical path for applying this adapter to the pinned upstream firmware. This +directory supplies the public protocol binding and tests; the upstream project +supplies the board build and remains separately licensed. + +## What is verified before opening the enclosure + +The upstream application is an STM32F103VET6 composite USB device: + +- USB HID (`STM32 HID CUSTOM`) for the normal keyboard surface; +- USB CDC ACM (`STM32 CDC ACM0`) for the configuration channel; +- 8 matrix keys (2 rows × 4 columns) and 3 rotary encoders in the upstream + source; +- no DFU, IAP, or application-side bootloader path in the upstream source; +- upstream reference commit `78c1ee533a7f513e9f390741c4f5eed1e0aa91b3`; +- upstream license: GPL-3.0. + +The purchased unit still needs one physical check before a write: whether it is +the **module PCB** (USB serial switch plus BOOT/RESET buttons) or the **FPC +PCB** (SWD/J-Link is required). Do not assume the external Type-C connector is +a bootloader. The included `nexting-multipad-device-info.template.json` keeps +unknown display, serial, and battery fields absent until that check is done. + +The upstream repository exposes firmware facts, not a PCB drawing. Its +`Config/stlink.cfg` selects ST-Link/SWD and its `leden.ioc` maps `PA13` to +`SWDIO` and `PA14` to `SWCLK`; physical pad order must be read from the actual +board before attaching a probe. + +## What this adapter does + +`nexting_multipad_adapter.c` reuses the public portable C99 SDK. It adds no +private App or Agent code. The board port supplies five things: + +1. a monotonic millisecond clock; +2. a USB CDC write function; +3. approval rendering (the two small displays, LEDs, or keys); +4. status rendering (up to the number of displays/indicators actually wired); +5. a call from the CDC receive callback. + +The adapter accepts newline-terminated Nexting JSON frames and leaves the +upstream `AA BB xx` legacy commands untouched. It owns stream framing, approval +expiry, answer retry, choice locking, resolved cleanup, status replacement, and +disconnect cleanup by delegating to `sdk/c`. It never receives a credential or +an Agent session identifier. + +Build the host-side contract test without an STM32 toolchain: + +```sh +cmake -S firmware/multipad -B firmware/multipad/.build +cmake --build firmware/multipad/.build --parallel +ctest --test-dir firmware/multipad/.build --output-on-failure +``` + +## Integrating with the upstream firmware + +Do not replace `USER/Usb/usb_user.c` wholesale. Add the adapter to the upstream +project and route only frames for which +`nexting_multipad_accepts(&adapter, Buf, *Len)` is true. Keep the existing +`AA BB CC`, `AA BB AA`, `AA BB DD`, `AA BB EE`, and `AA BB FF` branches for the +vendor configuration UI. A minimal receive hook is: + +```c +if (nexting_multipad_accepts(&nexting_adapter, Buf, *Len)) { + (void)nexting_multipad_receive(&nexting_adapter, Buf, *Len); + return USBD_OK; +} +``` + +Call `nexting_multipad_choose(&nexting_adapter, NEXTING_DEVICE_CHOICE_ALLOW)` +or `..._DENY` from the two approval keys, call +`nexting_multipad_tick()` from the existing millisecond loop, and call +`nexting_multipad_disconnect()` from the USB disconnect callback. The board +callbacks should render only the state they receive; they must not implement a +second JSON parser. + +The adapter is a protocol binding, not App enrollment. The public Nexting App +BLE path does not claim USB CDC enrollment. A Host that owns the USB permission +and authorization policy can use this binding over a local CDC port; a future +App USB transport must be released separately with its own Host tests. + +## Flash preparation (do not write yet) + +The checked-in upstream artifact is `leden.hex` from the commit above. Before +writing anything: + +1. open the back and photograph the PCB, MCU marking, and BOOT/RESET/SERIAL + switch labels; +2. save the original firmware with the vendor's supported tool; +3. confirm that the unit has the module serial path, or attach an ST-Link/J-Link + to the SWD pads; +4. run the dry checks in `tools/flash-multipad.sh --help` and the CDC smoke + check in `tools/multipad-cdc-smoke.py`; +5. only then use `--confirm --allow-write` with an explicit artifact path. + +There is no recovery promise over ordinary USB HID/CDC. If the board variant +does not expose the serial bootloader, stop and use SWD. A failed application +flash can otherwise leave the keyboard working as HID but unable to accept the +next write. + +## License and boundary + +The adapter and tests in this directory follow the Nexting Devices repository +license. The upstream MultiPad application remains GPL-3.0 and is not vendored +here. This directory contains no Nexting App, cloud, Agent bridge, production +firmware, signing key, or manufacturing file. diff --git a/devices/firmware/multipad/bringup-status.md b/devices/firmware/multipad/bringup-status.md new file mode 100644 index 0000000..1175306 --- /dev/null +++ b/devices/firmware/multipad/bringup-status.md @@ -0,0 +1,40 @@ +# MultiPad bring-up status + +Last updated: 2026-07-31 + +This is the pre-flash record for the purchased ILX MultiPad. It deliberately +separates source/build evidence from physical-board evidence. No firmware write +has been performed. + +## Current state + +| Phase | Step | Status | Evidence | +| ----- | ------------------------------- | ------------------------------- | -------------------------------------------------------------------------------------- | +| 0 | Upstream source audit | ✅ complete | STM32F103VET6, USB HID + CDC, commit `78c1ee533a7f513e9f390741c4f5eed1e0aa91b3` | +| 0 | Portable adapter build | ✅ complete | `npm run test:multipad`, CMake + ctest pass | +| 0 | Host CDC application path | ✅ complete | Connected `MultiPad_Device` CDC endpoint echoed `AA BB CC` byte-for-byte on 2026-07-31 | +| 0 | PCB variant identification | ⏳ blocked on enclosure opening | Module vs FPC is not visible from the outside | +| 0 | Original flash backup | ⏳ not started | Must identify the boot path first | +| 1 | Serial bootloader write | ⏳ not started | Only possible if the module serial path is present | +| 1 | SWD recovery/write | ⏳ not started | Required fallback for FPC or failed serial path | +| 2 | Nexting present/answer/resolved | ⏳ not started | Requires adapter firmware on the exact board | +| 2 | Status rendering | ⏳ not started | Display/indicator wiring must be photographed and mapped | + +## Bring-up order after opening + +1. Photograph the MCU, PCB revision, screens, SERIAL switch, BOOT and RESET. +2. Update [`pin-function-map.md`](pin-function-map.md) from the photograph and + the upstream source; record unknown pins rather than guessing. +3. Back up the original flash and record the tool output/hash. +4. Verify the USB CDC echo only; do not combine this with a write. +5. Build and write the adapter image through the confirmed path. +6. Verify one `present → answer → resolved` exchange, then expiry and disconnect + cleanup, one scenario at a time. +7. Update this file and the pin map immediately after each observation. + +## Explicit blockers + +- A normal HID/CDC USB connection is not proof of a bootloader. +- The public Nexting App BLE enrollment path does not imply USB enrollment. +- Battery, exact display dimensions, serial number, and SWD pad locations are + unknown until the enclosure is opened. diff --git a/devices/firmware/multipad/hw-review-findings.md b/devices/firmware/multipad/hw-review-findings.md new file mode 100644 index 0000000..77efd4a --- /dev/null +++ b/devices/firmware/multipad/hw-review-findings.md @@ -0,0 +1,16 @@ +# MultiPad hardware review findings + +Last updated: 2026-07-31 + +## Findings + +| ID | Finding | Impact | Next action | +| ----- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------- | +| MP-01 | The outside photo does not identify Module PCB vs FPC PCB. | Type-C serial flashing cannot be selected safely. | Open the enclosure and photograph the PCB labels. | +| MP-02 | Upstream source contains USB HID + CDC application code but no DFU/IAP path. | Ordinary CDC enumeration cannot be used as recovery evidence. | Use the module serial boot sequence or SWD/J-Link. | +| MP-03 | Upstream source exposes matrix and encoder modules, but the purchased unit's exact display/connector revision is unverified. | Device Info must not claim battery/display details yet. | Map traces and connectors after opening. | +| MP-04 | The live device echoed `AA BB CC` over CDC before any write. | Confirms the application CDC endpoint, not a bootloader. | Keep this as the baseline regression check. | + +No electrical anomaly has been observed because the enclosure has not been +opened. Any mismatch with the source or schematic must be added here before +continuing. diff --git a/devices/firmware/multipad/nexting-multipad-device-info.template.json b/devices/firmware/multipad/nexting-multipad-device-info.template.json new file mode 100644 index 0000000..6dda37b --- /dev/null +++ b/devices/firmware/multipad/nexting-multipad-device-info.template.json @@ -0,0 +1,32 @@ +{ + "protocol": "nexting-device", + "spec": "0.2.0-experimental.0", + "wire": [1], + "profiles": ["approval/1", "status/1"], + "model": "ilx-multipad-usb-cdc", + "fw": "nexting-multipad-dev-0.1.0", + "max_message_bytes": 4096, + "max_summary_bytes": 240, + "button_count": 8, + "approval_button_count": 2, + "custom_button_count": 6, + "rotary_count": 3, + "rotary_press_count": 3, + "statusSlots": 3, + "vendor": { + "namespace": "com.ilx.multipad", + "facts": [ + { "key": "transport", "label": "Transport", "value": "USB CDC" }, + { + "key": "legacy_protocol", + "label": "Legacy protocol", + "value": "AA BB xx" + }, + { + "key": "hardware_variant", + "label": "Hardware variant", + "value": "verify after opening" + } + ] + } +} diff --git a/devices/firmware/multipad/nexting_multipad_adapter.c b/devices/firmware/multipad/nexting_multipad_adapter.c new file mode 100644 index 0000000..a89bf35 --- /dev/null +++ b/devices/firmware/multipad/nexting_multipad_adapter.c @@ -0,0 +1,152 @@ +#include "nexting_multipad_adapter.h" + +#include + +static uint64_t adapter_now_ms(const nexting_multipad_t *adapter) { + if (adapter == NULL || adapter->now_ms == NULL) + return 0; + return adapter->now_ms(adapter->context); +} + +static void render_approval(const nexting_multipad_t *adapter) { + if (adapter != NULL && adapter->render_approval != NULL) + adapter->render_approval(&adapter->approval, adapter->context); +} + +static void render_status(const nexting_multipad_t *adapter) { + if (adapter != NULL && adapter->render_status != NULL) + adapter->render_status(&adapter->status, adapter->context); +} + +static void handle_message(const nexting_device_message_t *message, + void *context) { + nexting_multipad_t *adapter = (nexting_multipad_t *)context; + if (adapter == NULL || message == NULL) + return; + + switch (message->type) { + case NEXTING_DEVICE_MESSAGE_PRESENT: + if (nexting_device_state_on_present(&adapter->approval, message, + adapter_now_ms(adapter)) == + NEXTING_DEVICE_OK) + render_approval(adapter); + break; + case NEXTING_DEVICE_MESSAGE_RESOLVED: + if (nexting_device_state_on_resolved(&adapter->approval, message) == + NEXTING_DEVICE_OK) + render_approval(adapter); + break; + case NEXTING_DEVICE_MESSAGE_STATUS: + if (nexting_device_status_on_message(&adapter->status, message) == + NEXTING_DEVICE_OK) + render_status(adapter); + break; + default: + /* Answers and errors are host-facing; a device does not render them. */ + break; + } +} + +static void write_answer(nexting_multipad_t *adapter, + const nexting_device_message_t *answer) { + char wire[NEXTING_DEVICE_DEFAULT_MAX_MESSAGE_BYTES + 1U]; + size_t wire_length = 0; + if (adapter == NULL || answer == NULL || adapter->write_frame == NULL || + nexting_device_encode(answer, wire, sizeof wire, &wire_length) != + NEXTING_DEVICE_OK) + return; + adapter->write_frame((const uint8_t *)wire, wire_length, adapter->context); +} + +static bool contains_newline(const uint8_t *bytes, size_t length) { + if (bytes == NULL) + return false; + for (size_t i = 0; i < length; ++i) { + if (bytes[i] == '\n') + return true; + } + return false; +} + +void nexting_multipad_init(nexting_multipad_t *adapter, + nexting_multipad_now_ms_fn now_ms, + nexting_multipad_write_frame_fn write_frame, + nexting_multipad_render_approval_fn render_approval, + nexting_multipad_render_status_fn render_status, + void *context) { + if (adapter == NULL) + return; + memset(adapter, 0, sizeof *adapter); + nexting_device_state_init(&adapter->approval); + nexting_device_status_init(&adapter->status); + nexting_device_stream_init(&adapter->stream, adapter->stream_storage, + sizeof adapter->stream_storage); + adapter->now_ms = now_ms; + adapter->write_frame = write_frame; + adapter->render_approval = render_approval; + adapter->render_status = render_status; + adapter->context = context; +} + +bool nexting_multipad_accepts(const nexting_multipad_t *adapter, + const uint8_t *bytes, size_t length) { + if (adapter == NULL || bytes == NULL || length == 0) + return false; + if (adapter->frame_active) + return true; + for (size_t i = 0; i < length; ++i) { + if (bytes[i] == ' ' || bytes[i] == '\t' || bytes[i] == '\r') + continue; + return bytes[i] == '{'; + } + return false; +} + +nexting_device_result_t nexting_multipad_receive(nexting_multipad_t *adapter, + const uint8_t *bytes, + size_t length) { + if (adapter == NULL || (bytes == NULL && length != 0)) + return NEXTING_DEVICE_BAD_MESSAGE; + adapter->frame_active = true; + nexting_device_result_t result = nexting_device_stream_push( + &adapter->stream, bytes, length, handle_message, adapter); + if (contains_newline(bytes, length)) + adapter->frame_active = false; + return result; +} + +nexting_device_result_t nexting_multipad_choose( + nexting_multipad_t *adapter, nexting_device_choice_t choice) { + if (adapter == NULL || adapter->write_frame == NULL) + return NEXTING_DEVICE_BAD_MESSAGE; + nexting_device_message_t answer = {0}; + nexting_device_result_t result = nexting_device_state_choose( + &adapter->approval, choice, adapter_now_ms(adapter), &answer); + if (result == NEXTING_DEVICE_OK) { + write_answer(adapter, &answer); + render_approval(adapter); + } + return result; +} + +void nexting_multipad_tick(nexting_multipad_t *adapter) { + if (adapter == NULL) + return; + const uint64_t now_ms = adapter_now_ms(adapter); + if (nexting_device_state_tick(&adapter->approval, now_ms)) + render_approval(adapter); + nexting_device_message_t answer = {0}; + if (nexting_device_state_retry_answer(&adapter->approval, now_ms, &answer)) + write_answer(adapter, &answer); +} + +void nexting_multipad_disconnect(nexting_multipad_t *adapter) { + if (adapter == NULL) + return; + nexting_device_state_disconnect(&adapter->approval); + nexting_device_status_disconnect(&adapter->status); + nexting_device_stream_reset(&adapter->stream); + adapter->frame_active = false; + render_approval(adapter); + render_status(adapter); +} diff --git a/devices/firmware/multipad/nexting_multipad_adapter.h b/devices/firmware/multipad/nexting_multipad_adapter.h new file mode 100644 index 0000000..a878568 --- /dev/null +++ b/devices/firmware/multipad/nexting_multipad_adapter.h @@ -0,0 +1,72 @@ +#ifndef NEXTING_MULTIPAD_ADAPTER_H +#define NEXTING_MULTIPAD_ADAPTER_H + +#include +#include +#include + +#include "nexting_device.h" + +#ifdef __cplusplus +extern "C" { +#endif + +/* + * The ILX MultiPad application speaks USB CDC. This adapter deliberately + * leaves USB, GPIO, displays, and clocks to the board port and only connects + * them to the portable Nexting C99 state machine. + */ +#define NEXTING_MULTIPAD_STREAM_CAPACITY \ + (NEXTING_DEVICE_DEFAULT_MAX_MESSAGE_BYTES + 1U) + +typedef uint64_t (*nexting_multipad_now_ms_fn)(void *context); +typedef void (*nexting_multipad_write_frame_fn)(const uint8_t *bytes, + size_t length, void *context); +typedef void (*nexting_multipad_render_approval_fn)( + const nexting_device_state_t *state, void *context); +typedef void (*nexting_multipad_render_status_fn)( + const nexting_device_status_state_t *state, void *context); + +typedef struct { + nexting_device_state_t approval; + nexting_device_status_state_t status; + nexting_device_stream_t stream; + uint8_t stream_storage[NEXTING_MULTIPAD_STREAM_CAPACITY]; + bool frame_active; + nexting_multipad_now_ms_fn now_ms; + nexting_multipad_write_frame_fn write_frame; + nexting_multipad_render_approval_fn render_approval; + nexting_multipad_render_status_fn render_status; + void *context; +} nexting_multipad_t; + +void nexting_multipad_init(nexting_multipad_t *adapter, + nexting_multipad_now_ms_fn now_ms, + nexting_multipad_write_frame_fn write_frame, + nexting_multipad_render_approval_fn render_approval, + nexting_multipad_render_status_fn render_status, + void *context); + +/* + * Return true only for a JSON frame or a continuation of one. The upstream + * MultiPad firmware retains its legacy AA BB xx commands; the USB callback + * can use this gate before handing a buffer to nexting_multipad_receive(). + */ +bool nexting_multipad_accepts(const nexting_multipad_t *adapter, + const uint8_t *bytes, size_t length); + +nexting_device_result_t nexting_multipad_receive(nexting_multipad_t *adapter, + const uint8_t *bytes, + size_t length); + +nexting_device_result_t nexting_multipad_choose( + nexting_multipad_t *adapter, nexting_device_choice_t choice); + +void nexting_multipad_tick(nexting_multipad_t *adapter); +void nexting_multipad_disconnect(nexting_multipad_t *adapter); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/devices/firmware/multipad/pin-function-map.md b/devices/firmware/multipad/pin-function-map.md new file mode 100644 index 0000000..c757d0f --- /dev/null +++ b/devices/firmware/multipad/pin-function-map.md @@ -0,0 +1,43 @@ +# MultiPad pin-function map + +Last updated: 2026-07-31 + +This map records only literal evidence from the upstream STM32 source. A blank +or `pending` cell is intentional; do not fill it from memory or from a similar +board. The Nexting adapter itself is pin-agnostic and does not add a pin claim. + +## Pin mappings visible in upstream source + +| Pin | MCU function | Hardware connection | Function | Driver/source | Verification | Notes | +| ----- | ------------ | ------------------- | ---------------------- | ---------------------- | ------------------- | --------------------------------- | +| PB10 | GPIO output | matrix row 0 | key matrix row | upstream `KEY` module | ⏳ physical pending | source-level only | +| PB11 | GPIO output | matrix row 1 | key matrix row | upstream `KEY` module | ⏳ physical pending | source-level only | +| PE12 | GPIO input | matrix column 0 | key matrix column | upstream `KEY` module | ⏳ physical pending | source-level only | +| PE13 | GPIO input | matrix column 1 | key matrix column | upstream `KEY` module | ⏳ physical pending | source-level only | +| PE14 | GPIO input | matrix column 2 | key matrix column | upstream `KEY` module | ⏳ physical pending | source-level only | +| PE15 | GPIO input | matrix column 3 | key matrix column | upstream `KEY` module | ⏳ physical pending | source-level only | +| PA9 | USART1 TX | serial module path | bootloader/UART TX | upstream `USART1` init | ⏳ physical pending | only module PCB exposes this path | +| PA10 | USART1 RX | serial module path | bootloader/UART RX | upstream `USART1` init | ⏳ physical pending | only module PCB exposes this path | +| SWDIO | debug pad | MCU SWD header/pads | recovery/program data | ST-Link/J-Link | ⏳ physical pending | pad location unknown | +| SWCLK | debug pad | MCU SWD header/pads | recovery/program clock | ST-Link/J-Link | ⏳ physical pending | pad location unknown | + +## Functions needing an enclosure photograph + +| Function | Expected source evidence | Physical evidence required | Status | +| ----------------- | ------------------------------------------- | ---------------------------------------- | --------------------- | +| 8 key matrix | 2 rows × 4 columns | trace/connector and switch orientation | ⏳ | +| 3 rotary encoders | `encoder1`, `encoder2`, `encoder3` modules | exact A/B/SW pins and pull-ups | ⏳ | +| Displays | upstream OLED/LCD modules | module/FPC variant, bus pins, dimensions | ⏳ | +| USB HID + CDC | STM32 USB device stack | connector wiring and stable enumeration | ✅ host CDC echo only | +| BOOT/RESET | module PCB documentation | buttons and switch labels | ⏳ | +| Battery | no battery service found in upstream source | battery/charger IC and ADC trace | ⏳ / do not declare | + +## Reverse index + +| Goal | Pins/peripheral | Preconditions | Status | +| --------------------- | ------------------------------- | --------------------------------- | --------------- | +| Preserve keyboard HID | USB device stack | device still enumerates | ✅ before flash | +| Read original flash | PA9/PA10 serial or SWD | confirmed PCB path | ⏳ | +| Nexting CDC frames | USB CDC RX/TX | adapter image + CDC callback hook | ⏳ | +| Approval keys | key matrix + encoder/button map | exact input wiring | ⏳ | +| Status rendering | displays/LEDs | exact display map | ⏳ | diff --git a/devices/firmware/multipad/tests/test_adapter.c b/devices/firmware/multipad/tests/test_adapter.c new file mode 100644 index 0000000..7889f27 --- /dev/null +++ b/devices/firmware/multipad/tests/test_adapter.c @@ -0,0 +1,85 @@ +#include "nexting_multipad_adapter.h" + +#include +#include + +typedef struct { + uint64_t now_ms; + char tx[512]; + size_t tx_length; + size_t approval_renders; + size_t status_renders; + nexting_device_phase_t phase; + size_t status_count; +} test_context_t; + +static uint64_t now_ms(void *context) { + return ((test_context_t *)context)->now_ms; +} + +static void write_frame(const uint8_t *bytes, size_t length, void *context) { + test_context_t *test = (test_context_t *)context; + assert(length < sizeof test->tx); + memcpy(test->tx, bytes, length); + test->tx[length] = '\0'; + test->tx_length = length; +} + +static void render_approval(const nexting_device_state_t *state, + void *context) { + test_context_t *test = (test_context_t *)context; + test->approval_renders += 1U; + test->phase = state->phase; +} + +static void render_status(const nexting_device_status_state_t *state, + void *context) { + test_context_t *test = (test_context_t *)context; + test->status_renders += 1U; + test->status_count = 0; + for (size_t i = 0; i < NEXTING_DEVICE_STATUS_MAX_AGENTS; ++i) + if (state->occupied[i]) + test->status_count += 1U; +} + +int main(void) { + test_context_t context = {0}; + nexting_multipad_t adapter; + nexting_multipad_init(&adapter, now_ms, write_frame, render_approval, + render_status, &context); + + static const char present[] = + "{\"v\":1,\"t\":\"present\",\"id\":\"mp1\",\"sum\":\"Allow\"," + "\"opt\":[\"allow\",\"deny\"],\"ttl\":30000}\n"; + assert(nexting_multipad_accepts(&adapter, (const uint8_t *)present, 4)); + assert(nexting_multipad_receive(&adapter, (const uint8_t *)present, + sizeof present - 1U) == NEXTING_DEVICE_OK); + assert(context.phase == NEXTING_DEVICE_PHASE_PENDING); + assert(adapter.approval.request.has_request_id); + assert(strcmp(adapter.approval.request.request_id, "mp1") == 0); + + context.now_ms = 100; + assert(nexting_multipad_choose(&adapter, NEXTING_DEVICE_CHOICE_ALLOW) == + NEXTING_DEVICE_OK); + assert(strcmp(context.tx, + "{\"v\":1,\"t\":\"answer\",\"id\":\"mp1\",\"ch\":\"allow\"}\n") == + 0); + + static const char status[] = + "{\"v\":1,\"t\":\"status\",\"agents\":[{\"slot\":0," + "\"state\":\"working\",\"label\":\"build\"}]}\n"; + assert(nexting_multipad_receive(&adapter, (const uint8_t *)status, + sizeof status - 1U) == NEXTING_DEVICE_OK); + assert(context.status_count == 1U); + + static const char resolved[] = + "{\"v\":1,\"t\":\"resolved\",\"id\":\"mp1\",\"r\":\"answered\"}\n"; + assert(nexting_multipad_receive(&adapter, (const uint8_t *)resolved, + sizeof resolved - 1U) == NEXTING_DEVICE_OK); + assert(context.phase == NEXTING_DEVICE_PHASE_IDLE); + + nexting_multipad_disconnect(&adapter); + assert(adapter.approval.phase == NEXTING_DEVICE_PHASE_IDLE); + assert(!adapter.status.occupied[0]); + return 0; +} diff --git a/devices/firmware/multipad/tools/flash-multipad.sh b/devices/firmware/multipad/tools/flash-multipad.sh new file mode 100755 index 0000000..92bdff5 --- /dev/null +++ b/devices/firmware/multipad/tools/flash-multipad.sh @@ -0,0 +1,65 @@ +#!/bin/sh +set -eu + +usage() { + cat <<'EOF' +Usage: + flash-multipad.sh backup --port /dev/cu.usbmodem... --out backup.bin + flash-multipad.sh write --port /dev/cu.usbmodem... --hex leden.hex \ + --confirm --allow-write + +The write path is intentionally fail-closed. It requires both flags, an +explicit serial port, and an explicit Intel HEX file. This script does not +discover a BOOT/RESET sequence or claim that ordinary USB CDC is a bootloader. +EOF +} + +action=${1:-} +shift || true +port= +hex= +out= +confirm=false +allow_write=false + +while [ "$#" -gt 0 ]; do + case "$1" in + --port) port=${2:?missing value for --port}; shift 2 ;; + --hex) hex=${2:?missing value for --hex}; shift 2 ;; + --out) out=${2:?missing value for --out}; shift 2 ;; + --confirm) confirm=true; shift ;; + --allow-write) allow_write=true; shift ;; + -h|--help) usage; exit 0 ;; + *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; + esac +done + +[ -n "$port" ] || { echo "--port is required" >&2; exit 2; } +command -v stm32flash >/dev/null 2>&1 || { + echo "stm32flash is required (install it before using this script)" >&2 + exit 1 +} + +case "$action" in + backup) + [ -n "$out" ] || { echo "--out is required for backup" >&2; exit 2; } + [ ! -e "$out" ] || { echo "refusing to overwrite existing backup: $out" >&2; exit 2; } + echo "Reading the original flash to $out; put the module PCB in serial boot mode first." + exec stm32flash -b 115200 -r "$out" "$port" + ;; + write) + [ -n "$hex" ] || { echo "--hex is required for write" >&2; exit 2; } + [ -f "$hex" ] || { echo "HEX file not found: $hex" >&2; exit 2; } + [ "$confirm" = true ] && [ "$allow_write" = true ] || { + echo "refusing to write: pass --confirm --allow-write explicitly" >&2 + exit 2 + } + case "$hex" in + *.hex|*.HEX) ;; + *) echo "refusing non-Intel-HEX artifact: $hex" >&2; exit 2 ;; + esac + echo "Writing $hex to $port; the board must already be in serial boot mode." + exec stm32flash -b 115200 -w "$hex" -v -g 0x08000000 "$port" + ;; + *) usage >&2; exit 2 ;; +esac diff --git a/devices/firmware/multipad/tools/multipad-cdc-smoke.py b/devices/firmware/multipad/tools/multipad-cdc-smoke.py new file mode 100755 index 0000000..cb419b3 --- /dev/null +++ b/devices/firmware/multipad/tools/multipad-cdc-smoke.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""Small, dependency-free USB CDC smoke check for a Nexting MultiPad port.""" + +from __future__ import annotations + +import argparse +import os +import select +import termios +import time + + +def configure(fd: int) -> None: + attrs = termios.tcgetattr(fd) + attrs[0] = 0 + attrs[1] = 0 + attrs[2] = termios.CLOCAL | termios.CREAD | termios.CS8 + attrs[3] = 0 + attrs[4] = termios.B115200 + attrs[5] = termios.B115200 + termios.tcsetattr(fd, termios.TCSANOW, attrs) + + +def read_until(fd: int, timeout: float) -> bytes: + end = time.monotonic() + timeout + data = bytearray() + while time.monotonic() < end: + ready, _, _ = select.select([fd], [], [], 0.1) + if ready: + data.extend(os.read(fd, 4096)) + if b"\n" in data or len(data) >= 3: + break + return bytes(data) + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("port", help="the explicit USB CDC ACM path") + parser.add_argument( + "--present", + action="store_true", + help="send a Nexting present frame after the legacy echo check", + ) + args = parser.parse_args() + fd = os.open(args.port, os.O_RDWR | os.O_NOCTTY | os.O_NONBLOCK) + try: + configure(fd) + os.write(fd, bytes((0xAA, 0xBB, 0xCC))) + echo = read_until(fd, 1.5) + if echo != bytes((0xAA, 0xBB, 0xCC)): + raise SystemExit(f"legacy CDC echo mismatch: {echo.hex()}") + print("legacy CDC echo: PASS (AA BB CC)") + if args.present: + frame = ( + b'{"v":1,"t":"present","id":"smoke",' + b'"sum":"Allow smoke test?","opt":["allow","deny"],' + b'"ttl":30000}\n' + ) + os.write(fd, frame) + print("present frame: SENT (Nexting adapter firmware required for answer)") + return 0 + finally: + os.close(fd) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/devices/firmware/zephyr/README.md b/devices/firmware/zephyr/README.md index f2ad034..a05eee6 100644 --- a/devices/firmware/zephyr/README.md +++ b/devices/firmware/zephyr/README.md @@ -6,12 +6,12 @@ Start with [the public foundation](../../docs/foundation-development.md) and [re ## Reference wiring -| Board | Allow | Deny | Pending output | -| --- | --- | --- | --- | -| nRF52840 DK | Button 1 (`sw0`) | Button 2 (`sw1`) | LED 1 (`led0`) | -| XIAO nRF52840 / Sense | D0 to GND | D1 to GND | onboard red LED | -| XIAO ESP32-C3 | D0 to GND | D1 to GND | D2 through a resistor to an LED, active high | -| XIAO ESP32-S3 | D0 to GND | D1 to GND | onboard LED | +| Board | Allow | Deny | Pending output | +| --------------------- | ---------------- | ---------------- | -------------------------------------------- | +| nRF52840 DK | Button 1 (`sw0`) | Button 2 (`sw1`) | LED 1 (`led0`) | +| XIAO nRF52840 / Sense | D0 to GND | D1 to GND | onboard red LED | +| XIAO ESP32-C3 | D0 to GND | D1 to GND | D2 through a resistor to an LED, active high | +| XIAO ESP32-S3 | D0 to GND | D1 to GND | onboard LED | The button inputs use internal pull-ups and are active low. diff --git a/devices/firmware/zephyr/tests/firmware-contract.test.mjs b/devices/firmware/zephyr/tests/firmware-contract.test.mjs index 4a7931e..ba57328 100644 --- a/devices/firmware/zephyr/tests/firmware-contract.test.mjs +++ b/devices/firmware/zephyr/tests/firmware-contract.test.mjs @@ -2,7 +2,8 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -const read = (relativePath) => readFile(new URL(relativePath, import.meta.url), "utf8"); +const read = (relativePath) => + readFile(new URL(relativePath, import.meta.url), "utf8"); test("firmware reuses the fixed-buffer protocol core", async () => { const cmake = await read("../CMakeLists.txt"); @@ -16,8 +17,12 @@ test("firmware reuses the fixed-buffer protocol core", async () => { "nexting_device_state_retry_answer", "nexting_device_state_tick", "nexting_device_state_disconnect", - ]) assert.ok(main.includes(call), `missing shared-core call ${call}`); - assert.ok(!main.includes("strstr("), "firmware must not parse protocol with substring searches"); + ]) + assert.ok(main.includes(call), `missing shared-core call ${call}`); + assert.ok( + !main.includes("strstr("), + "firmware must not parse protocol with substring searches", + ); }); test("GATT service publishes the complete encrypted transport", async () => { @@ -28,7 +33,10 @@ test("GATT service publishes the complete encrypted transport", async () => { assert.ok(main.includes(shortID), `missing GATT UUID component ${shortID}`); } assert.match(main, /BT_GATT_PERM_WRITE_ENCRYPT/); - assert.match(main, /BT_GATT_PERM_READ_ENCRYPT[\s\S]*BT_GATT_PERM_WRITE_ENCRYPT/); + assert.match( + main, + /BT_GATT_PERM_READ_ENCRYPT[\s\S]*BT_GATT_PERM_WRITE_ENCRYPT/, + ); assert.match(main, /max_message_bytes/); assert.match(main, /max_summary_bytes/); assert.match(main, /0\.2\.0-experimental\.2/); @@ -52,16 +60,32 @@ test("bonding is persistent but approvals are volatile", async () => { "CONFIG_BT_BONDING_REQUIRED=y", "CONFIG_BT_SETTINGS=y", "CONFIG_SETTINGS=y", - ]) assert.ok(config.includes(option), `missing ${option}`); + ]) + assert.ok(config.includes(option), `missing ${option}`); assert.match(main, /settings_load\(\)/); assert.ok(!main.includes("settings_save_one")); - assert.match(main, /clear_volatile_state_locked[\s\S]*nexting_device_state_disconnect/); - assert.match(main, /static void disconnected[\s\S]*clear_volatile_state_locked\(\)/); + assert.match( + main, + /clear_volatile_state_locked[\s\S]*nexting_device_state_disconnect/, + ); + assert.match( + main, + /static void disconnected[\s\S]*clear_volatile_state_locked\(\)/, + ); assert.match(main, /bt_unpair\(BT_ID_DEFAULT, BT_ADDR_LE_ANY\)/); assert.match(main, /K_SECONDS\(3\)/); - assert.match(main, /static void bond_reset_work_handler[\s\S]*bt_conn_disconnect/); - assert.match(main, /static void bond_reset_work_handler[\s\S]*clear_volatile_state_locked/); - assert.match(main, /static void bond_reset_work_handler[\s\S]*bt_unpair\(BT_ID_DEFAULT, BT_ADDR_LE_ANY\)/); + assert.match( + main, + /static void bond_reset_work_handler[\s\S]*bt_conn_disconnect/, + ); + assert.match( + main, + /static void bond_reset_work_handler[\s\S]*clear_volatile_state_locked/, + ); + assert.match( + main, + /static void bond_reset_work_handler[\s\S]*bt_unpair\(BT_ID_DEFAULT, BT_ADDR_LE_ANY\)/, + ); assert.match(main, /both_pressed_since[\s\S]*K_SECONDS\(3\)/); const resetHandler = main.slice( main.indexOf("static void bond_reset_work_handler(struct k_work *work)\n{"), @@ -78,10 +102,19 @@ test("bonding is persistent but approvals are volatile", async () => { /complete_bond_reset_locked[\s\S]*bond_reset_unpair_succeeded[\s\S]*current_connection\s*!=\s*NULL[\s\S]*return false;[\s\S]*bond_reset_in_progress\s*=\s*false[\s\S]*set_pending_led\(true\)/, ); assert.match(main, /static void disconnected[\s\S]*bond_reset_in_progress/); - assert.match(main, /static void disconnected[\s\S]*complete_bond_reset_locked\(\)/); - assert.match(main, /write_downlink[\s\S]*bond_reset_in_progress[\s\S]*BT_ATT_ERR_AUTHORIZATION/); + assert.match( + main, + /static void disconnected[\s\S]*complete_bond_reset_locked\(\)/, + ); + assert.match( + main, + /write_downlink[\s\S]*bond_reset_in_progress[\s\S]*BT_ATT_ERR_AUTHORIZATION/, + ); assert.match(main, /subscription_changed[\s\S]*!bond_reset_in_progress/); - assert.match(main, /static void connected[\s\S]*bond_reset_in_progress[\s\S]*bt_conn_disconnect/); + assert.match( + main, + /static void connected[\s\S]*bond_reset_in_progress[\s\S]*bt_conn_disconnect/, + ); const connectedHandler = main.slice( main.indexOf("static void connected"), main.indexOf("static void disconnected"), @@ -94,11 +127,20 @@ test("bonding is persistent but approvals are volatile", async () => { main.indexOf("static void security_changed"), main.indexOf("BT_CONN_CB_DEFINE"), ); - assert.match(connectedHandler, /bt_conn_set_security[\s\S]*bt_conn_disconnect/); + assert.match( + connectedHandler, + /bt_conn_set_security[\s\S]*bt_conn_disconnect/, + ); assert.ok(!disconnectedHandler.includes("start_advertising()")); assert.match(disconnectedHandler, /schedule_advertising\(\)/); - assert.match(securityHandler, /error\s*==\s*BT_SECURITY_ERR_SUCCESS[\s\S]*level\s*>=\s*BT_SECURITY_L2[\s\S]*return;[\s\S]*bt_conn_disconnect/); - assert.match(main, /advertising_work_handler[\s\S]*start_advertising\(\)[\s\S]*k_work_reschedule/); + assert.match( + securityHandler, + /error\s*==\s*BT_SECURITY_ERR_SUCCESS[\s\S]*level\s*>=\s*BT_SECURITY_L2[\s\S]*return;[\s\S]*bt_conn_disconnect/, + ); + assert.match( + main, + /advertising_work_handler[\s\S]*start_advertising\(\)[\s\S]*k_work_reschedule/, + ); }); test("uplink uses one fixed callback-driven fragmented frame", async () => { @@ -106,13 +148,25 @@ test("uplink uses one fixed callback-driven fragmented frame", async () => { assert.match(main, /static char uplink_frame\[/); assert.match(main, /bt_gatt_get_mtu\(current_connection\)\s*-\s*3/); assert.match(main, /bt_gatt_notify_cb\(/); - assert.match(main, /static void notification_sent[\s\S]*uplink_frame_offset\s*\+=/); - assert.match(main, /uplink_frame_offset\s*>=\s*uplink_frame_length[\s\S]*next_retry_ms/); + assert.match( + main, + /static void notification_sent[\s\S]*uplink_frame_offset\s*\+=/, + ); + assert.match( + main, + /uplink_frame_offset\s*>=\s*uplink_frame_length[\s\S]*next_retry_ms/, + ); assert.match(main, /clear_uplink_locked[\s\S]*uplink_frame_length\s*=\s*0/); assert.match(main, /subscription_changed[\s\S]*clear_uplink_locked/); assert.match(main, /disconnected[\s\S]*clear_uplink_locked/); - assert.match(main, /clear_uplink_locked[\s\S]*uplink_resync_required\s*=\s*true/); - assert.match(main, /queue_uplink_locked[\s\S]*uplink_resync_required[\s\S]*'\\n'/); + assert.match( + main, + /clear_uplink_locked[\s\S]*uplink_resync_required\s*=\s*true/, + ); + assert.match( + main, + /queue_uplink_locked[\s\S]*uplink_resync_required[\s\S]*'\\n'/, + ); const queueHandler = main.slice( main.indexOf("static bool queue_uplink_locked"), main.indexOf("static void notification_sent"), @@ -125,9 +179,18 @@ test("uplink uses one fixed callback-driven fragmented frame", async () => { !queueHandler.includes("uplink_resync_required = false"), "queueing a delimiter must not claim it was delivered", ); - assert.match(notificationHandler, /uplink_frame_has_resync_prefix[\s\S]*uplink_resync_required\s*=\s*false/); - assert.match(main, /clear_uplink_locked[\s\S]*uplink_notification_in_flight[\s\S]*uplink_resync_required\s*=\s*true/); - assert.ok(!main.includes("bt_gatt_notify("), "uplink must not bypass completion callbacks"); + assert.match( + notificationHandler, + /uplink_frame_has_resync_prefix[\s\S]*uplink_resync_required\s*=\s*false/, + ); + assert.match( + main, + /clear_uplink_locked[\s\S]*uplink_notification_in_flight[\s\S]*uplink_resync_required\s*=\s*true/, + ); + assert.ok( + !main.includes("bt_gatt_notify("), + "uplink must not bypass completion callbacks", + ); }); test("XIAO overlays map two explicit actions and a visible state", async () => { @@ -151,10 +214,16 @@ test("public board claims match the exact build matrix", async () => { "XIAO ESP32S3", ]) { assert.ok(rootReadme.includes(supported), `README misses ${supported}`); - assert.ok(hardwareSupport.includes(supported), `hardware support misses ${supported}`); + assert.ok( + hardwareSupport.includes(supported), + `hardware support misses ${supported}`, + ); } for (const unsupported of ["ESP32-C3-DevKitM", "ESP32-S3-DevKitC"]) { - assert.ok(!rootReadme.includes(unsupported), `README overclaims ${unsupported}`); + assert.ok( + !rootReadme.includes(unsupported), + `README overclaims ${unsupported}`, + ); assert.ok( !hardwareSupport.includes(unsupported), `hardware support overclaims ${unsupported}`, diff --git a/devices/package.json b/devices/package.json index 67cf95e..4c8e8ed 100644 --- a/devices/package.json +++ b/devices/package.json @@ -1,14 +1,17 @@ { "name": "@nexting-ai/devices", - "version": "0.2.0-experimental.2", + "version": "0.4.0-experimental.1", "private": true, "description": "Open interfaces for building physical control surfaces for AI agents.", "type": "module", "scripts": { - "test": "npm run test:reference && npm run test:scripts && npm run test:firmware", + "test": "npm run test:reference && npm run test:simulator && npm run test:scripts && npm run test:firmware && npm run test:multipad && npm run test:ahakey", "test:reference": "node --test reference/js/test/*.test.mjs", + "test:simulator": "node --test simulator/*.test.mjs", "test:scripts": "node --test scripts/*.test.mjs", "test:firmware": "npm --prefix firmware/zephyr test", + "test:multipad": "cmake -S firmware/multipad -B firmware/multipad/.build && cmake --build firmware/multipad/.build --parallel && ctest --test-dir firmware/multipad/.build --output-on-failure", + "test:ahakey": "cmake -S firmware/ahakey-x1 -B firmware/ahakey-x1/.build && cmake --build firmware/ahakey-x1/.build --parallel && ctest --test-dir firmware/ahakey-x1/.build --output-on-failure", "export": "node scripts/export-nexting-devices.mjs", "check:boundary": "node scripts/check-public-boundary.mjs", "check:naming": "node scripts/check-naming.mjs", diff --git a/devices/protocol/vectors/approval-v1.json b/devices/protocol/vectors/approval-v1.json index e0c1a05..1f7d9ba 100644 --- a/devices/protocol/vectors/approval-v1.json +++ b/devices/protocol/vectors/approval-v1.json @@ -43,10 +43,7 @@ "type": "present", "requestId": "boundary-240", "summary": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "options": [ - "allow", - "deny" - ], + "options": ["allow", "deny"], "ttlMs": 30000 } }, diff --git a/devices/protocol/vectors/compatibility-tunnel-v1.json b/devices/protocol/vectors/compatibility-tunnel-v1.json new file mode 100644 index 0000000..f3333eb --- /dev/null +++ b/devices/protocol/vectors/compatibility-tunnel-v1.json @@ -0,0 +1,56 @@ +{ + "spec": "0.3.0-experimental.1", + "version": 1, + "valid": [ + { + "name": "vendor-neutral fixed opcode tunnel", + "descriptor": { + "version": 1, + "integration_id": "example.controls-x1", + "display_name": "Example Controls X1", + "discovery": { + "service_uuid": "8f4e3d10-91b2-4c6d-a7e8-1029384756ab" + }, + "envelope": { + "strategy": "opcode-prefix-v1", + "max_payload_bytes": 243 + }, + "channels": { + "control_down": { + "characteristic_uuid": "8f4e3d11-91b2-4c6d-a7e8-1029384756ab", + "property": "write", + "opcode": 16 + }, + "control_up": { + "characteristic_uuid": "8f4e3d12-91b2-4c6d-a7e8-1029384756ab", + "property": "notify", + "opcode": 17 + }, + "audio_up": { + "characteristic_uuid": "8f4e3d13-91b2-4c6d-a7e8-1029384756ab", + "property": "notify", + "opcode": 18 + }, + "flow_down": { + "characteristic_uuid": "8f4e3d14-91b2-4c6d-a7e8-1029384756ab", + "property": "write", + "opcode": 19 + } + }, + "requirements": { + "bonded": true, + "encrypted": true, + "min_att_payload": 64 + } + } + } + ], + "invalid": [ + { "name": "unknown envelope strategy", "path": "envelope.strategy", "value": "javascript" }, + { "name": "URL is not an integration id", "path": "integration_id", "value": "https://vendor.example/plugin" }, + { "name": "duplicate opcode", "path": "channels.audio_up.opcode", "value": 17 }, + { "name": "audio cannot use write", "path": "channels.audio_up.property", "value": "write" }, + { "name": "ATT payload below baseline", "path": "requirements.min_att_payload", "value": 63 }, + { "name": "executable field", "path": "script", "value": "fetch('https://vendor.example')" } + ] +} diff --git a/devices/protocol/vectors/config-v1.json b/devices/protocol/vectors/config-v1.json index 2a20733..e2022ab 100644 --- a/devices/protocol/vectors/config-v1.json +++ b/devices/protocol/vectors/config-v1.json @@ -6,31 +6,65 @@ { "name": "atomic mixed configuration", "wire": "{\"v\":1,\"t\":\"config\",\"rev\":7,\"entries\":[{\"key\":\"key.0.mode\",\"value\":\"momentary\"},{\"key\":\"display.brightness\",\"value\":70},{\"key\":\"haptics.enabled\",\"value\":true}]}\n", - "decoded": {"type":"config","revision":7,"entries":[{"key":"key.0.mode","value":"momentary"},{"key":"display.brightness","value":70},{"key":"haptics.enabled","value":true}]} + "decoded": { + "type": "config", + "revision": 7, + "entries": [ + { "key": "key.0.mode", "value": "momentary" }, + { "key": "display.brightness", "value": 70 }, + { "key": "haptics.enabled", "value": true } + ] + } }, { "name": "clear configuration", "wire": "{\"v\":1,\"t\":\"config\",\"rev\":8,\"entries\":[]}\n", - "decoded": {"type":"config","revision":8,"entries":[]} + "decoded": { "type": "config", "revision": 8, "entries": [] } }, { "name": "configuration applied", "wire": "{\"v\":1,\"t\":\"config_result\",\"rev\":7,\"status\":\"applied\"}\n", - "decoded": {"type":"configResult","revision":7,"status":"applied"} + "decoded": { "type": "configResult", "revision": 7, "status": "applied" } }, { "name": "configuration rejected", "wire": "{\"v\":1,\"t\":\"config_result\",\"rev\":8,\"status\":\"rejected\",\"code\":\"unknown_key\"}\n", - "decoded": {"type":"configResult","revision":8,"status":"rejected","code":"unknown_key"} + "decoded": { + "type": "configResult", + "revision": 8, + "status": "rejected", + "code": "unknown_key" + } } ], "invalid": [ - {"name":"duplicate keys","wire":"{\"v\":1,\"t\":\"config\",\"rev\":7,\"entries\":[{\"key\":\"a\",\"value\":1},{\"key\":\"a\",\"value\":2}]}\n"}, - {"name":"bad key","wire":"{\"v\":1,\"t\":\"config\",\"rev\":7,\"entries\":[{\"key\":\"bad key\",\"value\":1}]}\n"}, - {"name":"object value forbidden","wire":"{\"v\":1,\"t\":\"config\",\"rev\":7,\"entries\":[{\"key\":\"a\",\"value\":{\"private\":true}}]}\n"}, - {"name":"null value forbidden","wire":"{\"v\":1,\"t\":\"config\",\"rev\":7,\"entries\":[{\"key\":\"a\",\"value\":null}]}\n"}, - {"name":"applied with code","wire":"{\"v\":1,\"t\":\"config_result\",\"rev\":7,\"status\":\"applied\",\"code\":\"unknown_key\"}\n"}, - {"name":"rejected without code","wire":"{\"v\":1,\"t\":\"config_result\",\"rev\":7,\"status\":\"rejected\"}\n"}, - {"name":"bad rejection code","wire":"{\"v\":1,\"t\":\"config_result\",\"rev\":7,\"status\":\"rejected\",\"code\":\"denied\"}\n"} + { + "name": "duplicate keys", + "wire": "{\"v\":1,\"t\":\"config\",\"rev\":7,\"entries\":[{\"key\":\"a\",\"value\":1},{\"key\":\"a\",\"value\":2}]}\n" + }, + { + "name": "bad key", + "wire": "{\"v\":1,\"t\":\"config\",\"rev\":7,\"entries\":[{\"key\":\"bad key\",\"value\":1}]}\n" + }, + { + "name": "object value forbidden", + "wire": "{\"v\":1,\"t\":\"config\",\"rev\":7,\"entries\":[{\"key\":\"a\",\"value\":{\"private\":true}}]}\n" + }, + { + "name": "null value forbidden", + "wire": "{\"v\":1,\"t\":\"config\",\"rev\":7,\"entries\":[{\"key\":\"a\",\"value\":null}]}\n" + }, + { + "name": "applied with code", + "wire": "{\"v\":1,\"t\":\"config_result\",\"rev\":7,\"status\":\"applied\",\"code\":\"unknown_key\"}\n" + }, + { + "name": "rejected without code", + "wire": "{\"v\":1,\"t\":\"config_result\",\"rev\":7,\"status\":\"rejected\"}\n" + }, + { + "name": "bad rejection code", + "wire": "{\"v\":1,\"t\":\"config_result\",\"rev\":7,\"status\":\"rejected\",\"code\":\"denied\"}\n" + } ] } diff --git a/devices/protocol/vectors/device-audio-v1.json b/devices/protocol/vectors/device-audio-v1.json new file mode 100644 index 0000000..6e94631 --- /dev/null +++ b/devices/protocol/vectors/device-audio-v1.json @@ -0,0 +1,115 @@ +{ + "spec": "0.3.0-experimental.1", + "wire": 1, + "profile": "device-audio/1", + "format": { + "codec": "ima_adpcm", + "sample_rate": 16000, + "channels": 1, + "frame_ms": 20, + "samples_per_frame": 320 + }, + "crc32c": [ + { + "name": "Castagnoli check value", + "utf8": "123456789", + "value": 3808858755 + } + ], + "valid": [ + { + "name": "zero PCM block in a complete logical frame", + "epoch": 287454020, + "stream_id": 2864434397, + "sequence": 0, + "sample_index": 0, + "step_index": 0, + "pcm_fill": 0, + "frame_hex": "0101010044332211ddccbbaa0000000000000000400100000000a00000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000c78c4635" + } + ], + "invalid": [ + { + "name": "truncated CRC", + "frame_hex": "0101010044332211ddccbbaa0000000000000000400100000000a000" + }, + { + "name": "unknown codec", + "mutate": { "offset": 2, "value": 2 }, + "recompute_crc": true + }, + { + "name": "reserved flag set", + "mutate": { "offset": 3, "value": 1 }, + "recompute_crc": true + }, + { + "name": "step index above 88", + "mutate": { "offset": 24, "value": 89 }, + "recompute_crc": true + }, + { + "name": "non-zero reserved byte", + "mutate": { "offset": 25, "value": 1 }, + "recompute_crc": true + }, + { + "name": "sample index does not match sequence", + "mutate": { "offset": 16, "value": 1 }, + "recompute_crc": true + }, + { + "name": "corrupt CRC", + "mutate": { "offset": 191, "value": 0 } + } + ], + "control": { + "valid": [ + { + "name": "host prepares a bounded receiver without opening the microphone", + "wire": "{\"v\":1,\"t\":\"audio_config\",\"epoch\":287454020,\"codec\":\"ima_adpcm\",\"sample_rate\":16000,\"channels\":1,\"frame_ms\":20,\"max_duration_ms\":120000,\"startup_buffer_ms\":200,\"credits\":8}\n", + "decoded": { "type": "audioConfig", "epoch": 287454020, "codec": "ima_adpcm", "sampleRate": 16000, "channels": 1, "frameMs": 20, "maxDurationMs": 120000, "startupBufferMs": 200, "credits": 8 } + }, + { + "name": "device begins only after a physical input", + "wire": "{\"v\":1,\"t\":\"audio_begin\",\"epoch\":287454020,\"stream_id\":2864434397,\"codec\":\"ima_adpcm\",\"sample_rate\":16000,\"channels\":1,\"frame_ms\":20}\n", + "decoded": { "type": "audioBegin", "epoch": 287454020, "streamId": 2864434397, "codec": "ima_adpcm", "sampleRate": 16000, "channels": 1, "frameMs": 20 } + }, + { + "name": "host cumulatively acknowledges consumed frames", + "wire": "{\"v\":1,\"t\":\"audio_credit\",\"epoch\":287454020,\"stream_id\":2864434397,\"ack_seq\":7,\"credits\":4}\n", + "decoded": { "type": "audioCredit", "epoch": 287454020, "streamId": 2864434397, "ackSequence": 7, "credits": 4 } + }, + { + "name": "device drains and ends a submitted stream", + "wire": "{\"v\":1,\"t\":\"audio_end\",\"epoch\":287454020,\"stream_id\":2864434397,\"last_seq\":49,\"sample_count\":16000,\"reason\":\"submitted\"}\n", + "decoded": { "type": "audioEnd", "epoch": 287454020, "streamId": 2864434397, "lastSequence": 49, "sampleCount": 16000, "reason": "submitted" } + }, + { + "name": "either endpoint cancels the matching stream", + "wire": "{\"v\":1,\"t\":\"audio_cancel\",\"epoch\":287454020,\"stream_id\":2864434397,\"reason\":\"rejected\"}\n", + "decoded": { "type": "audioCancel", "epoch": 287454020, "streamId": 2864434397, "reason": "rejected" } + }, + { + "name": "host displays transcribing state", + "wire": "{\"v\":1,\"t\":\"audio_state\",\"epoch\":287454020,\"stream_id\":2864434397,\"state\":\"transcribing\"}\n", + "decoded": { "type": "audioState", "epoch": 287454020, "streamId": 2864434397, "state": "transcribing" } + }, + { + "name": "host advertises receiver readiness outside a stream", + "wire": "{\"v\":1,\"t\":\"audio_state\",\"epoch\":287454020,\"state\":\"ready\"}\n", + "decoded": { "type": "audioState", "epoch": 287454020, "state": "ready" } + } + ], + "invalid": [ + "{\"v\":1,\"t\":\"audio_config\",\"epoch\":1,\"codec\":\"pcm16\",\"sample_rate\":16000,\"channels\":1,\"frame_ms\":20,\"max_duration_ms\":120000,\"startup_buffer_ms\":200,\"credits\":8}\n", + "{\"v\":1,\"t\":\"audio_config\",\"epoch\":1,\"codec\":\"ima_adpcm\",\"sample_rate\":16000,\"channels\":1,\"frame_ms\":20,\"max_duration_ms\":120001,\"startup_buffer_ms\":200,\"credits\":8}\n", + "{\"v\":1,\"t\":\"audio_begin\",\"epoch\":1,\"stream_id\":1,\"codec\":\"ima_adpcm\",\"sample_rate\":16000,\"channels\":1,\"frame_ms\":20,\"remote_start\":true}\n", + "{\"v\":1,\"t\":\"audio_credit\",\"epoch\":1,\"stream_id\":1,\"ack_seq\":-1,\"credits\":1}\n", + "{\"v\":1,\"t\":\"audio_end\",\"epoch\":1,\"stream_id\":1,\"last_seq\":0,\"sample_count\":0,\"reason\":\"submitted\"}\n", + "{\"v\":1,\"t\":\"audio_end\",\"epoch\":1,\"stream_id\":1,\"last_seq\":1,\"sample_count\":320,\"reason\":\"submitted\"}\n", + "{\"v\":1,\"t\":\"audio_cancel\",\"epoch\":1,\"stream_id\":1,\"reason\":\"remote_start\"}\n", + "{\"v\":1,\"t\":\"audio_state\",\"epoch\":1,\"state\":\"receiving\"}\n" + ] + } +} diff --git a/devices/protocol/vectors/device-info-v1.json b/devices/protocol/vectors/device-info-v1.json index 0196748..647e5ab 100644 --- a/devices/protocol/vectors/device-info-v1.json +++ b/devices/protocol/vectors/device-info-v1.json @@ -79,8 +79,8 @@ "vendor": { "namespace": "com.ilx.multipad", "facts": [ - {"key": "layers", "label": "Layers", "value": "4"}, - {"key": "layers", "label": "Other", "value": "5"} + { "key": "layers", "label": "Layers", "value": "4" }, + { "key": "layers", "label": "Other", "value": "5" } ] } }, @@ -88,28 +88,32 @@ "name": "html value", "vendor": { "namespace": "com.ilx.multipad", - "facts": [{"key": "layers", "label": "Layers", "value": "4"}] + "facts": [{ "key": "layers", "label": "Layers", "value": "4" }] } }, { "name": "url value", "vendor": { "namespace": "com.ilx.multipad", - "facts": [{"key": "site", "label": "Site", "value": "https://example.com"}] + "facts": [ + { "key": "site", "label": "Site", "value": "https://example.com" } + ] } }, { "name": "bad namespace", "vendor": { "namespace": "multipad", - "facts": [{"key": "layers", "label": "Layers", "value": "4"}] + "facts": [{ "key": "layers", "label": "Layers", "value": "4" }] } }, { "name": "object value", "vendor": { "namespace": "com.ilx.multipad", - "facts": [{"key": "layers", "label": "Layers", "value": {"count": 4}}] + "facts": [ + { "key": "layers", "label": "Layers", "value": { "count": 4 } } + ] } } ] diff --git a/devices/protocol/vectors/keys-v1.json b/devices/protocol/vectors/keys-v1.json index 5b0070d..40aa9db 100644 --- a/devices/protocol/vectors/keys-v1.json +++ b/devices/protocol/vectors/keys-v1.json @@ -6,26 +6,64 @@ { "name": "replace key presentation", "wire": "{\"v\":1,\"t\":\"keymap\",\"rev\":4,\"keys\":[{\"slot\":0,\"label\":\"Approve\",\"enabled\":true,\"light\":\"solid\",\"rgb\":[0,200,90]}]}\n", - "decoded": {"type":"keymap","revision":4,"keys":[{"slot":0,"label":"Approve","enabled":true,"light":"solid","rgb":[0,200,90]}]} + "decoded": { + "type": "keymap", + "revision": 4, + "keys": [ + { + "slot": 0, + "label": "Approve", + "enabled": true, + "light": "solid", + "rgb": [0, 200, 90] + } + ] + } }, { "name": "clear key presentation", "wire": "{\"v\":1,\"t\":\"keymap\",\"rev\":5,\"keys\":[]}\n", - "decoded": {"type":"keymap","revision":5,"keys":[]} + "decoded": { "type": "keymap", "revision": 5, "keys": [] } }, { "name": "physical key press", "wire": "{\"v\":1,\"t\":\"key_event\",\"slot\":0,\"event\":\"press\",\"seq\":41}\n", - "decoded": {"type":"keyEvent","slot":0,"event":"press","sequence":41} + "decoded": { + "type": "keyEvent", + "slot": 0, + "event": "press", + "sequence": 41 + } } ], "invalid": [ - {"name":"duplicate slots","wire":"{\"v\":1,\"t\":\"keymap\",\"rev\":4,\"keys\":[{\"slot\":0,\"label\":\"A\",\"enabled\":true,\"light\":\"off\"},{\"slot\":0,\"label\":\"B\",\"enabled\":true,\"light\":\"off\"}]}\n"}, - {"name":"bad light","wire":"{\"v\":1,\"t\":\"keymap\",\"rev\":4,\"keys\":[{\"slot\":0,\"label\":\"A\",\"enabled\":true,\"light\":\"rainbow\"}]}\n"}, - {"name":"bad rgb length","wire":"{\"v\":1,\"t\":\"keymap\",\"rev\":4,\"keys\":[{\"slot\":0,\"label\":\"A\",\"enabled\":true,\"light\":\"solid\",\"rgb\":[0,1]}]}\n"}, - {"name":"rgb outside byte","wire":"{\"v\":1,\"t\":\"keymap\",\"rev\":4,\"keys\":[{\"slot\":0,\"label\":\"A\",\"enabled\":true,\"light\":\"solid\",\"rgb\":[0,1,256]}]}\n"}, - {"name":"slot outside range","wire":"{\"v\":1,\"t\":\"key_event\",\"slot\":64,\"event\":\"press\",\"seq\":41}\n"}, - {"name":"bad gesture","wire":"{\"v\":1,\"t\":\"key_event\",\"slot\":0,\"event\":\"triple\",\"seq\":41}\n"}, - {"name":"non canonical revision","wire":"{\"v\":1,\"t\":\"keymap\",\"rev\":4.0,\"keys\":[]}\n"} + { + "name": "duplicate slots", + "wire": "{\"v\":1,\"t\":\"keymap\",\"rev\":4,\"keys\":[{\"slot\":0,\"label\":\"A\",\"enabled\":true,\"light\":\"off\"},{\"slot\":0,\"label\":\"B\",\"enabled\":true,\"light\":\"off\"}]}\n" + }, + { + "name": "bad light", + "wire": "{\"v\":1,\"t\":\"keymap\",\"rev\":4,\"keys\":[{\"slot\":0,\"label\":\"A\",\"enabled\":true,\"light\":\"rainbow\"}]}\n" + }, + { + "name": "bad rgb length", + "wire": "{\"v\":1,\"t\":\"keymap\",\"rev\":4,\"keys\":[{\"slot\":0,\"label\":\"A\",\"enabled\":true,\"light\":\"solid\",\"rgb\":[0,1]}]}\n" + }, + { + "name": "rgb outside byte", + "wire": "{\"v\":1,\"t\":\"keymap\",\"rev\":4,\"keys\":[{\"slot\":0,\"label\":\"A\",\"enabled\":true,\"light\":\"solid\",\"rgb\":[0,1,256]}]}\n" + }, + { + "name": "slot outside range", + "wire": "{\"v\":1,\"t\":\"key_event\",\"slot\":64,\"event\":\"press\",\"seq\":41}\n" + }, + { + "name": "bad gesture", + "wire": "{\"v\":1,\"t\":\"key_event\",\"slot\":0,\"event\":\"triple\",\"seq\":41}\n" + }, + { + "name": "non canonical revision", + "wire": "{\"v\":1,\"t\":\"keymap\",\"rev\":4.0,\"keys\":[]}\n" + } ] } diff --git a/devices/protocol/vectors/navigation-v1.json b/devices/protocol/vectors/navigation-v1.json index 4587bae..b4ccd9a 100644 --- a/devices/protocol/vectors/navigation-v1.json +++ b/devices/protocol/vectors/navigation-v1.json @@ -6,32 +6,76 @@ { "name": "present two options", "wire": "{\"v\":1,\"t\":\"nav_present\",\"id\":\"q7\",\"items\":[\"Fix it\",\"Explain\"],\"cursor\":0,\"ttl\":30000}\n", - "decoded": {"type":"navPresent","requestId":"q7","items":["Fix it","Explain"],"cursor":0,"ttlMs":30000} + "decoded": { + "type": "navPresent", + "requestId": "q7", + "items": ["Fix it", "Explain"], + "cursor": 0, + "ttlMs": 30000 + } }, { "name": "move next", "wire": "{\"v\":1,\"t\":\"nav_move\",\"id\":\"q7\",\"dir\":\"next\",\"seq\":12}\n", - "decoded": {"type":"navMove","requestId":"q7","direction":"next","sequence":12} + "decoded": { + "type": "navMove", + "requestId": "q7", + "direction": "next", + "sequence": 12 + } }, { "name": "select option", "wire": "{\"v\":1,\"t\":\"nav_select\",\"id\":\"q7\",\"index\":1,\"seq\":13}\n", - "decoded": {"type":"navSelect","requestId":"q7","index":1,"sequence":13} + "decoded": { + "type": "navSelect", + "requestId": "q7", + "index": 1, + "sequence": 13 + } }, { "name": "resolve selected", "wire": "{\"v\":1,\"t\":\"nav_resolved\",\"id\":\"q7\",\"r\":\"selected\"}\n", - "decoded": {"type":"navResolved","requestId":"q7","reason":"selected"} + "decoded": { + "type": "navResolved", + "requestId": "q7", + "reason": "selected" + } } ], "invalid": [ - {"name":"too few options","wire":"{\"v\":1,\"t\":\"nav_present\",\"id\":\"q7\",\"items\":[\"Only\"],\"cursor\":0,\"ttl\":30000}\n"}, - {"name":"duplicate options","wire":"{\"v\":1,\"t\":\"nav_present\",\"id\":\"q7\",\"items\":[\"Same\",\"Same\"],\"cursor\":0,\"ttl\":30000}\n"}, - {"name":"cursor outside items","wire":"{\"v\":1,\"t\":\"nav_present\",\"id\":\"q7\",\"items\":[\"A\",\"B\"],\"cursor\":2,\"ttl\":30000}\n"}, - {"name":"bad direction","wire":"{\"v\":1,\"t\":\"nav_move\",\"id\":\"q7\",\"dir\":\"clockwise\",\"seq\":12}\n"}, - {"name":"negative sequence","wire":"{\"v\":1,\"t\":\"nav_move\",\"id\":\"q7\",\"dir\":\"next\",\"seq\":-1}\n"}, - {"name":"selection outside profile bound","wire":"{\"v\":1,\"t\":\"nav_select\",\"id\":\"q7\",\"index\":8,\"seq\":13}\n"}, - {"name":"bad resolution","wire":"{\"v\":1,\"t\":\"nav_resolved\",\"id\":\"q7\",\"r\":\"answered\"}\n"}, - {"name":"unknown field","wire":"{\"v\":1,\"t\":\"nav_move\",\"id\":\"q7\",\"dir\":\"next\",\"seq\":12,\"action\":\"fork\"}\n"} + { + "name": "too few options", + "wire": "{\"v\":1,\"t\":\"nav_present\",\"id\":\"q7\",\"items\":[\"Only\"],\"cursor\":0,\"ttl\":30000}\n" + }, + { + "name": "duplicate options", + "wire": "{\"v\":1,\"t\":\"nav_present\",\"id\":\"q7\",\"items\":[\"Same\",\"Same\"],\"cursor\":0,\"ttl\":30000}\n" + }, + { + "name": "cursor outside items", + "wire": "{\"v\":1,\"t\":\"nav_present\",\"id\":\"q7\",\"items\":[\"A\",\"B\"],\"cursor\":2,\"ttl\":30000}\n" + }, + { + "name": "bad direction", + "wire": "{\"v\":1,\"t\":\"nav_move\",\"id\":\"q7\",\"dir\":\"clockwise\",\"seq\":12}\n" + }, + { + "name": "negative sequence", + "wire": "{\"v\":1,\"t\":\"nav_move\",\"id\":\"q7\",\"dir\":\"next\",\"seq\":-1}\n" + }, + { + "name": "selection outside profile bound", + "wire": "{\"v\":1,\"t\":\"nav_select\",\"id\":\"q7\",\"index\":8,\"seq\":13}\n" + }, + { + "name": "bad resolution", + "wire": "{\"v\":1,\"t\":\"nav_resolved\",\"id\":\"q7\",\"r\":\"answered\"}\n" + }, + { + "name": "unknown field", + "wire": "{\"v\":1,\"t\":\"nav_move\",\"id\":\"q7\",\"dir\":\"next\",\"seq\":12,\"action\":\"fork\"}\n" + } ] } diff --git a/devices/protocol/vectors/rotary-v1.json b/devices/protocol/vectors/rotary-v1.json index 6d83589..66d56d3 100644 --- a/devices/protocol/vectors/rotary-v1.json +++ b/devices/protocol/vectors/rotary-v1.json @@ -6,25 +6,66 @@ { "name": "replace rotary presentation", "wire": "{\"v\":1,\"t\":\"rotary_map\",\"rev\":8,\"controls\":[{\"slot\":0,\"label\":\"Model\",\"value\":2,\"min\":0,\"max\":3,\"wrap\":true}]}\n", - "decoded": {"type":"rotaryMap","revision":8,"controls":[{"slot":0,"label":"Model","value":2,"minimum":0,"maximum":3,"wrap":true}]} + "decoded": { + "type": "rotaryMap", + "revision": 8, + "controls": [ + { + "slot": 0, + "label": "Model", + "value": 2, + "minimum": 0, + "maximum": 3, + "wrap": true + } + ] + } }, { "name": "rotate one detent", "wire": "{\"v\":1,\"t\":\"rotary_event\",\"slot\":0,\"delta\":1,\"seq\":52}\n", - "decoded": {"type":"rotaryEvent","slot":0,"delta":1,"sequence":52} + "decoded": { + "type": "rotaryEvent", + "slot": 0, + "delta": 1, + "sequence": 52 + } }, { "name": "rotary press", "wire": "{\"v\":1,\"t\":\"rotary_press\",\"slot\":0,\"event\":\"press\",\"seq\":53}\n", - "decoded": {"type":"rotaryPress","slot":0,"event":"press","sequence":53} + "decoded": { + "type": "rotaryPress", + "slot": 0, + "event": "press", + "sequence": 53 + } } ], "invalid": [ - {"name":"value below minimum","wire":"{\"v\":1,\"t\":\"rotary_map\",\"rev\":8,\"controls\":[{\"slot\":0,\"label\":\"Model\",\"value\":-1,\"min\":0,\"max\":3,\"wrap\":true}]}\n"}, - {"name":"minimum above maximum","wire":"{\"v\":1,\"t\":\"rotary_map\",\"rev\":8,\"controls\":[{\"slot\":0,\"label\":\"Model\",\"value\":2,\"min\":3,\"max\":0,\"wrap\":true}]}\n"}, - {"name":"duplicate controls","wire":"{\"v\":1,\"t\":\"rotary_map\",\"rev\":8,\"controls\":[{\"slot\":0,\"label\":\"A\",\"value\":0,\"min\":0,\"max\":1,\"wrap\":false},{\"slot\":0,\"label\":\"B\",\"value\":0,\"min\":0,\"max\":1,\"wrap\":false}]}\n"}, - {"name":"zero delta","wire":"{\"v\":1,\"t\":\"rotary_event\",\"slot\":0,\"delta\":0,\"seq\":52}\n"}, - {"name":"large delta","wire":"{\"v\":1,\"t\":\"rotary_event\",\"slot\":0,\"delta\":128,\"seq\":52}\n"}, - {"name":"bad press","wire":"{\"v\":1,\"t\":\"rotary_press\",\"slot\":0,\"event\":\"turn\",\"seq\":53}\n"} + { + "name": "value below minimum", + "wire": "{\"v\":1,\"t\":\"rotary_map\",\"rev\":8,\"controls\":[{\"slot\":0,\"label\":\"Model\",\"value\":-1,\"min\":0,\"max\":3,\"wrap\":true}]}\n" + }, + { + "name": "minimum above maximum", + "wire": "{\"v\":1,\"t\":\"rotary_map\",\"rev\":8,\"controls\":[{\"slot\":0,\"label\":\"Model\",\"value\":2,\"min\":3,\"max\":0,\"wrap\":true}]}\n" + }, + { + "name": "duplicate controls", + "wire": "{\"v\":1,\"t\":\"rotary_map\",\"rev\":8,\"controls\":[{\"slot\":0,\"label\":\"A\",\"value\":0,\"min\":0,\"max\":1,\"wrap\":false},{\"slot\":0,\"label\":\"B\",\"value\":0,\"min\":0,\"max\":1,\"wrap\":false}]}\n" + }, + { + "name": "zero delta", + "wire": "{\"v\":1,\"t\":\"rotary_event\",\"slot\":0,\"delta\":0,\"seq\":52}\n" + }, + { + "name": "large delta", + "wire": "{\"v\":1,\"t\":\"rotary_event\",\"slot\":0,\"delta\":128,\"seq\":52}\n" + }, + { + "name": "bad press", + "wire": "{\"v\":1,\"t\":\"rotary_press\",\"slot\":0,\"event\":\"turn\",\"seq\":53}\n" + } ] } diff --git a/devices/protocol/vectors/text-v1.json b/devices/protocol/vectors/text-v1.json index 6533413..40e2455 100644 --- a/devices/protocol/vectors/text-v1.json +++ b/devices/protocol/vectors/text-v1.json @@ -6,23 +6,40 @@ { "name": "show plain text", "wire": "{\"v\":1,\"t\":\"text\",\"channel\":0,\"title\":\"Current task\",\"content\":\"Waiting for approval\"}\n", - "decoded": {"type":"text","channel":0,"title":"Current task","content":"Waiting for approval"} + "decoded": { + "type": "text", + "channel": 0, + "title": "Current task", + "content": "Waiting for approval" + } }, { "name": "clear channel", "wire": "{\"v\":1,\"t\":\"text\",\"channel\":7,\"content\":\"\"}\n", - "decoded": {"type":"text","channel":7,"content":""} + "decoded": { "type": "text", "channel": 7, "content": "" } }, { "name": "multiline content", "wire": "{\"v\":1,\"t\":\"text\",\"channel\":1,\"content\":\"Line 1\\n\\tLine 2\"}\n", - "decoded": {"type":"text","channel":1,"content":"Line 1\n\tLine 2"} + "decoded": { "type": "text", "channel": 1, "content": "Line 1\n\tLine 2" } } ], "invalid": [ - {"name":"channel outside range","wire":"{\"v\":1,\"t\":\"text\",\"channel\":8,\"content\":\"x\"}\n"}, - {"name":"empty title","wire":"{\"v\":1,\"t\":\"text\",\"channel\":0,\"title\":\"\",\"content\":\"x\"}\n"}, - {"name":"control in content","wire":"{\"v\":1,\"t\":\"text\",\"channel\":0,\"content\":\"bad\\u0007content\"}\n"}, - {"name":"unknown command field","wire":"{\"v\":1,\"t\":\"text\",\"channel\":0,\"content\":\"x\",\"action\":\"send\"}\n"} + { + "name": "channel outside range", + "wire": "{\"v\":1,\"t\":\"text\",\"channel\":8,\"content\":\"x\"}\n" + }, + { + "name": "empty title", + "wire": "{\"v\":1,\"t\":\"text\",\"channel\":0,\"title\":\"\",\"content\":\"x\"}\n" + }, + { + "name": "control in content", + "wire": "{\"v\":1,\"t\":\"text\",\"channel\":0,\"content\":\"bad\\u0007content\"}\n" + }, + { + "name": "unknown command field", + "wire": "{\"v\":1,\"t\":\"text\",\"channel\":0,\"content\":\"x\",\"action\":\"send\"}\n" + } ] } diff --git a/devices/protocol/vectors/usage-v1.json b/devices/protocol/vectors/usage-v1.json index 0fa735e..39c0779 100644 --- a/devices/protocol/vectors/usage-v1.json +++ b/devices/protocol/vectors/usage-v1.json @@ -6,26 +6,60 @@ { "name": "full usage snapshot", "wire": "{\"v\":1,\"t\":\"usage\",\"model\":\"GPT-5.6\",\"input_tokens\":1234,\"output_tokens\":567,\"cached_tokens\":89,\"context_used\":1800,\"context_limit\":128000}\n", - "decoded": {"type":"usage","model":"GPT-5.6","inputTokens":1234,"outputTokens":567,"cachedTokens":89,"contextUsed":1800,"contextLimit":128000} + "decoded": { + "type": "usage", + "model": "GPT-5.6", + "inputTokens": 1234, + "outputTokens": 567, + "cachedTokens": 89, + "contextUsed": 1800, + "contextLimit": 128000 + } }, { "name": "minimum usage snapshot", "wire": "{\"v\":1,\"t\":\"usage\",\"model\":\"Claude\",\"input_tokens\":0,\"output_tokens\":0}\n", - "decoded": {"type":"usage","model":"Claude","inputTokens":0,"outputTokens":0} + "decoded": { + "type": "usage", + "model": "Claude", + "inputTokens": 0, + "outputTokens": 0 + } }, { "name": "clear usage", "wire": "{\"v\":1,\"t\":\"usage_clear\"}\n", - "decoded": {"type":"usageClear"} + "decoded": { "type": "usageClear" } } ], "invalid": [ - {"name":"negative tokens","wire":"{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":-1,\"output_tokens\":0}\n"}, - {"name":"fractional tokens","wire":"{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":1.5,\"output_tokens\":0}\n"}, - {"name":"unsafe integer","wire":"{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":9007199254740992,\"output_tokens\":0}\n"}, - {"name":"partial context pair","wire":"{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":1,\"output_tokens\":0,\"context_used\":1}\n"}, - {"name":"context exceeds limit","wire":"{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":1,\"output_tokens\":0,\"context_used\":2,\"context_limit\":1}\n"}, - {"name":"billing field forbidden","wire":"{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":1,\"output_tokens\":0,\"cost\":1}\n"}, - {"name":"usage clear extra field","wire":"{\"v\":1,\"t\":\"usage_clear\",\"model\":\"GPT\"}\n"} + { + "name": "negative tokens", + "wire": "{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":-1,\"output_tokens\":0}\n" + }, + { + "name": "fractional tokens", + "wire": "{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":1.5,\"output_tokens\":0}\n" + }, + { + "name": "unsafe integer", + "wire": "{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":9007199254740992,\"output_tokens\":0}\n" + }, + { + "name": "partial context pair", + "wire": "{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":1,\"output_tokens\":0,\"context_used\":1}\n" + }, + { + "name": "context exceeds limit", + "wire": "{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":1,\"output_tokens\":0,\"context_used\":2,\"context_limit\":1}\n" + }, + { + "name": "billing field forbidden", + "wire": "{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":1,\"output_tokens\":0,\"cost\":1}\n" + }, + { + "name": "usage clear extra field", + "wire": "{\"v\":1,\"t\":\"usage_clear\",\"model\":\"GPT\"}\n" + } ] } diff --git a/devices/protocol/vectors/voice-v1.json b/devices/protocol/vectors/voice-v1.json index 68ddc8a..501f827 100644 --- a/devices/protocol/vectors/voice-v1.json +++ b/devices/protocol/vectors/voice-v1.json @@ -6,29 +6,48 @@ { "name": "start host microphone", "wire": "{\"v\":1,\"t\":\"voice_event\",\"event\":\"start\",\"seq\":61}\n", - "decoded": {"type":"voiceEvent","event":"start","sequence":61} + "decoded": { "type": "voiceEvent", "event": "start", "sequence": 61 } }, { "name": "stop host microphone", "wire": "{\"v\":1,\"t\":\"voice_event\",\"event\":\"stop\",\"seq\":62}\n", - "decoded": {"type":"voiceEvent","event":"stop","sequence":62} + "decoded": { "type": "voiceEvent", "event": "stop", "sequence": 62 } }, { "name": "show listening state", "wire": "{\"v\":1,\"t\":\"voice_state\",\"state\":\"listening\",\"label\":\"Release to send\"}\n", - "decoded": {"type":"voiceState","state":"listening","label":"Release to send"} + "decoded": { + "type": "voiceState", + "state": "listening", + "label": "Release to send" + } }, { "name": "idle without label", "wire": "{\"v\":1,\"t\":\"voice_state\",\"state\":\"idle\"}\n", - "decoded": {"type":"voiceState","state":"idle"} + "decoded": { "type": "voiceState", "state": "idle" } } ], "invalid": [ - {"name":"bad voice event","wire":"{\"v\":1,\"t\":\"voice_event\",\"event\":\"audio\",\"seq\":61}\n"}, - {"name":"audio bytes forbidden","wire":"{\"v\":1,\"t\":\"voice_event\",\"event\":\"start\",\"seq\":61,\"audio\":\"AAAA\"}\n"}, - {"name":"bad voice state","wire":"{\"v\":1,\"t\":\"voice_state\",\"state\":\"recording\"}\n"}, - {"name":"empty label","wire":"{\"v\":1,\"t\":\"voice_state\",\"state\":\"idle\",\"label\":\"\"}\n"}, - {"name":"control in label","wire":"{\"v\":1,\"t\":\"voice_state\",\"state\":\"error\",\"label\":\"bad\\u0007label\"}\n"} + { + "name": "bad voice event", + "wire": "{\"v\":1,\"t\":\"voice_event\",\"event\":\"audio\",\"seq\":61}\n" + }, + { + "name": "audio bytes forbidden", + "wire": "{\"v\":1,\"t\":\"voice_event\",\"event\":\"start\",\"seq\":61,\"audio\":\"AAAA\"}\n" + }, + { + "name": "bad voice state", + "wire": "{\"v\":1,\"t\":\"voice_state\",\"state\":\"recording\"}\n" + }, + { + "name": "empty label", + "wire": "{\"v\":1,\"t\":\"voice_state\",\"state\":\"idle\",\"label\":\"\"}\n" + }, + { + "name": "control in label", + "wire": "{\"v\":1,\"t\":\"voice_state\",\"state\":\"error\",\"label\":\"bad\\u0007label\"}\n" + } ] } diff --git a/devices/reference/js/README.md b/devices/reference/js/README.md index e16b186..5f0d7a2 100644 --- a/devices/reference/js/README.md +++ b/devices/reference/js/README.md @@ -1,8 +1,8 @@ # JavaScript protocol reference -This package is the executable, human-readable reference for Nexting Device Protocol Experimental 0.2. Use it to understand the wire contract, generate conformance fixtures, and compare another SDK or firmware port with the canonical behavior. +This package is the executable, human-readable reference for Nexting Device Protocol Experimental 0.4. Use it to understand the wire contract, generate conformance fixtures, and compare another SDK or firmware port with the canonical behavior. -It is deliberately marked `private`. Experimental 0.2 does not publish this package to npm, and a product should not make its runtime depend on the reference implementation. Use it for protocol tests, fixture generation, and a portable implementation reference; firmware should use the C99 device core. The official Nexting App owns Bluetooth enrollment, authorization, and Agent actions. +It is deliberately marked `private`. Experimental 0.4 does not publish this package to npm, and a product should not make its runtime depend on the reference implementation. Use it for protocol tests, fixture generation, and a portable implementation reference; firmware should use the C99 device core. The official Nexting App owns Bluetooth enrollment, authorization, and Agent actions. ## Explicit entry points @@ -10,13 +10,17 @@ It is deliberately marked `private`. Experimental 0.2 does not publish this pack import { decode, encode } from "@nexting-ai/device-reference/protocol"; import { createLineDecoder } from "@nexting-ai/device-reference/framing"; import { createApprovalRelay } from "@nexting-ai/device-reference/relay"; +import { encodeAudioFrame } from "@nexting-ai/device-reference/audio"; +import { parseTransportDescriptor } from "@nexting-ai/device-reference/tunnel"; ``` - `protocol` validates and encodes the four `approval/1` message types. - `framing` turns bounded newline-delimited byte chunks into messages. - `relay` demonstrates replacement, TTL, authorization, phone/device races, and two-phase Agent completion. +- `audio` provides CRC32C, IMA ADPCM, logical audio frames, and fragments. +- `tunnel` validates data-only descriptors and the fixed opcode envelope. -The official reference enforces the Experimental 0.2 host ceiling: a complete JSON message plus its terminating newline must fit within 4096 bytes. `createLineDecoder` accepts a configured maximum from 1 through 4096 bytes and rejects an individual input chunk above 4096 bytes, so callers must stream transport-sized chunks rather than an unbounded file or socket buffer. +The official reference enforces the Experimental 0.4 host ceiling: a complete JSON message plus its terminating newline must fit within 4096 bytes. `createLineDecoder` accepts a configured maximum from 1 through 4096 bytes and rejects an individual input chunk above 4096 bytes, so callers must stream transport-sized chunks rather than an unbounded file or socket buffer. For local tools, install the directory as a file dependency or run code from inside this package. The named exports are intentional; importing private files below `src/` is not a supported interface. diff --git a/devices/reference/js/package.json b/devices/reference/js/package.json index d0f953e..e382084 100644 --- a/devices/reference/js/package.json +++ b/devices/reference/js/package.json @@ -1,6 +1,6 @@ { "name": "@nexting-ai/device-reference", - "version": "0.2.0-experimental.2", + "version": "0.4.0-experimental.1", "private": true, "description": "Executable JavaScript reference for the Nexting Device Protocol.", "type": "module", @@ -8,7 +8,9 @@ "./protocol": "./src/protocol.mjs", "./device-info": "./src/device-info.mjs", "./framing": "./src/framing.mjs", - "./relay": "./src/relay.mjs" + "./relay": "./src/relay.mjs", + "./audio": "./src/audio.mjs", + "./tunnel": "./src/tunnel.mjs" }, "scripts": { "test": "node --test test/*.test.mjs" diff --git a/devices/reference/js/src/audio.mjs b/devices/reference/js/src/audio.mjs new file mode 100644 index 0000000..99ce43e --- /dev/null +++ b/devices/reference/js/src/audio.mjs @@ -0,0 +1,301 @@ +export const DEVICE_AUDIO_PROFILE = "device-audio/1"; +export const DEVICE_AUDIO_WIRE_VERSION = 1; +export const DEVICE_AUDIO_CODEC_IMA_ADPCM = 1; +export const DEVICE_AUDIO_SAMPLE_RATE = 16_000; +export const DEVICE_AUDIO_FRAME_MS = 20; +export const DEVICE_AUDIO_SAMPLES_PER_FRAME = 320; +export const DEVICE_AUDIO_PAYLOAD_BYTES = 160; +export const DEVICE_AUDIO_MAX_FRAMES = 6_000; +export const DEVICE_AUDIO_MAX_SAMPLES = 1_920_000; +export const AUDIO_FRAME_HEADER_BYTES = 28; +export const AUDIO_FRAME_BYTES = + AUDIO_FRAME_HEADER_BYTES + DEVICE_AUDIO_PAYLOAD_BYTES + 4; +export const AUDIO_FRAGMENT_HEADER_BYTES = 12; +export const MAX_AUDIO_FRAGMENTS = 4; + +const stepTable = [ + 7, 8, 9, 10, 11, 12, 13, 14, 16, 17, 19, 21, 23, 25, 28, 31, 34, 37, + 41, 45, 50, 55, 60, 66, 73, 80, 88, 97, 107, 118, 130, 143, 157, 173, + 190, 209, 230, 253, 279, 307, 337, 371, 408, 449, 494, 544, 598, 658, + 724, 796, 876, 963, 1060, 1166, 1282, 1411, 1552, 1707, 1878, 2066, + 2272, 2499, 2749, 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, + 6484, 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, 15289, + 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767, +]; +const indexTable = [-1, -1, -1, -1, 2, 4, 6, 8]; + +function clamp(value, minimum, maximum) { + return Math.max(minimum, Math.min(maximum, value)); +} + +function isU32(value) { + return Number.isInteger(value) && value >= 0 && value <= 0xffff_ffff; +} + +function isValidFramePosition(sequence, sampleIndex) { + return ( + isU32(sequence) && + sequence < DEVICE_AUDIO_MAX_FRAMES && + isU32(sampleIndex) && + sampleIndex === sequence * DEVICE_AUDIO_SAMPLES_PER_FRAME + ); +} + +function updatePredictor(predictor, step, code) { + let difference = step >> 3; + if (code & 4) difference += step; + if (code & 2) difference += step >> 1; + if (code & 1) difference += step >> 2; + return clamp(predictor + (code & 8 ? -difference : difference), -32768, 32767); +} + +export function crc32c(bytes) { + if (!(bytes instanceof Uint8Array)) throw new TypeError("bytes must be Uint8Array"); + let crc = 0xffff_ffff; + for (const byte of bytes) { + crc ^= byte; + for (let bit = 0; bit < 8; bit += 1) { + crc = (crc >>> 1) ^ (crc & 1 ? 0x82f6_3b78 : 0); + } + } + return (crc ^ 0xffff_ffff) >>> 0; +} + +export function encodeImaAdpcmBlock(samples, stepIndex = 0) { + if (!(samples instanceof Int16Array) || samples.length !== 320) { + throw new RangeError("IMA ADPCM blocks require exactly 320 PCM16 samples"); + } + if (!Number.isInteger(stepIndex) || stepIndex < 0 || stepIndex > 88) { + throw new RangeError("stepIndex must be between 0 and 88"); + } + + const predictor = samples[0]; + let currentPredictor = predictor; + let currentIndex = stepIndex; + const payload = new Uint8Array(DEVICE_AUDIO_PAYLOAD_BYTES); + for (let sampleIndex = 1; sampleIndex < samples.length; sampleIndex += 1) { + const step = stepTable[currentIndex]; + let difference = samples[sampleIndex] - currentPredictor; + let code = 0; + if (difference < 0) { + code = 8; + difference = -difference; + } + let threshold = step; + if (difference >= threshold) { + code |= 4; + difference -= threshold; + } + threshold >>= 1; + if (difference >= threshold) { + code |= 2; + difference -= threshold; + } + threshold >>= 1; + if (difference >= threshold) code |= 1; + + currentPredictor = updatePredictor(currentPredictor, step, code); + currentIndex = clamp(currentIndex + indexTable[code & 7], 0, 88); + const codeIndex = sampleIndex - 1; + const byteIndex = codeIndex >> 1; + if ((codeIndex & 1) === 0) payload[byteIndex] = code; + else payload[byteIndex] |= code << 4; + } + return { predictor, stepIndex, payload }; +} + +export function decodeImaAdpcmBlock({ predictor, stepIndex, payload, sampleCount }) { + if ( + !Number.isInteger(predictor) || + predictor < -32768 || + predictor > 32767 || + !Number.isInteger(stepIndex) || + stepIndex < 0 || + stepIndex > 88 || + !(payload instanceof Uint8Array) || + !Number.isInteger(sampleCount) || + sampleCount < 1 || + sampleCount > 320 || + payload.length !== Math.ceil((sampleCount - 1) / 2) + ) { + throw new RangeError("invalid IMA ADPCM block"); + } + if ((sampleCount - 1) % 2 === 1 && (payload.at(-1) & 0xf0) !== 0) { + throw new RangeError("unused ADPCM high nibble must be zero"); + } + + const samples = new Int16Array(sampleCount); + samples[0] = predictor; + let currentPredictor = predictor; + let currentIndex = stepIndex; + for (let sampleIndex = 1; sampleIndex < sampleCount; sampleIndex += 1) { + const codeIndex = sampleIndex - 1; + const packed = payload[codeIndex >> 1]; + const code = codeIndex & 1 ? packed >> 4 : packed & 0x0f; + currentPredictor = updatePredictor( + currentPredictor, + stepTable[currentIndex], + code, + ); + currentIndex = clamp(currentIndex + indexTable[code & 7], 0, 88); + samples[sampleIndex] = currentPredictor; + } + return samples; +} + +export function encodeAudioFrame({ + epoch, + streamId, + sequence, + sampleIndex, + samples, + stepIndex = 0, +}) { + if ( + !isU32(epoch) || + !isU32(streamId) || + !isValidFramePosition(sequence, sampleIndex) + ) { + throw new RangeError("audio frame counters must be unsigned 32-bit integers"); + } + const block = encodeImaAdpcmBlock(samples, stepIndex); + const frame = new Uint8Array(AUDIO_FRAME_BYTES); + const view = new DataView(frame.buffer); + frame[0] = DEVICE_AUDIO_WIRE_VERSION; + frame[1] = 1; + frame[2] = DEVICE_AUDIO_CODEC_IMA_ADPCM; + frame[3] = 0; + view.setUint32(4, epoch, true); + view.setUint32(8, streamId, true); + view.setUint32(12, sequence, true); + view.setUint32(16, sampleIndex, true); + view.setUint16(20, samples.length, true); + view.setInt16(22, block.predictor, true); + frame[24] = block.stepIndex; + frame[25] = 0; + view.setUint16(26, block.payload.length, true); + frame.set(block.payload, AUDIO_FRAME_HEADER_BYTES); + view.setUint32(AUDIO_FRAME_BYTES - 4, crc32c(frame.subarray(0, -4)), true); + return frame; +} + +export function decodeAudioFrame(frame) { + if (!(frame instanceof Uint8Array) || frame.byteLength !== AUDIO_FRAME_BYTES) { + return null; + } + const view = new DataView(frame.buffer, frame.byteOffset, frame.byteLength); + const sequence = view.getUint32(12, true); + const sampleIndex = view.getUint32(16, true); + if ( + frame[0] !== DEVICE_AUDIO_WIRE_VERSION || + frame[1] !== 1 || + frame[2] !== DEVICE_AUDIO_CODEC_IMA_ADPCM || + frame[3] !== 0 || + frame[24] > 88 || + frame[25] !== 0 || + view.getUint16(20, true) !== DEVICE_AUDIO_SAMPLES_PER_FRAME || + view.getUint16(26, true) !== DEVICE_AUDIO_PAYLOAD_BYTES || + !isValidFramePosition(sequence, sampleIndex) || + (frame[AUDIO_FRAME_BYTES - 5] & 0xf0) !== 0 || + view.getUint32(AUDIO_FRAME_BYTES - 4, true) !== + crc32c(frame.subarray(0, -4)) + ) { + return null; + } + const payload = frame.slice(AUDIO_FRAME_HEADER_BYTES, -4); + try { + return { + epoch: view.getUint32(4, true), + streamId: view.getUint32(8, true), + sequence, + sampleIndex, + sampleCount: view.getUint16(20, true), + codec: "ima_adpcm", + predictor: view.getInt16(22, true), + stepIndex: frame[24], + payload, + samples: decodeImaAdpcmBlock({ + predictor: view.getInt16(22, true), + stepIndex: frame[24], + payload, + sampleCount: view.getUint16(20, true), + }), + }; + } catch { + return null; + } +} + +export function fragmentAudioFrame( + frame, + { streamId, sequence, maxPacketBytes }, +) { + if ( + !(frame instanceof Uint8Array) || + frame.length !== AUDIO_FRAME_BYTES || + !isU32(streamId) || + !isU32(sequence) || + !Number.isInteger(maxPacketBytes) || + maxPacketBytes <= AUDIO_FRAGMENT_HEADER_BYTES + ) { + throw new RangeError("invalid fragmentation arguments"); + } + const decoded = decodeAudioFrame(frame); + if ( + decoded === null || + decoded.streamId !== streamId || + decoded.sequence !== sequence + ) { + throw new RangeError("fragment identity must match the logical frame"); + } + const chunkBytes = maxPacketBytes - AUDIO_FRAGMENT_HEADER_BYTES; + const count = Math.ceil(frame.length / chunkBytes); + if (count > MAX_AUDIO_FRAGMENTS) { + throw new RangeError("logical audio frame exceeds four fragments"); + } + return Array.from({ length: count }, (_, fragmentIndex) => { + const payload = frame.subarray( + fragmentIndex * chunkBytes, + Math.min(frame.length, (fragmentIndex + 1) * chunkBytes), + ); + const packet = new Uint8Array(AUDIO_FRAGMENT_HEADER_BYTES + payload.length); + const view = new DataView(packet.buffer); + packet[0] = DEVICE_AUDIO_WIRE_VERSION; + packet[1] = 2; + packet[2] = fragmentIndex; + packet[3] = count; + view.setUint32(4, streamId, true); + view.setUint32(8, sequence, true); + packet.set(payload, AUDIO_FRAGMENT_HEADER_BYTES); + return packet; + }); +} + +export function parseAudioFragment(packet) { + if ( + !(packet instanceof Uint8Array) || + packet.length <= AUDIO_FRAGMENT_HEADER_BYTES || + packet.length > AUDIO_FRAGMENT_HEADER_BYTES + AUDIO_FRAME_BYTES + ) { + return null; + } + const view = new DataView(packet.buffer, packet.byteOffset, packet.byteLength); + const fragmentIndex = packet[2]; + const fragmentCount = packet[3]; + if ( + packet[0] !== DEVICE_AUDIO_WIRE_VERSION || + packet[1] !== 2 || + fragmentCount < 1 || + fragmentCount > MAX_AUDIO_FRAGMENTS || + fragmentIndex >= fragmentCount + ) { + return null; + } + return { + streamId: view.getUint32(4, true), + sequence: view.getUint32(8, true), + fragmentIndex, + fragmentCount, + payload: packet.slice(AUDIO_FRAGMENT_HEADER_BYTES), + }; +} diff --git a/devices/reference/js/src/device-info.mjs b/devices/reference/js/src/device-info.mjs index 1eddd90..14ef235 100644 --- a/devices/reference/js/src/device-info.mjs +++ b/devices/reference/js/src/device-info.mjs @@ -1,3 +1,5 @@ +import { hasUniqueObjectKeys } from "./strict-json.mjs"; + export const MAX_DEVICE_INFO_BYTES = 4_096; export const MAX_DEVICE_INFO_STRING_BYTES = 64; export const MAX_VENDOR_FACTS = 16; @@ -8,7 +10,8 @@ const decoder = new TextDecoder("utf-8", { fatal: true }); const controlCharacterPattern = /[\u0000-\u001f\u007f]/; const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; -const namespacePattern = /^[a-z0-9](?:[a-z0-9-]{0,62}\.)+[a-z0-9][a-z0-9-]{0,62}$/; +const namespacePattern = + /^[a-z0-9](?:[a-z0-9-]{0,62}\.)+[a-z0-9][a-z0-9-]{0,62}$/; const factKeyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$/; const inertTextPattern = /(?:<\/?[a-z]|https?:\/\/|www\.|[`*_#[\]()])/i; @@ -44,6 +47,7 @@ const knownFields = new Set([ "battery_service", "display", "haptics", + "audio", "vendor", ]); @@ -217,6 +221,48 @@ function normalizeHaptics(value) { return [...value]; } +function normalizeAudio(value, profileDeclared) { + if (value === undefined) return profileDeclared ? undefined : null; + if (!profileDeclared || !isObject(value)) return undefined; + const fields = [ + "source", + "codecs", + "sample_rates", + "channels", + "frame_ms", + "max_duration_ms", + "startup_buffer_ms", + ]; + if ( + Object.keys(value).length !== fields.length || + !Object.keys(value).every((field) => fields.includes(field)) || + value.source !== "device_microphone" || + !Array.isArray(value.codecs) || + value.codecs.length !== 1 || + value.codecs[0] !== "ima_adpcm" || + !Array.isArray(value.sample_rates) || + value.sample_rates.length !== 1 || + value.sample_rates[0] !== 16_000 || + value.channels !== 1 || + !Array.isArray(value.frame_ms) || + value.frame_ms.length !== 1 || + value.frame_ms[0] !== 20 || + value.max_duration_ms !== 120_000 || + value.startup_buffer_ms !== 200 + ) { + return undefined; + } + return { + source: value.source, + codecs: [...value.codecs], + sampleRates: [...value.sample_rates], + channels: value.channels, + frameMs: [...value.frame_ms], + maxDurationMs: value.max_duration_ms, + startupBufferMs: value.startup_buffer_ms, + }; +} + function normalizeVendor(value) { if (value === undefined) return null; if (!isObject(value)) return null; @@ -243,7 +289,8 @@ function normalizeVendor(value) { if (!isObject(fact) || !factKeyPattern.test(fact.key ?? "")) return null; if (keys.has(fact.key)) return null; keys.add(fact.key); - if (!validText(fact.label, 64) || inertTextPattern.test(fact.label)) return null; + if (!validText(fact.label, 64) || inertTextPattern.test(fact.label)) + return null; const normalizedValue = Number.isSafeInteger(fact.value) ? String(fact.value) : fact.value; @@ -260,7 +307,12 @@ function normalizeVendor(value) { export function decodeDeviceInfo(raw) { const text = normalizeText(raw); - if (text === null || !safeTopLevelEncoding(text)) return null; + if ( + text === null || + !safeTopLevelEncoding(text) || + !hasUniqueObjectKeys(text) + ) + return null; let value; try { @@ -302,6 +354,10 @@ export function decodeDeviceInfo(raw) { const rotaryPressCount = optionalCount(value.rotary_press_count, 64); const display = normalizeDisplay(value.display); const haptics = normalizeHaptics(value.haptics); + const audio = normalizeAudio( + value.audio, + value.profiles.includes("device-audio/1"), + ); if ( [ statusSlots, @@ -312,6 +368,7 @@ export function decodeDeviceInfo(raw) { rotaryPressCount, display, haptics, + audio, ].includes(undefined) || (buttonCount !== null && [approvalButtonCount, customButtonCount].some( @@ -335,7 +392,11 @@ export function decodeDeviceInfo(raw) { ]; for (const [field, item, maxBytes] of optionalIdentity) { if (item !== undefined && !validText(item, maxBytes)) return null; - if (field === "device_id" && item !== undefined && !uuidPattern.test(item)) { + if ( + field === "device_id" && + item !== undefined && + !uuidPattern.test(item) + ) { return null; } } @@ -367,6 +428,7 @@ export function decodeDeviceInfo(raw) { batteryService: value.battery_service ?? false, display, haptics, + audio, }, vendor: normalizeVendor(value.vendor), }; diff --git a/devices/reference/js/src/framing.mjs b/devices/reference/js/src/framing.mjs index 2c051bc..ac29908 100644 --- a/devices/reference/js/src/framing.mjs +++ b/devices/reference/js/src/framing.mjs @@ -14,7 +14,9 @@ export function createLineDecoder({ maxMessageBytes = 4096 } = {}) { maxMessageBytes < 1 || maxMessageBytes > MAX_MESSAGE_BYTES ) { - throw new RangeError(`maxMessageBytes must be an integer from 1 to ${MAX_MESSAGE_BYTES}`); + throw new RangeError( + `maxMessageBytes must be an integer from 1 to ${MAX_MESSAGE_BYTES}`, + ); } let buffer = []; diff --git a/devices/reference/js/src/protocol.mjs b/devices/reference/js/src/protocol.mjs index 65a98c2..404df07 100644 --- a/devices/reference/js/src/protocol.mjs +++ b/devices/reference/js/src/protocol.mjs @@ -17,6 +17,7 @@ export const VOICE_PROFILE = "voice/1"; export const TEXT_PROFILE = "text/1"; export const USAGE_PROFILE = "usage/1"; export const CONFIG_PROFILE = "config/1"; +export const DEVICE_AUDIO_PROFILE = "device-audio/1"; export const INTERACTION_PROFILES = [ NAVIGATION_PROFILE, KEYS_PROFILE, @@ -25,6 +26,7 @@ export const INTERACTION_PROFILES = [ TEXT_PROFILE, USAGE_PROFILE, CONFIG_PROFILE, + DEVICE_AUDIO_PROFILE, ]; export const CHOICES = ["allow", "deny"]; export const STATUS_STATES = [ @@ -65,7 +67,13 @@ const NAV_RESOLUTION_REASONS = ["selected", "cancelled", "expired", "replaced"]; const GESTURES = ["press", "release", "hold", "double"]; const LIGHT_STATES = ["off", "dim", "solid", "pulse"]; const VOICE_EVENTS = ["start", "stop", "cancel"]; -const VOICE_STATES = ["idle", "listening", "transcribing", "submitted", "error"]; +const VOICE_STATES = [ + "idle", + "listening", + "transcribing", + "submitted", + "error", +]; const CONFIG_STATUSES = ["applied", "rejected"]; const CONFIG_ERROR_CODES = [ "unknown_key", @@ -73,6 +81,24 @@ const CONFIG_ERROR_CODES = [ "storage_error", "unsupported", ]; +const AUDIO_CODEC = "ima_adpcm"; +const AUDIO_END_REASONS = ["submitted", "max_duration"]; +const AUDIO_CANCEL_REASONS = [ + "local", + "host", + "disconnect", + "transport", + "timeout", + "hardware", + "rejected", +]; +const AUDIO_STATES = [ + "ready", + "receiving", + "transcribing", + "submitted", + "error", +]; const idPattern = /^[A-Za-z0-9._:-]+$/; const encoder = new TextEncoder(); @@ -112,6 +138,19 @@ const knownWireFields = new Set([ "context_limit", "entries", "status", + "epoch", + "codec", + "sample_rate", + "channels", + "frame_ms", + "max_duration_ms", + "startup_buffer_ms", + "credits", + "stream_id", + "ack_seq", + "last_seq", + "sample_count", + "reason", ]); const canonicalUnsignedFields = new Set([ "v", @@ -127,6 +166,17 @@ const canonicalUnsignedFields = new Set([ "cached_tokens", "context_used", "context_limit", + "epoch", + "sample_rate", + "channels", + "frame_ms", + "max_duration_ms", + "startup_buffer_ms", + "credits", + "stream_id", + "ack_seq", + "last_seq", + "sample_count", ]); // eslint-disable-next-line no-control-regex -- the protocol bans control characters on purpose @@ -179,14 +229,16 @@ function validStatusAgents(value) { if (!Array.isArray(value) || value.length > MAX_STATUS_AGENTS) return false; const seenSlots = new Set(); for (const agent of value) { - if (!agent || typeof agent !== "object" || Array.isArray(agent)) return false; + if (!agent || typeof agent !== "object" || Array.isArray(agent)) + return false; if (!Number.isInteger(agent.slot) || agent.slot < 0 || agent.slot > 7) { return false; } if (seenSlots.has(agent.slot)) return false; seenSlots.add(agent.slot); if (!STATUS_STATES.includes(agent.state)) return false; - if (agent.label !== undefined && !validStatusLabel(agent.label)) return false; + if (agent.label !== undefined && !validStatusLabel(agent.label)) + return false; } return true; } @@ -196,7 +248,10 @@ function isObject(value) { } function hasOnlyFields(value, fields) { - return isObject(value) && Object.keys(value).every((field) => fields.includes(field)); + return ( + isObject(value) && + Object.keys(value).every((field) => fields.includes(field)) + ); } function validBoundedText(value, minimum, maximum, allowNewlineAndTab = false) { @@ -204,7 +259,8 @@ function validBoundedText(value, minimum, maximum, allowNewlineAndTab = false) { typeof value !== "string" || byteLength(value) < minimum || byteLength(value) > maximum - ) return false; + ) + return false; const pattern = allowNewlineAndTab ? /[\u0000-\u0008\u000b-\u001f\u007f]/ : controlCharacterPattern; @@ -219,6 +275,32 @@ function validSafeCounter(value) { return Number.isSafeInteger(value) && value >= 0; } +function validAudioFormat(message) { + return ( + message.codec === AUDIO_CODEC && + message.sampleRate === 16_000 && + message.channels === 1 && + message.frameMs === 20 + ); +} + +function validAudioState(state, streamId) { + return ( + AUDIO_STATES.includes(state) && + ((state === "ready" && streamId === undefined) || + (state !== "ready" && validU32(streamId))) + ); +} + +function validAudioEnd(lastSequence, sampleCount) { + return ( + validU32(lastSequence) && + lastSequence < 6_000 && + Number.isInteger(sampleCount) && + sampleCount === (lastSequence + 1) * 320 + ); +} + function validUniqueSlots(value, maximumItems, maximumSlot, validator) { if (!Array.isArray(value) || value.length > maximumItems) return false; const slots = new Set(); @@ -230,7 +312,8 @@ function validUniqueSlots(value, maximumItems, maximumSlot, validator) { item.slot > maximumSlot || slots.has(item.slot) || !validator(item) - ) return false; + ) + return false; slots.add(item.slot); } return true; @@ -247,28 +330,42 @@ function validNavigationItems(value) { } function validKeyPresentations(value) { - return validUniqueSlots(value, 64, 63, (item) => - hasOnlyFields(item, ["slot", "label", "enabled", "light", "rgb"]) && - validBoundedText(item.label, 1, 32) && - typeof item.enabled === "boolean" && - LIGHT_STATES.includes(item.light) && - (item.rgb === undefined || - (Array.isArray(item.rgb) && - item.rgb.length === 3 && - item.rgb.every((component) => - Number.isInteger(component) && component >= 0 && component <= 255))), + return validUniqueSlots( + value, + 64, + 63, + (item) => + hasOnlyFields(item, ["slot", "label", "enabled", "light", "rgb"]) && + validBoundedText(item.label, 1, 32) && + typeof item.enabled === "boolean" && + LIGHT_STATES.includes(item.light) && + (item.rgb === undefined || + (Array.isArray(item.rgb) && + item.rgb.length === 3 && + item.rgb.every( + (component) => + Number.isInteger(component) && component >= 0 && component <= 255, + ))), ); } function validRotaryControls(value) { - return validUniqueSlots(value, 16, 15, (item) => - hasOnlyFields(item, ["slot", "label", "value", "min", "max", "wrap"]) && - validBoundedText(item.label, 1, 32) && - [item.value, item.min, item.max].every((number) => - Number.isInteger(number) && number >= -1_000_000 && number <= 1_000_000) && - item.min <= item.value && - item.value <= item.max && - typeof item.wrap === "boolean", + return validUniqueSlots( + value, + 16, + 15, + (item) => + hasOnlyFields(item, ["slot", "label", "value", "min", "max", "wrap"]) && + validBoundedText(item.label, 1, 32) && + [item.value, item.min, item.max].every( + (number) => + Number.isInteger(number) && + number >= -1_000_000 && + number <= 1_000_000, + ) && + item.min <= item.value && + item.value <= item.max && + typeof item.wrap === "boolean", ); } @@ -292,7 +389,8 @@ function validConfigEntries(value) { !configKeyPattern.test(entry.key) || keys.has(entry.key) || !validConfigValue(entry.value) - ) return false; + ) + return false; keys.add(entry.key); } return true; @@ -314,17 +412,19 @@ function normalizeRaw(raw) { } else if (raw instanceof Uint8Array) { if ( raw.byteLength > MAX_MESSAGE_BYTES || - (raw.byteLength === MAX_MESSAGE_BYTES && raw[raw.byteLength - 1] !== 0x0a) - ) return null; + (raw.byteLength === MAX_MESSAGE_BYTES && + raw[raw.byteLength - 1] !== 0x0a) + ) + return null; text = decoder.decode(raw); - } - else return null; + } else return null; } catch { return null; } if (text.endsWith("\n")) text = text.slice(0, -1); - if (text.length === 0 || text.includes("\n") || text.includes("\r")) return null; + if (text.length === 0 || text.includes("\n") || text.includes("\r")) + return null; return text; } @@ -393,7 +493,7 @@ function rawObjectIsSafe(text) { let rootStarted = false; const seenKnownFields = new Set(); - for (let index = 0; index < text.length; ) { + for (let index = 0; index < text.length;) { const value = text[index]; if (value === '"') { const end = scanJSONString(text, index); @@ -412,7 +512,8 @@ function rawObjectIsSafe(text) { if ( canonicalUnsignedFields.has(key) && !hasCanonicalUnsignedIntegerValue(text, end) - ) return false; + ) + return false; expectingTopLevelKey = false; } index = end; @@ -464,7 +565,8 @@ export function encode(message) { }; break; case "answer": - if (!validId(message.requestId) || !CHOICES.includes(message.choice)) return null; + if (!validId(message.requestId) || !CHOICES.includes(message.choice)) + return null; wire = { v: WIRE_VERSION, t: "answer", @@ -487,8 +589,12 @@ export function encode(message) { }; break; case "error": { - const hasId = message.requestId !== null && message.requestId !== undefined; - if ((hasId && !validId(message.requestId)) || !ERROR_CODES.includes(message.code)) { + const hasId = + message.requestId !== null && message.requestId !== undefined; + if ( + (hasId && !validId(message.requestId)) || + !ERROR_CODES.includes(message.code) + ) { return null; } wire = { v: WIRE_VERSION, t: "error" }; @@ -517,7 +623,8 @@ export function encode(message) { message.cursor < 0 || message.cursor >= message.items.length || !validTTL(message.ttlMs) - ) return null; + ) + return null; wire = { v: WIRE_VERSION, t: "nav_present", @@ -532,7 +639,8 @@ export function encode(message) { !validId(message.requestId) || !NAV_DIRECTIONS.includes(message.direction) || !validU32(message.sequence) - ) return null; + ) + return null; wire = { v: WIRE_VERSION, t: "nav_move", @@ -548,7 +656,8 @@ export function encode(message) { message.index < 0 || message.index > 7 || !validU32(message.sequence) - ) return null; + ) + return null; wire = { v: WIRE_VERSION, t: "nav_select", @@ -561,7 +670,8 @@ export function encode(message) { if ( !validId(message.requestId) || !NAV_RESOLUTION_REASONS.includes(message.reason) - ) return null; + ) + return null; wire = { v: WIRE_VERSION, t: "nav_resolved", @@ -587,7 +697,8 @@ export function encode(message) { message.slot > 63 || !GESTURES.includes(message.event) || !validU32(message.sequence) - ) return null; + ) + return null; wire = { v: WIRE_VERSION, t: "key_event", @@ -597,7 +708,8 @@ export function encode(message) { }; break; case "rotaryMap": - if (!validU32(message.revision) || !Array.isArray(message.controls)) return null; + if (!validU32(message.revision) || !Array.isArray(message.controls)) + return null; { const controls = message.controls.map((item) => ({ slot: item.slot, @@ -626,7 +738,8 @@ export function encode(message) { message.delta < -127 || message.delta > 127 || !validU32(message.sequence) - ) return null; + ) + return null; wire = { v: WIRE_VERSION, t: "rotary_event", @@ -642,7 +755,8 @@ export function encode(message) { message.slot > 15 || !GESTURES.includes(message.event) || !validU32(message.sequence) - ) return null; + ) + return null; wire = { v: WIRE_VERSION, t: "rotary_press", @@ -652,7 +766,10 @@ export function encode(message) { }; break; case "voiceEvent": - if (!VOICE_EVENTS.includes(message.event) || !validU32(message.sequence)) { + if ( + !VOICE_EVENTS.includes(message.event) || + !validU32(message.sequence) + ) { return null; } wire = { @@ -665,9 +782,9 @@ export function encode(message) { case "voiceState": if ( !VOICE_STATES.includes(message.state) || - (message.label !== undefined && - !validBoundedText(message.label, 1, 64)) - ) return null; + (message.label !== undefined && !validBoundedText(message.label, 1, 64)) + ) + return null; wire = { v: WIRE_VERSION, t: "voice_state", state: message.state }; if (message.label !== undefined) wire.label = message.label; break; @@ -679,7 +796,8 @@ export function encode(message) { (message.title !== undefined && !validBoundedText(message.title, 1, 64)) || !validBoundedText(message.content, 0, 1024, true) - ) return null; + ) + return null; wire = { v: WIRE_VERSION, t: "text", channel: message.channel }; if (message.title !== undefined) wire.title = message.title; wire.content = message.content; @@ -691,13 +809,14 @@ export function encode(message) { !validSafeCounter(message.outputTokens) || (message.cachedTokens !== undefined && !validSafeCounter(message.cachedTokens)) || - ((message.contextUsed === undefined) !== - (message.contextLimit === undefined)) || + (message.contextUsed === undefined) !== + (message.contextLimit === undefined) || (message.contextUsed !== undefined && (!validSafeCounter(message.contextUsed) || !validSafeCounter(message.contextLimit) || message.contextUsed > message.contextLimit)) - ) return null; + ) + return null; wire = { v: WIRE_VERSION, t: "usage", @@ -734,7 +853,8 @@ export function encode(message) { (message.status === "applied" && message.code !== undefined) || (message.status === "rejected" && !CONFIG_ERROR_CODES.includes(message.code)) - ) return null; + ) + return null; wire = { v: WIRE_VERSION, t: "config_result", @@ -743,6 +863,106 @@ export function encode(message) { }; if (message.code !== undefined) wire.code = message.code; break; + case "audioConfig": + if ( + !validU32(message.epoch) || + !validAudioFormat(message) || + message.maxDurationMs !== 120_000 || + message.startupBufferMs !== 200 || + message.credits !== 8 + ) + return null; + wire = { + v: WIRE_VERSION, + t: "audio_config", + epoch: message.epoch, + codec: message.codec, + sample_rate: message.sampleRate, + channels: message.channels, + frame_ms: message.frameMs, + max_duration_ms: message.maxDurationMs, + startup_buffer_ms: message.startupBufferMs, + credits: message.credits, + }; + break; + case "audioBegin": + if ( + !validU32(message.epoch) || + !validU32(message.streamId) || + !validAudioFormat(message) + ) + return null; + wire = { + v: WIRE_VERSION, + t: "audio_begin", + epoch: message.epoch, + stream_id: message.streamId, + codec: message.codec, + sample_rate: message.sampleRate, + channels: message.channels, + frame_ms: message.frameMs, + }; + break; + case "audioCredit": + if ( + !validU32(message.epoch) || + !validU32(message.streamId) || + !validU32(message.ackSequence) || + !Number.isInteger(message.credits) || + message.credits < 1 || + message.credits > 32 + ) + return null; + wire = { + v: WIRE_VERSION, + t: "audio_credit", + epoch: message.epoch, + stream_id: message.streamId, + ack_seq: message.ackSequence, + credits: message.credits, + }; + break; + case "audioEnd": + if ( + !validU32(message.epoch) || + !validU32(message.streamId) || + !validAudioEnd(message.lastSequence, message.sampleCount) || + !AUDIO_END_REASONS.includes(message.reason) + ) + return null; + wire = { + v: WIRE_VERSION, + t: "audio_end", + epoch: message.epoch, + stream_id: message.streamId, + last_seq: message.lastSequence, + sample_count: message.sampleCount, + reason: message.reason, + }; + break; + case "audioCancel": + if ( + !validU32(message.epoch) || + !validU32(message.streamId) || + !AUDIO_CANCEL_REASONS.includes(message.reason) + ) + return null; + wire = { + v: WIRE_VERSION, + t: "audio_cancel", + epoch: message.epoch, + stream_id: message.streamId, + reason: message.reason, + }; + break; + case "audioState": + if (!validU32(message.epoch) || !validAudioState(message.state, message.streamId)) { + return null; + } + wire = { v: WIRE_VERSION, t: "audio_state", epoch: message.epoch }; + if (message.streamId !== undefined) wire.stream_id = message.streamId; + wire.state = message.state; + break; default: return null; } @@ -784,11 +1004,13 @@ export function decode(raw) { if (!validId(wire.id) || !CHOICES.includes(wire.ch)) return null; return { type: "answer", requestId: wire.id, choice: wire.ch }; case "resolved": - if (!validId(wire.id) || !RESOLUTION_REASONS.includes(wire.r)) return null; + if (!validId(wire.id) || !RESOLUTION_REASONS.includes(wire.r)) + return null; return { type: "resolved", requestId: wire.id, reason: wire.r }; case "error": { const hasId = Object.hasOwn(wire, "id"); - if ((hasId && !validId(wire.id)) || !ERROR_CODES.includes(wire.code)) return null; + if ((hasId && !validId(wire.id)) || !ERROR_CODES.includes(wire.code)) + return null; return { type: "error", requestId: hasId ? wire.id : null, @@ -815,7 +1037,8 @@ export function decode(raw) { wire.cursor < 0 || wire.cursor >= wire.items.length || !validTTL(wire.ttl) - ) return null; + ) + return null; return { type: "navPresent", requestId: wire.id, @@ -829,7 +1052,8 @@ export function decode(raw) { !validId(wire.id) || !NAV_DIRECTIONS.includes(wire.dir) || !validU32(wire.seq) - ) return null; + ) + return null; return { type: "navMove", requestId: wire.id, @@ -844,7 +1068,8 @@ export function decode(raw) { wire.index < 0 || wire.index > 7 || !validU32(wire.seq) - ) return null; + ) + return null; return { type: "navSelect", requestId: wire.id, @@ -856,7 +1081,8 @@ export function decode(raw) { !hasOnlyFields(wire, ["v", "t", "id", "r"]) || !validId(wire.id) || !NAV_RESOLUTION_REASONS.includes(wire.r) - ) return null; + ) + return null; return { type: "navResolved", requestId: wire.id, @@ -867,11 +1093,15 @@ export function decode(raw) { !hasOnlyFields(wire, ["v", "t", "rev", "keys"]) || !validU32(wire.rev) || !validKeyPresentations(wire.keys) - ) return null; + ) + return null; return { type: "keymap", revision: wire.rev, - keys: wire.keys.map((key) => ({ ...key, ...(key.rgb ? { rgb: [...key.rgb] } : {}) })), + keys: wire.keys.map((key) => ({ + ...key, + ...(key.rgb ? { rgb: [...key.rgb] } : {}), + })), }; case "key_event": if ( @@ -881,7 +1111,8 @@ export function decode(raw) { wire.slot > 63 || !GESTURES.includes(wire.event) || !validU32(wire.seq) - ) return null; + ) + return null; return { type: "keyEvent", slot: wire.slot, @@ -893,7 +1124,8 @@ export function decode(raw) { !hasOnlyFields(wire, ["v", "t", "rev", "controls"]) || !validU32(wire.rev) || !validRotaryControls(wire.controls) - ) return null; + ) + return null; return { type: "rotaryMap", revision: wire.rev, @@ -917,7 +1149,8 @@ export function decode(raw) { wire.delta < -127 || wire.delta > 127 || !validU32(wire.seq) - ) return null; + ) + return null; return { type: "rotaryEvent", slot: wire.slot, @@ -932,7 +1165,8 @@ export function decode(raw) { wire.slot > 15 || !GESTURES.includes(wire.event) || !validU32(wire.seq) - ) return null; + ) + return null; return { type: "rotaryPress", slot: wire.slot, @@ -944,7 +1178,8 @@ export function decode(raw) { !hasOnlyFields(wire, ["v", "t", "event", "seq"]) || !VOICE_EVENTS.includes(wire.event) || !validU32(wire.seq) - ) return null; + ) + return null; return { type: "voiceEvent", event: wire.event, @@ -955,7 +1190,8 @@ export function decode(raw) { !hasOnlyFields(wire, ["v", "t", "state", "label"]) || !VOICE_STATES.includes(wire.state) || (wire.label !== undefined && !validBoundedText(wire.label, 1, 64)) - ) return null; + ) + return null; const decoded = { type: "voiceState", state: wire.state }; if (wire.label !== undefined) decoded.label = wire.label; return decoded; @@ -968,7 +1204,8 @@ export function decode(raw) { wire.channel > 7 || (wire.title !== undefined && !validBoundedText(wire.title, 1, 64)) || !validBoundedText(wire.content, 0, 1024, true) - ) return null; + ) + return null; const decoded = { type: "text", channel: wire.channel }; if (wire.title !== undefined) decoded.title = wire.title; decoded.content = wire.content; @@ -991,20 +1228,22 @@ export function decode(raw) { !validSafeCounter(wire.output_tokens) || (wire.cached_tokens !== undefined && !validSafeCounter(wire.cached_tokens)) || - ((wire.context_used === undefined) !== - (wire.context_limit === undefined)) || + (wire.context_used === undefined) !== + (wire.context_limit === undefined) || (wire.context_used !== undefined && (!validSafeCounter(wire.context_used) || !validSafeCounter(wire.context_limit) || wire.context_used > wire.context_limit)) - ) return null; + ) + return null; const decoded = { type: "usage", model: wire.model, inputTokens: wire.input_tokens, outputTokens: wire.output_tokens, }; - if (wire.cached_tokens !== undefined) decoded.cachedTokens = wire.cached_tokens; + if (wire.cached_tokens !== undefined) + decoded.cachedTokens = wire.cached_tokens; if (wire.context_used !== undefined) { decoded.contextUsed = wire.context_used; decoded.contextLimit = wire.context_limit; @@ -1019,7 +1258,8 @@ export function decode(raw) { !hasOnlyFields(wire, ["v", "t", "rev", "entries"]) || !validU32(wire.rev) || !validConfigEntries(wire.entries) - ) return null; + ) + return null; return { type: "config", revision: wire.rev, @@ -1032,7 +1272,8 @@ export function decode(raw) { !CONFIG_STATUSES.includes(wire.status) || (wire.status === "applied" && wire.code !== undefined) || (wire.status === "rejected" && !CONFIG_ERROR_CODES.includes(wire.code)) - ) return null; + ) + return null; const decoded = { type: "configResult", revision: wire.rev, @@ -1041,6 +1282,153 @@ export function decode(raw) { if (wire.code !== undefined) decoded.code = wire.code; return decoded; } + case "audio_config": + if ( + !hasOnlyFields(wire, [ + "v", + "t", + "epoch", + "codec", + "sample_rate", + "channels", + "frame_ms", + "max_duration_ms", + "startup_buffer_ms", + "credits", + ]) || + !validU32(wire.epoch) || + !validAudioFormat({ + codec: wire.codec, + sampleRate: wire.sample_rate, + channels: wire.channels, + frameMs: wire.frame_ms, + }) || + wire.max_duration_ms !== 120_000 || + wire.startup_buffer_ms !== 200 || + wire.credits !== 8 + ) + return null; + return { + type: "audioConfig", + epoch: wire.epoch, + codec: wire.codec, + sampleRate: wire.sample_rate, + channels: wire.channels, + frameMs: wire.frame_ms, + maxDurationMs: wire.max_duration_ms, + startupBufferMs: wire.startup_buffer_ms, + credits: wire.credits, + }; + case "audio_begin": + if ( + !hasOnlyFields(wire, [ + "v", + "t", + "epoch", + "stream_id", + "codec", + "sample_rate", + "channels", + "frame_ms", + ]) || + !validU32(wire.epoch) || + !validU32(wire.stream_id) || + !validAudioFormat({ + codec: wire.codec, + sampleRate: wire.sample_rate, + channels: wire.channels, + frameMs: wire.frame_ms, + }) + ) + return null; + return { + type: "audioBegin", + epoch: wire.epoch, + streamId: wire.stream_id, + codec: wire.codec, + sampleRate: wire.sample_rate, + channels: wire.channels, + frameMs: wire.frame_ms, + }; + case "audio_credit": + if ( + !hasOnlyFields(wire, [ + "v", + "t", + "epoch", + "stream_id", + "ack_seq", + "credits", + ]) || + !validU32(wire.epoch) || + !validU32(wire.stream_id) || + !validU32(wire.ack_seq) || + !Number.isInteger(wire.credits) || + wire.credits < 1 || + wire.credits > 32 + ) + return null; + return { + type: "audioCredit", + epoch: wire.epoch, + streamId: wire.stream_id, + ackSequence: wire.ack_seq, + credits: wire.credits, + }; + case "audio_end": + if ( + !hasOnlyFields(wire, [ + "v", + "t", + "epoch", + "stream_id", + "last_seq", + "sample_count", + "reason", + ]) || + !validU32(wire.epoch) || + !validU32(wire.stream_id) || + !validAudioEnd(wire.last_seq, wire.sample_count) || + !AUDIO_END_REASONS.includes(wire.reason) + ) + return null; + return { + type: "audioEnd", + epoch: wire.epoch, + streamId: wire.stream_id, + lastSequence: wire.last_seq, + sampleCount: wire.sample_count, + reason: wire.reason, + }; + case "audio_cancel": + if ( + !hasOnlyFields(wire, ["v", "t", "epoch", "stream_id", "reason"]) || + !validU32(wire.epoch) || + !validU32(wire.stream_id) || + !AUDIO_CANCEL_REASONS.includes(wire.reason) + ) + return null; + return { + type: "audioCancel", + epoch: wire.epoch, + streamId: wire.stream_id, + reason: wire.reason, + }; + case "audio_state": { + if ( + !hasOnlyFields(wire, ["v", "t", "epoch", "stream_id", "state"]) || + !validU32(wire.epoch) || + !validAudioState(wire.state, wire.stream_id) + ) + return null; + const decoded = { + type: "audioState", + epoch: wire.epoch, + state: wire.state, + }; + if (wire.stream_id !== undefined) decoded.streamId = wire.stream_id; + return decoded; + } default: return null; } diff --git a/devices/reference/js/src/relay.mjs b/devices/reference/js/src/relay.mjs index d207184..8b85712 100644 --- a/devices/reference/js/src/relay.mjs +++ b/devices/reference/js/src/relay.mjs @@ -5,7 +5,10 @@ export function createApprovalRelay({ answerPrompt, now = () => performance.now(), }) { - if (typeof sendToDevice !== "function" || typeof answerPrompt !== "function") { + if ( + typeof sendToDevice !== "function" || + typeof answerPrompt !== "function" + ) { throw new TypeError("sendToDevice and answerPrompt are required"); } @@ -52,12 +55,14 @@ export function createApprovalRelay({ }, onDeviceAnswer({ requestId, choice, authorized }) { - if (authorized !== true) return { accepted: false, reason: "unauthorized" }; + if (authorized !== true) + return { accepted: false, reason: "unauthorized" }; if (pending === null) return { accepted: false, reason: "no_pending" }; if (requestId !== pending.requestId) { return { accepted: false, reason: "stale_or_unknown" }; } - if (!CHOICES.includes(choice)) return { accepted: false, reason: "bad_choice" }; + if (!CHOICES.includes(choice)) + return { accepted: false, reason: "bad_choice" }; if (now() >= pending.deadlineMs) { finish("expired"); return { accepted: false, reason: "expired" }; @@ -99,7 +104,10 @@ export function createApprovalRelay({ }, cancel(requestId) { - if (pending !== null && (requestId === undefined || pending.requestId === requestId)) { + if ( + pending !== null && + (requestId === undefined || pending.requestId === requestId) + ) { finish("cancelled"); } }, diff --git a/devices/reference/js/src/strict-json.mjs b/devices/reference/js/src/strict-json.mjs new file mode 100644 index 0000000..df7dc00 --- /dev/null +++ b/devices/reference/js/src/strict-json.mjs @@ -0,0 +1,52 @@ +const encoder = new TextEncoder(); + +function scanString(text, start) { + for (let index = start + 1; index < text.length; index += 1) { + if (text[index] === '"') return index + 1; + if (text[index] === "\\") index += text[index + 1] === "u" ? 5 : 1; + } + return null; +} + +export function hasUniqueObjectKeys(text, maximumBytes) { + if ( + typeof text !== "string" || + (maximumBytes !== undefined && encoder.encode(text).length > maximumBytes) + ) { + return false; + } + const stack = []; + for (let index = 0; index < text.length;) { + const value = text[index]; + if (value === '"') { + const end = scanString(text, index); + if (end === null) return false; + const current = stack.at(-1); + if (current?.type === "object" && current.expectingKey) { + let key; + try { + key = JSON.parse(text.slice(index, end)); + } catch { + return false; + } + if (current.keys.has(key)) return false; + current.keys.add(key); + current.expectingKey = false; + } + index = end; + continue; + } + if (value === "{") { + stack.push({ type: "object", keys: new Set(), expectingKey: true }); + } else if (value === "[") { + stack.push({ type: "array" }); + } else if (value === "}" || value === "]") { + stack.pop(); + } else if (value === ",") { + const current = stack.at(-1); + if (current?.type === "object") current.expectingKey = true; + } + index += 1; + } + return stack.length === 0; +} diff --git a/devices/reference/js/src/tunnel.mjs b/devices/reference/js/src/tunnel.mjs new file mode 100644 index 0000000..1fdc04b --- /dev/null +++ b/devices/reference/js/src/tunnel.mjs @@ -0,0 +1,147 @@ +import { hasUniqueObjectKeys } from "./strict-json.mjs"; + +export const COMPATIBILITY_TUNNEL_VERSION = 1; + +const descriptorFields = [ + "version", + "integration_id", + "display_name", + "discovery", + "envelope", + "channels", + "requirements", +]; +const channelNames = ["control_down", "control_up", "audio_up", "flow_down"]; +const normalizedNames = { + control_down: "controlDown", + control_up: "controlUp", + audio_up: "audioUp", + flow_down: "flowDown", +}; +const uuidPattern = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; +const integrationPattern = /^[a-z0-9][a-z0-9-]{0,31}(?:\.[a-z0-9][a-z0-9-]{0,31})+$/; + +function isRecord(value) { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +function exactFields(value, fields) { + return isRecord(value) && + Object.keys(value).length === fields.length && + Object.keys(value).every((field) => fields.includes(field)); +} + +function boundedText(value, maximumBytes) { + return ( + typeof value === "string" && + value.length > 0 && + new TextEncoder().encode(value).length <= maximumBytes && + !/[\u0000-\u001f\u007f]/.test(value) + ); +} + +export function parseTransportDescriptor(value) { + if (typeof value === "string") { + if (!hasUniqueObjectKeys(value, 8192)) { + return null; + } + try { + value = JSON.parse(value); + } catch { + return null; + } + } + if ( + !exactFields(value, descriptorFields) || + value.version !== COMPATIBILITY_TUNNEL_VERSION || + typeof value.integration_id !== "string" || + !integrationPattern.test(value.integration_id) || + !boundedText(value.display_name, 64) || + !exactFields(value.discovery, ["service_uuid"]) || + !uuidPattern.test(value.discovery.service_uuid) || + !exactFields(value.envelope, ["strategy", "max_payload_bytes"]) || + value.envelope.strategy !== "opcode-prefix-v1" || + !Number.isInteger(value.envelope.max_payload_bytes) || + value.envelope.max_payload_bytes < 1 || + value.envelope.max_payload_bytes > 511 || + !exactFields(value.channels, channelNames) || + !exactFields(value.requirements, ["bonded", "encrypted", "min_att_payload"]) || + value.requirements.bonded !== true || + value.requirements.encrypted !== true || + !Number.isInteger(value.requirements.min_att_payload) || + value.requirements.min_att_payload < 64 || + value.requirements.min_att_payload > 512 + ) { + return null; + } + + const channels = {}; + const opcodes = new Set(); + for (const name of channelNames) { + const channel = value.channels[name]; + const expectedProperty = name.endsWith("_up") ? "notify" : "write"; + if ( + !exactFields(channel, ["characteristic_uuid", "property", "opcode"]) || + !uuidPattern.test(channel.characteristic_uuid) || + channel.property !== expectedProperty || + !Number.isInteger(channel.opcode) || + channel.opcode < 0 || + channel.opcode > 255 || + opcodes.has(channel.opcode) + ) { + return null; + } + opcodes.add(channel.opcode); + channels[normalizedNames[name]] = { + characteristicUuid: channel.characteristic_uuid.toLowerCase(), + property: channel.property, + opcode: channel.opcode, + }; + } + + return { + version: value.version, + integrationId: value.integration_id, + displayName: value.display_name, + serviceUuid: value.discovery.service_uuid.toLowerCase(), + strategy: value.envelope.strategy, + maxPayloadBytes: value.envelope.max_payload_bytes, + channels, + requirements: { + bonded: true, + encrypted: true, + minAttPayload: value.requirements.min_att_payload, + }, + }; +} + +export function encodeTunnelPacket(descriptor, channelName, payload) { + const channel = descriptor?.channels?.[channelName]; + if ( + !channel || + channel.property !== "write" || + !(payload instanceof Uint8Array) || + payload.length > descriptor.maxPayloadBytes + ) { + throw new RangeError("invalid tunnel downlink packet"); + } + const packet = new Uint8Array(payload.length + 1); + packet[0] = channel.opcode; + packet.set(payload, 1); + return packet; +} + +export function parseTunnelPacket(descriptor, packet) { + if ( + !(packet instanceof Uint8Array) || + packet.length < 1 || + packet.length - 1 > descriptor?.maxPayloadBytes + ) { + return null; + } + const match = Object.entries(descriptor.channels).find( + ([, channel]) => channel.property === "notify" && channel.opcode === packet[0], + ); + return match ? { channel: match[0], payload: packet.slice(1) } : null; +} diff --git a/devices/reference/js/test/audio-control.test.mjs b/devices/reference/js/test/audio-control.test.mjs new file mode 100644 index 0000000..91d0e77 --- /dev/null +++ b/devices/reference/js/test/audio-control.test.mjs @@ -0,0 +1,62 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + DEVICE_AUDIO_PROFILE, + INTERACTION_PROFILES, + decode, + encode, +} from "../src/protocol.mjs"; + +const vectors = JSON.parse( + await readFile( + new URL("../../../protocol/vectors/device-audio-v1.json", import.meta.url), + "utf8", + ), +); + +test("device-audio/1 control messages round-trip canonically", () => { + assert.equal(DEVICE_AUDIO_PROFILE, "device-audio/1"); + assert.equal(INTERACTION_PROFILES.includes(DEVICE_AUDIO_PROFILE), true); + for (const item of vectors.control.valid) { + assert.deepEqual(decode(item.wire), item.decoded, item.name); + assert.equal(encode(item.decoded), item.wire, item.name); + } +}); + +test("device audio controls reject unsupported or microphone-opening fields", () => { + for (const wire of vectors.control.invalid) { + assert.equal(decode(wire), null, wire); + } +}); + +test("message schema distinguishes device audio from Host microphone voice", async () => { + const schema = JSON.parse( + await readFile( + new URL("../../../schemas/message.schema.json", import.meta.url), + "utf8", + ), + ); + const audioRefs = schema.oneOf + .map((item) => item.$ref) + .filter((ref) => ref?.includes("audio")); + assert.deepEqual(audioRefs, [ + "#/$defs/audioConfig", + "#/$defs/audioBegin", + "#/$defs/audioCredit", + "#/$defs/audioEnd", + "#/$defs/audioCancel", + "#/$defs/audioState", + ]); + assert.equal(schema.$defs.deviceAudioCapabilities.properties.source.const, "device_microphone"); + assert.deepEqual(schema.$defs.deviceAudioCapabilities.properties.codecs.const, [ + "ima_adpcm", + ]); + assert.equal(schema.$defs.audioConfig.properties.max_duration_ms.const, 120000); + assert.equal(schema.$defs.audioEnd.properties.last_seq.maximum, 5999); + assert.equal(schema.$defs.audioEnd.properties.sample_count.multipleOf, 320); + assert.equal(schema.$defs.voiceEvent.properties.t.const, "voice_event"); + assert.deepEqual(schema.$defs.deviceInfo.allOf[0].then.required, ["audio"]); + assert.deepEqual(schema.$defs.deviceInfo.allOf[0].else.not.required, ["audio"]); +}); diff --git a/devices/reference/js/test/audio.test.mjs b/devices/reference/js/test/audio.test.mjs new file mode 100644 index 0000000..af4097f --- /dev/null +++ b/devices/reference/js/test/audio.test.mjs @@ -0,0 +1,152 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + AUDIO_FRAME_BYTES, + AUDIO_FRAGMENT_HEADER_BYTES, + crc32c, + decodeAudioFrame, + decodeImaAdpcmBlock, + encodeAudioFrame, + encodeImaAdpcmBlock, + fragmentAudioFrame, + parseAudioFragment, +} from "../src/audio.mjs"; + +const vectors = JSON.parse( + await readFile( + new URL("../../../protocol/vectors/device-audio-v1.json", import.meta.url), + "utf8", + ), +); +const golden = vectors.valid[0]; +const goldenBytes = Uint8Array.from(Buffer.from(golden.frame_hex, "hex")); + +test("CRC32C uses the Castagnoli check value", () => { + assert.equal(crc32c(new TextEncoder().encode("123456789")), 0xe3069283); +}); + +test("a zero PCM block matches the cross-language golden frame", () => { + const samples = new Int16Array(320).fill(golden.pcm_fill); + const encoded = encodeAudioFrame({ + epoch: golden.epoch, + streamId: golden.stream_id, + sequence: golden.sequence, + sampleIndex: golden.sample_index, + stepIndex: golden.step_index, + samples, + }); + assert.equal(encoded.length, AUDIO_FRAME_BYTES); + assert.equal(Buffer.from(encoded).toString("hex"), golden.frame_hex); + + const decoded = decodeAudioFrame(encoded); + assert.equal(decoded.epoch, golden.epoch); + assert.equal(decoded.streamId, golden.stream_id); + assert.equal(decoded.sequence, 0); + assert.equal(decoded.sampleIndex, 0); + assert.deepEqual([...decoded.samples], [...samples]); +}); + +test("logical frame metadata is continuous and bounded to 120 seconds", () => { + const samples = new Int16Array(320); + assert.throws(() => + encodeAudioFrame({ + epoch: 1, + streamId: 1, + sequence: 1, + sampleIndex: 0, + samples, + }), + ); + assert.throws(() => + encodeAudioFrame({ + epoch: 1, + streamId: 1, + sequence: 6000, + sampleIndex: 1_920_000, + samples, + }), + ); +}); + +test("IMA ADPCM encodes low nibbles first and decodes exactly 320 samples", () => { + const samples = Int16Array.from({ length: 320 }, (_, index) => + Math.max(-32768, Math.min(32767, index * 97 - 15000)), + ); + const block = encodeImaAdpcmBlock(samples, 7); + assert.equal(block.predictor, samples[0]); + assert.equal(block.stepIndex, 7); + assert.equal(block.payload.length, 160); + assert.equal(block.payload[159] & 0xf0, 0); + + const decoded = decodeImaAdpcmBlock({ ...block, sampleCount: 320 }); + assert.equal(decoded.length, 320); + assert.equal(decoded[0], samples[0]); + const maxError = Math.max( + ...decoded.map((sample, index) => Math.abs(sample - samples[index])), + ); + assert.ok(maxError < 4000, `unexpected ADPCM error ${maxError}`); +}); + +test("logical frames split into at most four bounded non-interleavable fragments", () => { + const fragments = fragmentAudioFrame(goldenBytes, { + streamId: golden.stream_id, + sequence: 0, + maxPacketBytes: 63, + }); + assert.equal(fragments.length, 4); + const parsed = fragments.map(parseAudioFragment); + assert.deepEqual(parsed.map((item) => item.fragmentIndex), [0, 1, 2, 3]); + assert.ok(fragments.every((item) => item.length <= 63)); + assert.deepEqual( + Buffer.concat(parsed.map((item) => Buffer.from(item.payload))), + Buffer.from(goldenBytes), + ); + assert.throws(() => + fragmentAudioFrame(goldenBytes, { + streamId: golden.stream_id, + sequence: 0, + maxPacketBytes: 59, + }), + ); + assert.equal( + parseAudioFragment( + new Uint8Array(AUDIO_FRAME_BYTES + AUDIO_FRAGMENT_HEADER_BYTES + 1), + ), + null, + ); + assert.throws(() => + fragmentAudioFrame(goldenBytes, { + streamId: golden.stream_id + 1, + sequence: 0, + maxPacketBytes: 63, + }), + ); + assert.throws(() => + fragmentAudioFrame(goldenBytes, { + streamId: golden.stream_id, + sequence: 1, + maxPacketBytes: 63, + }), + ); +}); + +test("malformed and corrupted frame vectors fail closed", () => { + for (const item of vectors.invalid) { + let bytes; + if (item.frame_hex) bytes = Uint8Array.from(Buffer.from(item.frame_hex, "hex")); + else { + bytes = goldenBytes.slice(); + bytes[item.mutate.offset] = item.mutate.value; + if (item.recompute_crc) { + new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength).setUint32( + bytes.length - 4, + crc32c(bytes.subarray(0, -4)), + true, + ); + } + } + assert.equal(decodeAudioFrame(bytes), null, item.name); + } +}); diff --git a/devices/reference/js/test/device-info.test.mjs b/devices/reference/js/test/device-info.test.mjs index 4a47e05..7677e9e 100644 --- a/devices/reference/js/test/device-info.test.mjs +++ b/devices/reference/js/test/device-info.test.mjs @@ -24,15 +24,27 @@ test("Device Info 0.2 valid vectors normalize identically", () => { const decoded = decodeDeviceInfo(item.wire); assert.ok(decoded, item.name); assert.equal(decoded.model, item.decoded.model, item.name); - assert.equal(decoded.capabilities.statusSlots, item.decoded.statusSlots, item.name); + assert.equal( + decoded.capabilities.statusSlots, + item.decoded.statusSlots, + item.name, + ); assert.equal(decoded.identity.deviceId, item.decoded.deviceId, item.name); - assert.equal(decoded.capabilities.buttonCount, item.decoded.buttonCount, item.name); + assert.equal( + decoded.capabilities.buttonCount, + item.decoded.buttonCount, + item.name, + ); assert.equal( decoded.capabilities.batteryService, item.decoded.batteryService, item.name, ); - assert.equal(decoded.vendor?.namespace ?? null, item.decoded.vendorNamespace, item.name); + assert.equal( + decoded.vendor?.namespace ?? null, + item.decoded.vendorNamespace, + item.name, + ); } }); @@ -79,17 +91,21 @@ test("vendor facts remain bounded, inert, and cannot override system fields", () label: `Fact ${index}`, value: `${index}`, })); - const decoded = decodeDeviceInfo(withVendor(core, { - namespace: "com.example.board", - facts: tooManyFacts, - })); + const decoded = decodeDeviceInfo( + withVendor(core, { + namespace: "com.example.board", + facts: tooManyFacts, + }), + ); assert.ok(decoded); assert.equal(decoded.vendor, null); - const override = decodeDeviceInfo(withVendor(core, { - namespace: "com.example.board", - facts: [{ key: "battery", label: "Battery", value: "100%" }], - })); + const override = decodeDeviceInfo( + withVendor(core, { + namespace: "com.example.board", + facts: [{ key: "battery", label: "Battery", value: "100%" }], + }), + ); assert.ok(override); assert.deepEqual(override.vendor.facts, [ { key: "battery", label: "Battery", value: "100%" }, @@ -117,3 +133,63 @@ test("Device Info negotiates interaction profiles explicitly", () => { assert.equal(supportsProfile(info, "keys/1"), true); assert.equal(supportsProfile(info, "config/1"), false); }); + +test("Device Info advertises a real device microphone separately from voice/1", () => { + const core = JSON.parse(vectorFile.valid[0].wire); + const audio = { + source: "device_microphone", + codecs: ["ima_adpcm"], + sample_rates: [16000], + channels: 1, + frame_ms: [20], + max_duration_ms: 120000, + startup_buffer_ms: 200, + }; + const decoded = decodeDeviceInfo( + JSON.stringify({ + ...core, + spec: "0.4.0-experimental.1", + profiles: ["approval/1", "device-audio/1"], + audio, + }), + ); + assert.ok(decoded); + assert.equal(supportsProfile(decoded, "device-audio/1"), true); + assert.equal(supportsProfile(decoded, "voice/1"), false); + assert.deepEqual(decoded.capabilities.audio, { + source: "device_microphone", + codecs: ["ima_adpcm"], + sampleRates: [16000], + channels: 1, + frameMs: [20], + maxDurationMs: 120000, + startupBufferMs: 200, + }); + + assert.equal( + decodeDeviceInfo( + JSON.stringify({ ...core, profiles: ["approval/1", "device-audio/1"] }), + ), + null, + ); + assert.equal(decodeDeviceInfo(JSON.stringify({ ...core, audio })), null); + assert.equal( + decodeDeviceInfo( + JSON.stringify({ + ...core, + profiles: ["approval/1", "device-audio/1"], + audio: { ...audio, source: "host_microphone" }, + }), + ), + null, + ); + const duplicateAudioField = + `{\"protocol\":\"nexting-device\",\"spec\":\"0.4.0-experimental.1\",` + + `\"wire\":[1],\"profiles\":[\"approval/1\",\"device-audio/1\"],` + + `\"model\":\"audio-ref\",\"fw\":\"1\",\"max_message_bytes\":4096,` + + `\"max_summary_bytes\":240,\"audio\":{\"source\":\"device_microphone\",` + + `\"source\":\"device_microphone\",\"codecs\":[\"ima_adpcm\"],` + + `\"sample_rates\":[16000],\"channels\":1,\"frame_ms\":[20],` + + `\"max_duration_ms\":120000,\"startup_buffer_ms\":200}}`; + assert.equal(decodeDeviceInfo(duplicateAudioField), null); +}); diff --git a/devices/reference/js/test/framing.test.mjs b/devices/reference/js/test/framing.test.mjs index fe8edfa..b7b0594 100644 --- a/devices/reference/js/test/framing.test.mjs +++ b/devices/reference/js/test/framing.test.mjs @@ -32,7 +32,10 @@ test("line decoder handles a multibyte character split across byte chunks", () = test("line decoder emits every complete line in one chunk", () => { const stream = createLineDecoder({ maxMessageBytes: 512 }); - assert.deepEqual(stream.push(answerWire + answerWire), [answerMessage, answerMessage]); + assert.deepEqual(stream.push(answerWire + answerWire), [ + answerMessage, + answerMessage, + ]); }); test("oversize input emits one error then discards through newline", () => { diff --git a/devices/reference/js/test/package.test.mjs b/devices/reference/js/test/package.test.mjs index 2d5089c..26af740 100644 --- a/devices/reference/js/test/package.test.mjs +++ b/devices/reference/js/test/package.test.mjs @@ -25,21 +25,29 @@ test("reference package stays private and exposes explicit entry points", async "./device-info": "./src/device-info.mjs", "./framing": "./src/framing.mjs", "./relay": "./src/relay.mjs", + "./audio": "./src/audio.mjs", + "./tunnel": "./src/tunnel.mjs", }); const protocol = await import("@nexting-ai/device-reference/protocol"); const deviceInfo = await import("@nexting-ai/device-reference/device-info"); const framing = await import("@nexting-ai/device-reference/framing"); const relay = await import("@nexting-ai/device-reference/relay"); + const audio = await import("@nexting-ai/device-reference/audio"); + const tunnel = await import("@nexting-ai/device-reference/tunnel"); assert.equal(typeof protocol.decode, "function"); assert.equal(typeof deviceInfo.decodeDeviceInfo, "function"); assert.equal(typeof framing.createLineDecoder, "function"); assert.equal(typeof relay.createApprovalRelay, "function"); + assert.equal(typeof audio.decodeAudioFrame, "function"); + assert.equal(typeof tunnel.parseTransportDescriptor, "function"); }); -test("public release identity is Experimental 0.2", () => { - assert.equal(releasePackageJSON.version, "0.2.0-experimental.2"); - assert.equal(packageJSON.version, "0.2.0-experimental.2"); +test("public release identity is Experimental 0.4", () => { + assert.equal(releasePackageJSON.version, "0.4.0-experimental.1"); + assert.equal(packageJSON.version, "0.4.0-experimental.1"); + assert.match(changelog, /## 0\.4\.0-experimental\.1/); + assert.match(changelog, /## 0\.3\.0-experimental\.1/); assert.match(changelog, /## 0\.2\.0-experimental\.1/); - assert.match(readme, /## Experimental 0\.2/); + assert.match(readme, /## Experimental 0\.4/); }); diff --git a/devices/reference/js/test/protocol.test.mjs b/devices/reference/js/test/protocol.test.mjs index 2a49120..4a9b9d9 100644 --- a/devices/reference/js/test/protocol.test.mjs +++ b/devices/reference/js/test/protocol.test.mjs @@ -55,14 +55,20 @@ test("direct codec reserves one byte for the required newline", () => { const exactPayload = `${prefix}${"x".repeat( MAX_MESSAGE_BYTES - 1 - prefix.length - suffix.length, )}${suffix}`; - assert.equal(Buffer.byteLength(`${exactPayload}\n`, "utf8"), MAX_MESSAGE_BYTES); + assert.equal( + Buffer.byteLength(`${exactPayload}\n`, "utf8"), + MAX_MESSAGE_BYTES, + ); assert.equal(decode(`${exactPayload}\n`)?.type, "answer"); assert.equal(decode(exactPayload)?.type, "answer"); const tooLargeWithoutNewline = `${prefix}${"x".repeat( MAX_MESSAGE_BYTES - prefix.length - suffix.length, )}${suffix}`; - assert.equal(Buffer.byteLength(tooLargeWithoutNewline, "utf8"), MAX_MESSAGE_BYTES); + assert.equal( + Buffer.byteLength(tooLargeWithoutNewline, "utf8"), + MAX_MESSAGE_BYTES, + ); assert.equal(decode(tooLargeWithoutNewline), null); }); @@ -138,7 +144,9 @@ test("decoder ignores unknown fields but rejects unknown types and extra lines", ); const longUnknownKey = "k".repeat(241); assert.equal( - decode(`{"v":1,"t":"answer","id":"r1","ch":"allow","${longUnknownKey}":true}`)?.choice, + decode( + `{"v":1,"t":"answer","id":"r1","ch":"allow","${longUnknownKey}":true}`, + )?.choice, "allow", ); assert.equal(decode('{"v":1,"t":"future","id":"r1"}\n'), null); diff --git a/devices/reference/js/test/relay.test.mjs b/devices/reference/js/test/relay.test.mjs index dfa3fbd..0a116e9 100644 --- a/devices/reference/js/test/relay.test.mjs +++ b/devices/reference/js/test/relay.test.mjs @@ -46,7 +46,11 @@ test("authorized answer resolves only after commit and retries after failure", ( const { relay, sent, answered } = harness(); relay.present({ requestId: "r1", summary: "Allow?", ttlMs: 30_000 }); assert.deepEqual( - relay.onDeviceAnswer({ requestId: "r1", choice: "allow", authorized: true }), + relay.onDeviceAnswer({ + requestId: "r1", + choice: "allow", + authorized: true, + }), { accepted: true, choice: "allow" }, ); assert.deepEqual(answered, ["allow"]); @@ -59,7 +63,11 @@ test("authorized answer resolves only after commit and retries after failure", ( { accepted: false, reason: "choice_locked" }, ); assert.deepEqual( - relay.onDeviceAnswer({ requestId: "r1", choice: "allow", authorized: true }), + relay.onDeviceAnswer({ + requestId: "r1", + choice: "allow", + authorized: true, + }), { accepted: true, choice: "allow" }, ); assert.deepEqual(answered, ["allow", "allow"]); @@ -75,7 +83,11 @@ test("authorized answer resolves only after commit and retries after failure", ( }); assert.equal(relay.hasPending, false); assert.deepEqual( - relay.onDeviceAnswer({ requestId: "r1", choice: "allow", authorized: true }), + relay.onDeviceAnswer({ + requestId: "r1", + choice: "allow", + authorized: true, + }), { accepted: false, reason: "no_pending" }, ); assert.deepEqual(answered, ["allow", "allow"]); @@ -85,15 +97,27 @@ test("unauthorized, stale, and invalid answers fail closed", () => { const { relay, answered } = harness(); relay.present({ requestId: "r1", summary: "Allow?", ttlMs: 30_000 }); assert.deepEqual( - relay.onDeviceAnswer({ requestId: "r1", choice: "allow", authorized: false }), + relay.onDeviceAnswer({ + requestId: "r1", + choice: "allow", + authorized: false, + }), { accepted: false, reason: "unauthorized" }, ); assert.deepEqual( - relay.onDeviceAnswer({ requestId: "stale", choice: "allow", authorized: true }), + relay.onDeviceAnswer({ + requestId: "stale", + choice: "allow", + authorized: true, + }), { accepted: false, reason: "stale_or_unknown" }, ); assert.deepEqual( - relay.onDeviceAnswer({ requestId: "r1", choice: "maybe", authorized: true }), + relay.onDeviceAnswer({ + requestId: "r1", + choice: "maybe", + authorized: true, + }), { accepted: false, reason: "bad_choice" }, ); assert.deepEqual(answered, []); @@ -111,7 +135,11 @@ test("new present resolves the old request as replaced", () => { }); assert.equal(relay.pendingRequestId, "r2"); assert.deepEqual( - relay.onDeviceAnswer({ requestId: "r1", choice: "allow", authorized: true }), + relay.onDeviceAnswer({ + requestId: "r1", + choice: "allow", + authorized: true, + }), { accepted: false, reason: "stale_or_unknown" }, ); }); @@ -172,7 +200,11 @@ test("expiry rejects a boundary-time answer and emits expired", () => { relay.present({ requestId: "r1", summary: "Allow?", ttlMs: 10 }); setNow(1_010); assert.deepEqual( - relay.onDeviceAnswer({ requestId: "r1", choice: "allow", authorized: true }), + relay.onDeviceAnswer({ + requestId: "r1", + choice: "allow", + authorized: true, + }), { accepted: false, reason: "expired" }, ); assert.equal(sent.at(-1).reason, "expired"); diff --git a/devices/reference/js/test/status.test.mjs b/devices/reference/js/test/status.test.mjs index bf2c4e0..0ccf7cd 100644 --- a/devices/reference/js/test/status.test.mjs +++ b/devices/reference/js/test/status.test.mjs @@ -41,7 +41,9 @@ test("status/1 vectors are versioned and cover every state", () => { "must cover the empty clear message", ); assert.ok( - vectors.valid.some((item) => item.decoded.agents.length === MAX_STATUS_AGENTS), + vectors.valid.some( + (item) => item.decoded.agents.length === MAX_STATUS_AGENTS, + ), "must cover the slot-count boundary", ); assert.ok( @@ -111,10 +113,7 @@ test("protocol exports the status profile identity", () => { test("status encode rejects bad slot, state, and label shapes", () => { const base = { type: "status", agents: [{ slot: 0, state: "idle" }] }; - assert.equal( - encode({ ...base, agents: [{ slot: 8, state: "idle" }] }), - null, - ); + assert.equal(encode({ ...base, agents: [{ slot: 8, state: "idle" }] }), null); assert.equal( encode({ ...base, agents: [{ slot: 0, state: "paused" }] }), null, diff --git a/devices/reference/js/test/tunnel.test.mjs b/devices/reference/js/test/tunnel.test.mjs new file mode 100644 index 0000000..47ca66d --- /dev/null +++ b/devices/reference/js/test/tunnel.test.mjs @@ -0,0 +1,120 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import test from "node:test"; + +import { + encodeTunnelPacket, + parseTransportDescriptor, + parseTunnelPacket, +} from "../src/tunnel.mjs"; + +const vectors = JSON.parse( + await readFile( + new URL( + "../../../protocol/vectors/compatibility-tunnel-v1.json", + import.meta.url, + ), + "utf8", + ), +); +const base = vectors.valid[0].descriptor; + +function withPath(source, path, value) { + const copy = structuredClone(source); + const segments = path.split("."); + let cursor = copy; + for (const segment of segments.slice(0, -1)) cursor = cursor[segment]; + cursor[segments.at(-1)] = value; + return copy; +} + +test("valid compatibility descriptors normalize four logical channels", () => { + const descriptor = parseTransportDescriptor(base); + assert.equal(descriptor.integrationId, "example.controls-x1"); + assert.equal(descriptor.channels.audioUp.opcode, 0x12); + assert.equal(descriptor.requirements.minAttPayload, 64); + assert.equal(Object.keys(descriptor.channels).length, 4); +}); + +test("AhaKey integration descriptor is inert and conforms to the shared tunnel", async () => { + const raw = await readFile( + new URL( + "../../../firmware/ahakey-x1/transport-descriptor.nexting-device.json", + import.meta.url, + ), + "utf8", + ); + const descriptor = parseTransportDescriptor(raw); + assert.equal(descriptor?.integrationId, "ai.ahakey.x1"); + assert.equal(descriptor?.channels.controlDown.opcode, 0xa0); + assert.equal(descriptor?.channels.controlUp.opcode, 0xa1); + assert.equal(descriptor?.channels.audioUp.opcode, 0xa2); + assert.equal(descriptor?.channels.flowDown.opcode, 0xa3); + assert.equal(descriptor?.requirements.encrypted, true); +}); + +test("raw descriptors reject duplicate keys before JSON normalization", () => { + const raw = JSON.stringify(base); + assert.ok(parseTransportDescriptor(raw)); + assert.equal( + parseTransportDescriptor(raw.replace('"version":1', '"version":1,"version":1')), + null, + ); + assert.equal( + parseTransportDescriptor( + raw.replace('"opcode":16', '"opcode":16,"opcode":16'), + ), + null, + ); +}); + +test("descriptors reject executable, unsafe, and ambiguous data", () => { + for (const item of vectors.invalid) { + assert.equal( + parseTransportDescriptor(withPath(base, item.path, item.value)), + null, + item.name, + ); + } +}); + +test("the fixed opcode envelope cannot inject a parser or a fifth channel", () => { + const descriptor = parseTransportDescriptor(base); + const payload = Uint8Array.of(1, 2, 3); + assert.deepEqual( + [...encodeTunnelPacket(descriptor, "controlDown", payload)], + [0x10, 1, 2, 3], + ); + assert.deepEqual(parseTunnelPacket(descriptor, Uint8Array.of(0x12, 9, 8)), { + channel: "audioUp", + payload: Uint8Array.of(9, 8), + }); + assert.equal(parseTunnelPacket(descriptor, Uint8Array.of(0x1f, 1)), null); + assert.throws(() => encodeTunnelPacket(descriptor, "unknown", payload)); +}); + +test("the public descriptor schema is strict data, not executable plugin code", async () => { + const schema = JSON.parse( + await readFile( + new URL("../../../schemas/transport-descriptor.schema.json", import.meta.url), + "utf8", + ), + ); + assert.equal(schema.$defs.descriptor.additionalProperties, false); + assert.deepEqual(schema.$defs.envelope.properties.strategy.enum, [ + "opcode-prefix-v1", + ]); + assert.equal(schema.$defs.requirements.properties.min_att_payload.minimum, 64); + assert.equal(schema.$defs.channels.additionalProperties, false); + const keys = []; + const visit = (value) => { + if (!value || typeof value !== "object") return; + for (const [key, child] of Object.entries(value)) { + keys.push(key.toLowerCase()); + visit(child); + } + }; + visit(schema); + assert.equal(keys.includes("script"), false); + assert.equal(keys.includes("url"), false); +}); diff --git a/devices/reference/js/test/vectors.test.mjs b/devices/reference/js/test/vectors.test.mjs index 89ff6f7..15747b9 100644 --- a/devices/reference/js/test/vectors.test.mjs +++ b/devices/reference/js/test/vectors.test.mjs @@ -55,15 +55,27 @@ test("approval/1 vectors are versioned and cover every message type", async () = ["answer", "error", "present", "resolved"], ); assert.deepEqual( - [...new Set(vectors.valid.map((item) => item.decoded.choice).filter(Boolean))].sort(), + [ + ...new Set( + vectors.valid.map((item) => item.decoded.choice).filter(Boolean), + ), + ].sort(), [...CHOICES].sort(), ); assert.deepEqual( - [...new Set(vectors.valid.map((item) => item.decoded.reason).filter(Boolean))].sort(), + [ + ...new Set( + vectors.valid.map((item) => item.decoded.reason).filter(Boolean), + ), + ].sort(), [...RESOLUTION_REASONS].sort(), ); assert.deepEqual( - [...new Set(vectors.valid.map((item) => item.decoded.code).filter(Boolean))].sort(), + [ + ...new Set( + vectors.valid.map((item) => item.decoded.code).filter(Boolean), + ), + ].sort(), [...ERROR_CODES].sort(), ); assert.ok(vectors.invalid.length >= 8); diff --git a/devices/schemas/message.schema.json b/devices/schemas/message.schema.json index 54283e6..55076e4 100644 --- a/devices/schemas/message.schema.json +++ b/devices/schemas/message.schema.json @@ -1,7 +1,7 @@ { "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://github.com/Nexting-ai/devices/schemas/message.schema.json", - "title": "Nexting Device Protocol Experimental 0.2 message", + "title": "Nexting Device Protocol Experimental 0.4 message", "oneOf": [ { "$ref": "#/$defs/present" }, { "$ref": "#/$defs/answer" }, @@ -23,7 +23,13 @@ { "$ref": "#/$defs/usage" }, { "$ref": "#/$defs/usageClear" }, { "$ref": "#/$defs/config" }, - { "$ref": "#/$defs/configResult" } + { "$ref": "#/$defs/configResult" }, + { "$ref": "#/$defs/audioConfig" }, + { "$ref": "#/$defs/audioBegin" }, + { "$ref": "#/$defs/audioCredit" }, + { "$ref": "#/$defs/audioEnd" }, + { "$ref": "#/$defs/audioCancel" }, + { "$ref": "#/$defs/audioState" } ], "$defs": { "deviceInfo": { @@ -111,8 +117,21 @@ "uniqueItems": true, "items": { "$ref": "#/$defs/profileName" } }, - "vendor": { "$ref": "#/$defs/vendorInfo" } + "vendor": { "$ref": "#/$defs/vendorInfo" }, + "audio": { "$ref": "#/$defs/deviceAudioCapabilities" } }, + "allOf": [ + { + "if": { + "properties": { + "profiles": { "contains": { "const": "device-audio/1" } } + }, + "required": ["profiles"] + }, + "then": { "required": ["audio"] }, + "else": { "not": { "required": ["audio"] } } + } + ], "x-maxUtf8Bytes": 4096 }, "descriptor": { @@ -160,6 +179,28 @@ }, "x-maxUtf8Bytes": 1024 }, + "deviceAudioCapabilities": { + "type": "object", + "additionalProperties": false, + "required": [ + "source", + "codecs", + "sample_rates", + "channels", + "frame_ms", + "max_duration_ms", + "startup_buffer_ms" + ], + "properties": { + "source": { "const": "device_microphone" }, + "codecs": { "const": ["ima_adpcm"] }, + "sample_rates": { "const": [16000] }, + "channels": { "const": 1 }, + "frame_ms": { "const": [20] }, + "max_duration_ms": { "const": 120000 }, + "startup_buffer_ms": { "const": 200 } + } + }, "id": { "type": "string", "minLength": 1, @@ -425,7 +466,12 @@ "v": { "const": 1 }, "t": { "const": "rotary_event" }, "slot": { "type": "integer", "minimum": 0, "maximum": 15 }, - "delta": { "type": "integer", "minimum": -127, "maximum": 127, "not": { "const": 0 } }, + "delta": { + "type": "integer", + "minimum": -127, + "maximum": 127, + "not": { "const": 0 } + }, "seq": { "$ref": "#/$defs/u32" } } }, @@ -459,7 +505,9 @@ "properties": { "v": { "const": 1 }, "t": { "const": "voice_state" }, - "state": { "enum": ["idle", "listening", "transcribing", "submitted", "error"] }, + "state": { + "enum": ["idle", "listening", "transcribing", "submitted", "error"] + }, "label": { "type": "string", "minLength": 1, "x-maxUtf8Bytes": 64 } } }, @@ -547,8 +595,105 @@ "t": { "const": "config_result" }, "rev": { "$ref": "#/$defs/u32" }, "status": { "enum": ["applied", "rejected"] }, - "code": { "enum": ["unknown_key", "invalid_value", "storage_error", "unsupported"] } + "code": { + "enum": [ + "unknown_key", + "invalid_value", + "storage_error", + "unsupported" + ] + } + } + }, + "audioConfig": { + "type": "object", + "additionalProperties": false, + "required": ["v", "t", "epoch", "codec", "sample_rate", "channels", "frame_ms", "max_duration_ms", "startup_buffer_ms", "credits"], + "properties": { + "v": { "const": 1 }, + "t": { "const": "audio_config" }, + "epoch": { "$ref": "#/$defs/u32" }, + "codec": { "const": "ima_adpcm" }, + "sample_rate": { "const": 16000 }, + "channels": { "const": 1 }, + "frame_ms": { "const": 20 }, + "max_duration_ms": { "const": 120000 }, + "startup_buffer_ms": { "const": 200 }, + "credits": { "const": 8 } + } + }, + "audioBegin": { + "type": "object", + "additionalProperties": false, + "required": ["v", "t", "epoch", "stream_id", "codec", "sample_rate", "channels", "frame_ms"], + "properties": { + "v": { "const": 1 }, + "t": { "const": "audio_begin" }, + "epoch": { "$ref": "#/$defs/u32" }, + "stream_id": { "$ref": "#/$defs/u32" }, + "codec": { "const": "ima_adpcm" }, + "sample_rate": { "const": 16000 }, + "channels": { "const": 1 }, + "frame_ms": { "const": 20 } + } + }, + "audioCredit": { + "type": "object", + "additionalProperties": false, + "required": ["v", "t", "epoch", "stream_id", "ack_seq", "credits"], + "properties": { + "v": { "const": 1 }, + "t": { "const": "audio_credit" }, + "epoch": { "$ref": "#/$defs/u32" }, + "stream_id": { "$ref": "#/$defs/u32" }, + "ack_seq": { "$ref": "#/$defs/u32" }, + "credits": { "type": "integer", "minimum": 1, "maximum": 32 } + } + }, + "audioEnd": { + "type": "object", + "additionalProperties": false, + "required": ["v", "t", "epoch", "stream_id", "last_seq", "sample_count", "reason"], + "properties": { + "v": { "const": 1 }, + "t": { "const": "audio_end" }, + "epoch": { "$ref": "#/$defs/u32" }, + "stream_id": { "$ref": "#/$defs/u32" }, + "last_seq": { "type": "integer", "minimum": 0, "maximum": 5999 }, + "sample_count": { "type": "integer", "minimum": 320, "maximum": 1920000, "multipleOf": 320 }, + "reason": { "enum": ["submitted", "max_duration"] } + } + }, + "audioCancel": { + "type": "object", + "additionalProperties": false, + "required": ["v", "t", "epoch", "stream_id", "reason"], + "properties": { + "v": { "const": 1 }, + "t": { "const": "audio_cancel" }, + "epoch": { "$ref": "#/$defs/u32" }, + "stream_id": { "$ref": "#/$defs/u32" }, + "reason": { "enum": ["local", "host", "disconnect", "transport", "timeout", "hardware", "rejected"] } } + }, + "audioState": { + "type": "object", + "additionalProperties": false, + "required": ["v", "t", "epoch", "state"], + "properties": { + "v": { "const": 1 }, + "t": { "const": "audio_state" }, + "epoch": { "$ref": "#/$defs/u32" }, + "stream_id": { "$ref": "#/$defs/u32" }, + "state": { "enum": ["ready", "receiving", "transcribing", "submitted", "error"] } + }, + "allOf": [ + { + "if": { "properties": { "state": { "const": "ready" } } }, + "then": { "not": { "required": ["stream_id"] } }, + "else": { "required": ["stream_id"] } + } + ] } } } diff --git a/devices/schemas/transport-descriptor.schema.json b/devices/schemas/transport-descriptor.schema.json new file mode 100644 index 0000000..0dca269 --- /dev/null +++ b/devices/schemas/transport-descriptor.schema.json @@ -0,0 +1,108 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/Nexting-ai/devices/schemas/transport-descriptor.schema.json", + "title": "Nexting Compatibility Tunnel v1 descriptor", + "$ref": "#/$defs/descriptor", + "$defs": { + "descriptor": { + "type": "object", + "additionalProperties": false, + "required": [ + "version", + "integration_id", + "display_name", + "discovery", + "envelope", + "channels", + "requirements" + ], + "properties": { + "version": { "const": 1 }, + "integration_id": { + "type": "string", + "pattern": "^[a-z0-9][a-z0-9-]{0,31}(?:\\.[a-z0-9][a-z0-9-]{0,31})+$" + }, + "display_name": { + "type": "string", + "minLength": 1, + "x-maxUtf8Bytes": 64 + }, + "discovery": { "$ref": "#/$defs/discovery" }, + "envelope": { "$ref": "#/$defs/envelope" }, + "channels": { "$ref": "#/$defs/channels" }, + "requirements": { "$ref": "#/$defs/requirements" } + } + }, + "uuid": { + "type": "string", + "pattern": "^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$" + }, + "discovery": { + "type": "object", + "additionalProperties": false, + "required": ["service_uuid"], + "properties": { + "service_uuid": { "$ref": "#/$defs/uuid" } + } + }, + "envelope": { + "type": "object", + "additionalProperties": false, + "required": ["strategy", "max_payload_bytes"], + "properties": { + "strategy": { "enum": ["opcode-prefix-v1"] }, + "max_payload_bytes": { + "type": "integer", + "minimum": 1, + "maximum": 511 + } + } + }, + "writeChannel": { + "type": "object", + "additionalProperties": false, + "required": ["characteristic_uuid", "property", "opcode"], + "properties": { + "characteristic_uuid": { "$ref": "#/$defs/uuid" }, + "property": { "const": "write" }, + "opcode": { "type": "integer", "minimum": 0, "maximum": 255 } + } + }, + "notifyChannel": { + "type": "object", + "additionalProperties": false, + "required": ["characteristic_uuid", "property", "opcode"], + "properties": { + "characteristic_uuid": { "$ref": "#/$defs/uuid" }, + "property": { "const": "notify" }, + "opcode": { "type": "integer", "minimum": 0, "maximum": 255 } + } + }, + "channels": { + "type": "object", + "additionalProperties": false, + "required": ["control_down", "control_up", "audio_up", "flow_down"], + "properties": { + "control_down": { "$ref": "#/$defs/writeChannel" }, + "control_up": { "$ref": "#/$defs/notifyChannel" }, + "audio_up": { "$ref": "#/$defs/notifyChannel" }, + "flow_down": { "$ref": "#/$defs/writeChannel" } + }, + "$comment": "Conformance parsers also require all four opcodes to be distinct." + }, + "requirements": { + "type": "object", + "additionalProperties": false, + "required": ["bonded", "encrypted", "min_att_payload"], + "properties": { + "bonded": { "const": true }, + "encrypted": { "const": true }, + "min_att_payload": { + "type": "integer", + "minimum": 64, + "maximum": 512 + } + } + } + } +} diff --git a/devices/scripts/check-naming.mjs b/devices/scripts/check-naming.mjs index 8fa98f7..8a596bf 100644 --- a/devices/scripts/check-naming.mjs +++ b/devices/scripts/check-naming.mjs @@ -3,7 +3,12 @@ import { dirname, join, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const ignoredDirectories = new Set([".build", ".git", "build", "build-sanitize"]); +const ignoredDirectories = new Set([ + ".build", + ".git", + "build", + "build-sanitize", +]); const forbiddenNames = [ ["O", "DP"].join(""), ["Open Device", " Protocol"].join(""), @@ -18,7 +23,7 @@ async function collect(directory) { if (ignoredDirectories.has(entry.name)) continue; const path = join(directory, entry.name); const metadata = await lstat(path); - if (metadata.isDirectory()) files.push(...await collect(path)); + if (metadata.isDirectory()) files.push(...(await collect(path))); else if (metadata.isFile()) files.push(path); } return files; @@ -30,7 +35,9 @@ for (const path of await collect(root)) { const content = await readFile(path, "utf8"); for (const forbidden of forbiddenNames) { if (name.includes(forbidden) || content.includes(forbidden)) { - failures.push(`${name}: contains retired or ambiguous public name ${JSON.stringify(forbidden)}`); + failures.push( + `${name}: contains retired or ambiguous public name ${JSON.stringify(forbidden)}`, + ); } } } diff --git a/devices/scripts/check-public-boundary.mjs b/devices/scripts/check-public-boundary.mjs index c205335..6816d97 100644 --- a/devices/scripts/check-public-boundary.mjs +++ b/devices/scripts/check-public-boundary.mjs @@ -4,7 +4,12 @@ import { fileURLToPath } from "node:url"; const root = resolve(dirname(fileURLToPath(import.meta.url)), ".."); const privateRepositoryRoot = resolve(root, "../.."); -const ignoredDirectories = new Set([".build", ".git", "build", "build-sanitize"]); +const ignoredDirectories = new Set([ + ".build", + ".git", + "build", + "build-sanitize", +]); export const forbiddenPatterns = [ new RegExp(["hardware", "internal/"].join("-")), new RegExp(["CCSession", "Model"].join("")), @@ -27,9 +32,11 @@ async function collect(directory) { const path = join(directory, entry.name); const metadata = await lstat(path); if (metadata.isSymbolicLink()) { - throw new Error(`public subtree must not contain symlinks: ${relative(root, path)}`); + throw new Error( + `public subtree must not contain symlinks: ${relative(root, path)}`, + ); } - if (metadata.isDirectory()) files.push(...await collect(path)); + if (metadata.isDirectory()) files.push(...(await collect(path))); else if (metadata.isFile()) files.push(path); } return files; @@ -48,7 +55,9 @@ for (const path of await collect(root)) { for (const path of obsoletePrivateSources) { try { await lstat(path); - failures.push(`${relative(privateRepositoryRoot, path)}: obsolete prototype still exists`); + failures.push( + `${relative(privateRepositoryRoot, path)}: obsolete prototype still exists`, + ); } catch (error) { if (error?.code !== "ENOENT") throw error; } diff --git a/devices/scripts/documentation-contract.test.mjs b/devices/scripts/documentation-contract.test.mjs index ea0cf7d..8fffcc1 100644 --- a/devices/scripts/documentation-contract.test.mjs +++ b/devices/scripts/documentation-contract.test.mjs @@ -25,7 +25,10 @@ test("foundation guide maps device behavior to public source files", async () => } assert.match(foundation, /does not receive Agent credentials/); assert.match(foundation, /does not define a public cloud API/); - assert.doesNotMatch(foundation, /NextingDeviceKit|Protocol\.swift|Coordinator\.swift/); + assert.doesNotMatch( + foundation, + /NextingDeviceKit|Protocol\.swift|Coordinator\.swift/, + ); }); test("interface catalog exposes only the device-side public surfaces", async () => { @@ -34,43 +37,81 @@ test("interface catalog exposes only the device-side public surfaces", async () assert.match(interfaces, new RegExp("6EADC0DE-" + suffix)); } for (const marker of [ - "present", "answer", "resolved", "error", "status/1", "statusSlots", - "nexting_device_status_on_message", "status-v1.json", "navigation/1", - "keys/1", "rotary/1", "voice/1", "text/1", "usage/1", "config/1", - "navigation-v1.json", "key_event", "rotary_event", "voice_event", - "config_result", "nexting_device_decode", "nexting_device_stream_push", - "nexting_device_state_choose", "reference/js/src/protocol.mjs", - "sdk/c/src/nexting_device.c", "npm run test:reference", "ctest --test-dir", + "present", + "answer", + "resolved", + "error", + "status/1", + "statusSlots", + "nexting_device_status_on_message", + "status-v1.json", + "navigation/1", + "keys/1", + "rotary/1", + "voice/1", + "text/1", + "usage/1", + "config/1", + "navigation-v1.json", + "key_event", + "rotary_event", + "voice_event", + "config_result", + "nexting_device_decode", + "nexting_device_stream_push", + "nexting_device_state_choose", + "reference/js/src/protocol.mjs", + "sdk/c/src/nexting_device.c", + "npm run test:reference", + "ctest --test-dir", ]) { assert.match(interfaces, new RegExp(marker)); } assert.match(interfaces, /official (?:Nexting )?App/); assert.match(interfaces, /does not expose a Nexting cloud API/); - assert.doesNotMatch(interfaces, /NextingDeviceKit|NextingDeviceCentral|sdk\/swift|sdk\/kotlin/); + assert.doesNotMatch( + interfaces, + /NextingDeviceKit|NextingDeviceCentral|sdk\/swift|sdk\/kotlin/, + ); }); test("the public package keeps Agent adapters and custom App SDKs private", async () => { - const [overview, index, interfaces, manifest, availability] = await Promise.all([ - read("README.md"), read("docs/README.md"), read("docs/interfaces.md"), - read("scripts/export-manifest.json"), read("docs/availability.json").then(JSON.parse), - ]); + const [overview, index, interfaces, manifest, availability] = + await Promise.all([ + read("README.md"), + read("docs/README.md"), + read("docs/interfaces.md"), + read("scripts/export-manifest.json"), + read("docs/availability.json").then(JSON.parse), + ]); for (const source of [overview, index, interfaces]) { assert.match(source, /official (?:Nexting )?App/i); assert.doesNotMatch(source, /sdk\/swift|sdk\/kotlin|codex-app-server\.md/); } - assert.doesNotMatch(manifest, /sdk\/swift|sdk\/kotlin|codex-app-server|macos-device-simulator/); + assert.doesNotMatch( + manifest, + /sdk\/swift|sdk\/kotlin|codex-app-server|macos-device-simulator/, + ); assert.equal(availability.fallback, "firmware-tests"); }); test("implementation tracks cover board, MCU, and protocol-tooling routes", async () => { const tracks = await read("docs/implementation-tracks.md"); for (const marker of [ - "Track 1: Run a reference board", "Track 2: Port a new MCU or RTOS", - "Track 3: Maintain protocol tooling", "nrf52840dk/nrf52840", - "xiao_ble/nrf52840/sense", "xiao_esp32c3/esp32c3", "xiao_esp32s3/esp32s3/procpu", - "nexting_device_state_disconnect", "approval-v1.json", + "Track 1: Run a reference board", + "Track 2: Port a new MCU or RTOS", + "Track 3: Maintain protocol tooling", + "nrf52840dk/nrf52840", + "xiao_ble/nrf52840/sense", + "xiao_esp32c3/esp32c3", + "xiao_esp32s3/esp32s3/procpu", + "nexting_device_state_disconnect", + "approval-v1.json", ]) { - assert.ok(tracks.includes(marker), "missing implementation marker: " + marker); + assert.ok( + tracks.includes(marker), + "missing implementation marker: " + marker, + ); } assert.doesNotMatch(tracks, /Host SDK|Track 4/); }); @@ -78,54 +119,95 @@ test("implementation tracks cover board, MCU, and protocol-tooling routes", asyn test("conformance guide keeps evidence levels literal", async () => { const conformance = await read("docs/conformance.md"); for (const marker of [ - "Protocol conformant", "Core tested", "Build verified", "Board verified", - "Nexting Compatible", "npm run test:reference", "NEXTING_DEVICE_SANITIZE=ON", + "Protocol conformant", + "Core tested", + "Build verified", + "Board verified", + "Nexting Compatible", + "npm run test:reference", + "NEXTING_DEVICE_SANITIZE=ON", "npm run test:firmware", - ]) assert.ok(conformance.includes(marker), "missing conformance marker: " + marker); + ]) + assert.ok( + conformance.includes(marker), + "missing conformance marker: " + marker, + ); assert.doesNotMatch(conformance, /swift test --package-path sdk\/swift/); assert.match(conformance, /Build verified is not Board verified/); }); test("reader entry points link the public documentation system", async () => { const [overview, index, development] = await Promise.all([ - read("README.md"), read("docs/README.md"), read("docs/development.md"), + read("README.md"), + read("docs/README.md"), + read("docs/development.md"), ]); for (const link of [ - "docs/foundation-development.md", "docs/interfaces.md", "docs/implementation-tracks.md", - "docs/conformance.md", "docs/use-cases.md", - ]) assert.ok(overview.includes(link), "README missing link: " + link); + "docs/foundation-development.md", + "docs/interfaces.md", + "docs/implementation-tracks.md", + "docs/conformance.md", + "docs/use-cases.md", + ]) + assert.ok(overview.includes(link), "README missing link: " + link); for (const link of [ - "foundation-development.md", "interfaces.md", "implementation-tracks.md", - "conformance.md", "use-cases.md", - ]) assert.ok(index.includes(link), "docs index missing link: " + link); + "foundation-development.md", + "interfaces.md", + "implementation-tracks.md", + "conformance.md", + "use-cases.md", + ]) + assert.ok(index.includes(link), "docs index missing link: " + link); assert.match(development, /All four reference targets are Build verified/); assert.doesNotMatch(development, /swift|kotlin|Host SDK/i); }); -test("0.2 experimental.2 documents every frozen interaction profile", async () => { +test("0.4 experimental.1 documents every frozen interaction profile", async () => { const [overview, changelog, projectStatus, c, js] = await Promise.all([ - read("README.md"), read("CHANGELOG.md"), read("docs/project-status.md"), - read("sdk/c/README.md"), read("reference/js/README.md"), + read("README.md"), + read("CHANGELOG.md"), + read("docs/project-status.md"), + read("sdk/c/README.md"), + read("reference/js/README.md"), ]); for (const source of [overview, changelog, projectStatus, c]) { - assert.match(source, /0\.2\.0-experimental\.2/); + assert.match(source, /0\.4\.0-experimental\.1/); assert.doesNotMatch(source, /Swift 6|Kotlin SDK|NextingDeviceKit/); } - assert.match(js, /Experimental 0\.2/); - for (const profile of ["navigation/1", "keys/1", "rotary/1", "voice/1", "text/1", "usage/1", "config/1"]) { + assert.match(changelog, /## 0\.3\.0-experimental\.1/); + assert.match(js, /Experimental 0\.4/); + for (const profile of [ + "navigation/1", + "keys/1", + "rotary/1", + "voice/1", + "text/1", + "usage/1", + "config/1", + "device-audio/1", + ]) { assert.match(overview, new RegExp(profile.replace("/", "\\/"))); assert.match(projectStatus, new RegExp(profile.replace("/", "\\/"))); } assert.match(overview, /Host microphone/i); + assert.match(overview, /physical device/i); assert.match(changelog, /frozen interaction profiles/i); }); test("Agent and maintainer guides use the same public sources", async () => { const [agents, contributing, c, firmware, porting] = await Promise.all([ - read("AGENTS.md"), read("CONTRIBUTING.md"), read("sdk/c/README.md"), - read("firmware/zephyr/README.md"), read("docs/porting-guide.md"), + read("AGENTS.md"), + read("CONTRIBUTING.md"), + read("sdk/c/README.md"), + read("firmware/zephyr/README.md"), + read("docs/porting-guide.md"), ]); - for (const marker of ["docs/foundation-development.md", "docs/interfaces.md", "docs/implementation-tracks.md", "docs/conformance.md"]) { + for (const marker of [ + "docs/foundation-development.md", + "docs/interfaces.md", + "docs/implementation-tracks.md", + "docs/conformance.md", + ]) { assert.ok(agents.includes(marker), "AGENTS missing source: " + marker); } assert.ok(contributing.includes("docs/conformance.md")); @@ -136,31 +218,89 @@ test("Agent and maintainer guides use the same public sources", async () => { }); test("public Quickstart explains the product before optional reference hardware", async () => { - const referenceControllerUrl = new URL("docs/reference-approval-controller.md", root); + const referenceControllerUrl = new URL( + "docs/reference-approval-controller.md", + root, + ); const availabilityUrl = new URL("docs/availability.json", root); assert.ok(existsSync(referenceControllerUrl)); assert.ok(existsSync(availabilityUrl)); - const [overview, index, quickstart, referenceController, availability, packageJson, security, troubleshooting, firstApproval] = await Promise.all([ - read("README.md"), read("docs/README.md"), read("QUICKSTART.md"), - read("docs/reference-approval-controller.md"), read("docs/availability.json").then(JSON.parse), - read("package.json").then(JSON.parse), read("SECURITY.md"), - read("docs/troubleshooting.md"), read("docs/first-approval.md"), + const [ + overview, + index, + quickstart, + referenceController, + availability, + packageJson, + security, + troubleshooting, + firstApproval, + ] = await Promise.all([ + read("README.md"), + read("docs/README.md"), + read("QUICKSTART.md"), + read("docs/reference-approval-controller.md"), + read("docs/availability.json").then(JSON.parse), + read("package.json").then(JSON.parse), + read("SECURITY.md"), + read("docs/troubleshooting.md"), + read("docs/first-approval.md"), ]); for (const source of [overview, index]) { assert.match(source, /QUICKSTART\.md/); assert.match(source, /troubleshooting\.md/); } - for (const marker of ["Nexting device", "encrypted BLE", "trusted Host", "Agent integration", "Use a supported first-party Nexting product", "Build with the Nexting SDK", "First remote interaction", "local device proof", "third-party developer-device enrollment"]) { - assert.ok(quickstart.includes(marker), "Quickstart missing marker: " + marker); + for (const marker of [ + "Nexting device", + "encrypted BLE", + "trusted Host", + "Agent integration", + "Use a supported first-party Nexting product", + "Build with the Nexting SDK", + "First remote interaction", + "local device proof", + "third-party developer-device enrollment", + ]) { + assert.ok( + quickstart.includes(marker), + "Quickstart missing marker: " + marker, + ); } - assert.doesNotMatch(quickstart, /Build your first Nexting device|two-button Nexting device|D0 to GND|D1 to GND/); - for (const marker of ["Build the reference approval controller", "Developer Reference", "approval/1", "XIAO nRF52840", "D0", "D1", "bootstrap-zephyr.sh", "official Nexting App"]) { - assert.ok(referenceController.includes(marker), "reference controller missing marker: " + marker); + assert.doesNotMatch( + quickstart, + /Build your first Nexting device|two-button Nexting device|D0 to GND|D1 to GND/, + ); + for (const marker of [ + "Build the reference approval controller", + "Developer Reference", + "approval/1", + "XIAO nRF52840", + "D0", + "D1", + "bootstrap-zephyr.sh", + "official Nexting App", + ]) { + assert.ok( + referenceController.includes(marker), + "reference controller missing marker: " + marker, + ); } assert.equal(availability.sdkVersion, packageJson.version); assert.equal(availability.wireMajor, 1); assert.equal(availability.fallback, "firmware-tests"); - for (const marker of ["BLE LE Secure Connections", "encrypted GATT", "opaque request ID", "single consumption", "duplicate", "replay", "disconnect", "Just Works", "authenticated application identity", "voice/1", "never carries audio bytes or transcripts"]) { + for (const marker of [ + "BLE LE Secure Connections", + "encrypted GATT", + "opaque request ID", + "single consumption", + "duplicate", + "replay", + "disconnect", + "Just Works", + "authenticated application identity", + "voice/1", + "never carries audio bytes or transcripts", + ]) { assert.ok(security.includes(marker), "Security missing marker: " + marker); } assert.doesNotMatch(firstApproval, /swift|NextingDeviceKit|Host SDK/i); @@ -170,19 +310,44 @@ test("public Quickstart explains the product before optional reference hardware" }); test("public firmware workflow publishes pinned self-describing tag assets", async () => { - const workflow = await read("scripts/public-workflows/nexting-devices-firmware.yml"); - for (const marker of ["devices-v*", "artifact-manifest.json", "SHA256SUMS", '"zephyr": "4.3.0"', '"zephyrSdk": "0.17.4"', '"west": "1.5.0"', '"flash": "%s"', '"evidence": "Build verified"', '"boardVerified": false', "gh release create"]) { - assert.ok(workflow.includes(marker), `firmware release workflow missing ${marker}`); + const workflow = await read( + "scripts/public-workflows/nexting-devices-firmware.yml", + ); + for (const marker of [ + "devices-v*", + "artifact-manifest.json", + "SHA256SUMS", + '"zephyr": "4.3.0"', + '"zephyrSdk": "0.17.4"', + '"west": "1.5.0"', + '"flash": "%s"', + '"evidence": "Build verified"', + '"boardVerified": false', + "gh release create", + ]) { + assert.ok( + workflow.includes(marker), + `firmware release workflow missing ${marker}`, + ); } }); test("public workflows use current Node action majors and no Host SDK jobs", async () => { const [ci, firmware] = await Promise.all([ - read("scripts/public-workflows/nexting-devices-ci.yml"), read("scripts/public-workflows/nexting-devices-firmware.yml"), + read("scripts/public-workflows/nexting-devices-ci.yml"), + read("scripts/public-workflows/nexting-devices-firmware.yml"), ]); const workflows = `${ci}\n${firmware}`; - for (const marker of ["actions/checkout@v7", "actions/setup-node@v7", "actions/setup-python@v7", "actions/upload-artifact@v7"]) { + for (const marker of [ + "actions/checkout@v7", + "actions/setup-node@v7", + "actions/setup-python@v7", + "actions/upload-artifact@v7", + ]) { assert.ok(workflows.includes(marker), `public workflows missing ${marker}`); } - assert.doesNotMatch(workflows, /NextingDeviceKit|gradle|swift test|sdk\/kotlin/); + assert.doesNotMatch( + workflows, + /NextingDeviceKit|gradle|swift test|sdk\/kotlin/, + ); }); diff --git a/devices/scripts/export-manifest.json b/devices/scripts/export-manifest.json index 17f7b7a..5b7f586 100644 --- a/devices/scripts/export-manifest.json +++ b/devices/scripts/export-manifest.json @@ -15,14 +15,19 @@ "docs/README.md", "docs/availability.json", "docs/board-verification.md", + "docs/cases/multipad-first-case.md", + "docs/cases/ahakey-x1-case.md", "docs/conformance.md", + "docs/compatibility-tunnel.md", "docs/development.md", + "docs/device-audio.md", "docs/first-approval.md", "docs/foundation-development.md", "docs/hardware-support.md", "docs/implementation-tracks.md", "docs/interfaces.md", "docs/migration-0.1-to-0.2.md", + "docs/multipad-usb.md", "docs/porting-guide.md", "docs/project-status.md", "docs/reference-approval-controller.md", @@ -38,9 +43,39 @@ "firmware/zephyr/prj.conf", "firmware/zephyr/src/main.c", "firmware/zephyr/tests/firmware-contract.test.mjs", + "firmware/multipad/CMakeLists.txt", + "firmware/multipad/README.md", + "firmware/multipad/bringup-status.md", + "firmware/multipad/hw-review-findings.md", + "firmware/multipad/nexting-multipad-device-info.template.json", + "firmware/multipad/pin-function-map.md", + "firmware/multipad/nexting_multipad_adapter.c", + "firmware/multipad/nexting_multipad_adapter.h", + "firmware/multipad/tests/test_adapter.c", + "firmware/multipad/tools/flash-multipad.sh", + "firmware/multipad/tools/multipad-cdc-smoke.py", + "firmware/ahakey-x1/CMakeLists.txt", + "firmware/ahakey-x1/README.md", + "firmware/ahakey-x1/ahakey_proto.c", + "firmware/ahakey-x1/ahakey_proto.h", + "firmware/ahakey-x1/nexting_ahakey_adapter.c", + "firmware/ahakey-x1/nexting_ahakey_adapter.h", + "firmware/ahakey-x1/nexting_ahakey_audio.c", + "firmware/ahakey-x1/nexting_ahakey_audio.h", + "firmware/ahakey-x1/developer-kit/README.md", + "firmware/ahakey-x1/developer-kit/nexting_ahakey_port.c", + "firmware/ahakey-x1/developer-kit/nexting_ahakey_port.h", + "firmware/ahakey-x1/nexting-ahakey-device-info.template.json", + "firmware/ahakey-x1/transport-descriptor.nexting-device.json", + "firmware/ahakey-x1/tests/test_ahakey_adapter.c", + "firmware/ahakey-x1/tests/test_ahakey_audio.c", + "firmware/ahakey-x1/tests/test_ahakey_port.c", + "firmware/ahakey-x1/tests/test_ahakey_proto.c", "package.json", "protocol/vectors/approval-v1.json", "protocol/vectors/device-info-v1.json", + "protocol/vectors/device-audio-v1.json", + "protocol/vectors/compatibility-tunnel-v1.json", "protocol/vectors/navigation-v1.json", "protocol/vectors/keys-v1.json", "protocol/vectors/rotary-v1.json", @@ -54,15 +89,22 @@ "reference/js/src/device-info.mjs", "reference/js/src/framing.mjs", "reference/js/src/protocol.mjs", + "reference/js/src/strict-json.mjs", + "reference/js/src/audio.mjs", + "reference/js/src/tunnel.mjs", "reference/js/src/relay.mjs", "reference/js/test/device-info.test.mjs", "reference/js/test/framing.test.mjs", "reference/js/test/package.test.mjs", "reference/js/test/protocol.test.mjs", + "reference/js/test/audio-control.test.mjs", + "reference/js/test/audio.test.mjs", + "reference/js/test/tunnel.test.mjs", "reference/js/test/relay.test.mjs", "reference/js/test/status.test.mjs", "reference/js/test/vectors.test.mjs", "schemas/message.schema.json", + "schemas/transport-descriptor.schema.json", "scripts/check-naming.mjs", "scripts/check-public-boundary.mjs", "scripts/check-public-boundary.test.mjs", @@ -75,10 +117,14 @@ "scripts/public-workflows/nexting-devices-ci.yml", "scripts/public-workflows/nexting-devices-firmware.yml", "scripts/simulator-contract.test.mjs", + "simulator/device-audio-simulator.mjs", + "simulator/device-audio-simulator.test.mjs", "sdk/c/CMakeLists.txt", "sdk/c/README.md", "sdk/c/include/nexting_device.h", + "sdk/c/include/nexting_device_audio.h", "sdk/c/src/nexting_device.c", + "sdk/c/src/nexting_device_audio.c", "sdk/c/tests/generate_vectors.mjs", "sdk/c/tests/generate_interaction_vectors.mjs", "sdk/c/tests/test_codec.c", @@ -87,15 +133,14 @@ "sdk/c/tests/test_status.c", "sdk/c/tests/test_stream.c", "sdk/c/tests/test_interactions.c", + "sdk/c/tests/test_audio.c", "west.yml" ], "optionalStagingFiles": [ ".github/workflows/ci.yml", ".github/workflows/firmware.yml" ], - "optionalGeneratedFiles": [ - "SHA256SUMS" - ], + "optionalGeneratedFiles": ["SHA256SUMS"], "workflowMappings": { "scripts/public-workflows/nexting-devices-ci.yml": ".github/workflows/nexting-devices-ci.yml", "scripts/public-workflows/nexting-devices-firmware.yml": ".github/workflows/nexting-devices-firmware.yml" diff --git a/devices/scripts/export-nexting-devices.mjs b/devices/scripts/export-nexting-devices.mjs index 48932e1..796834b 100644 --- a/devices/scripts/export-nexting-devices.mjs +++ b/devices/scripts/export-nexting-devices.mjs @@ -21,12 +21,15 @@ const scriptDirectory = dirname(fileURLToPath(import.meta.url)); const defaultSource = resolve(scriptDirectory, ".."); const readmeStart = ""; const readmeEnd = ""; -const packageMarker = "// Generated by devices/scripts/export-nexting-devices.mjs"; +const packageMarker = + "// Generated by devices/scripts/export-nexting-devices.mjs"; const forbiddenPathPatterns = [ /(^|\/)\.env(?:\.|$)/i, /(^|\/)(?:secrets?|credentials?|signing)(?:\/|\.|$)/i, /\.(?:jks|keystore|mobileprovision|p12|pem|key)$/i, - new RegExp(`(^|/)(?:App|clients|cloud|infrastructure|${["supa", "base"].join("")})(?:/|$)`), + new RegExp( + `(^|/)(?:App|clients|cloud|infrastructure|${["supa", "base"].join("")})(?:/|$)`, + ), ]; const forbiddenContentPatterns = [ new RegExp(["hardware", "internal/"].join("-")), @@ -48,7 +51,9 @@ function parseArguments(argv) { else throw new Error(`unknown argument: ${argument}`); } if (!options.publicCheckout) { - throw new Error("usage: export-nexting-devices.mjs --public-checkout [--source ] [--check]"); + throw new Error( + "usage: export-nexting-devices.mjs --public-checkout [--source ] [--check]", + ); } return options; } @@ -88,9 +93,11 @@ async function collectFiles(root, ignoredDirectories) { } function isAllowedSourcePath(name, manifest) { - return manifest.allowedFiles.includes(name) - || manifest.optionalStagingFiles.includes(name) - || manifest.optionalGeneratedFiles.includes(name); + return ( + manifest.allowedFiles.includes(name) || + manifest.optionalStagingFiles.includes(name) || + manifest.optionalGeneratedFiles.includes(name) + ); } async function validateSource(source, manifest) { @@ -102,7 +109,8 @@ async function validateSource(source, manifest) { failures.push(`${file.name}: not present in the export allowlist`); } for (const pattern of forbiddenPathPatterns) { - if (pattern.test(file.name)) failures.push(`${file.name}: forbidden path`); + if (pattern.test(file.name)) + failures.push(`${file.name}: forbidden path`); } const content = await readFile(file.path, "utf8"); for (const pattern of forbiddenContentPatterns) { @@ -147,7 +155,9 @@ function mergeReadme(current) { return `${current.trimEnd()}\n\n${block}\n`; } if (start === -1 || end === -1 || end < start) { - throw new Error("public README contains an incomplete Nexting Devices marker block"); + throw new Error( + "public README contains an incomplete Nexting Devices marker block", + ); } return `${current.slice(0, start)}${block}${current.slice(end + readmeEnd.length)}`; } @@ -162,9 +172,10 @@ async function buildExport(source, output, manifest, files) { await mkdir(devices, { recursive: true }); for (const file of files) { if ( - manifest.optionalStagingFiles.includes(file.name) - || manifest.optionalGeneratedFiles.includes(file.name) - ) continue; + manifest.optionalStagingFiles.includes(file.name) || + manifest.optionalGeneratedFiles.includes(file.name) + ) + continue; const destination = join(devices, file.name); await mkdir(dirname(destination), { recursive: true }); await cp(file.path, destination, { errorOnExist: true }); @@ -187,7 +198,10 @@ async function sameTree(left, right) { if (leftFiles.length !== rightFiles.length) return false; for (let index = 0; index < leftFiles.length; index += 1) { if (leftFiles[index].name !== rightFiles[index].name) return false; - if (await sha256(leftFiles[index].path) !== await sha256(rightFiles[index].path)) { + if ( + (await sha256(leftFiles[index].path)) !== + (await sha256(rightFiles[index].path)) + ) { return false; } } @@ -195,7 +209,8 @@ async function sameTree(left, right) { } async function assertPublicCheckout(path) { - if (!(await exists(path))) throw new Error(`public checkout does not exist: ${path}`); + if (!(await exists(path))) + throw new Error(`public checkout does not exist: ${path}`); if (!(await exists(join(path, ".git")))) { throw new Error(`public checkout is not a Git worktree: ${path}`); } @@ -215,10 +230,17 @@ async function desiredRootFiles(publicCheckout, output, manifest, source) { } async function checkGeneratedRoot(publicCheckout, desired, manifest) { - for (const path of ["README.md", ...Object.values(manifest.workflowMappings)]) { + for (const path of [ + "README.md", + ...Object.values(manifest.workflowMappings), + ]) { const current = join(publicCheckout, path); const expected = join(desired, path); - if (!(await exists(current)) || await sha256(current) !== await sha256(expected)) return false; + if ( + !(await exists(current)) || + (await sha256(current)) !== (await sha256(expected)) + ) + return false; } return true; } @@ -235,7 +257,10 @@ async function installExport(publicCheckout, built, manifest) { const destination = join(publicCheckout, manifest.destination); await rm(destination, { recursive: true, force: true }); await cp(join(built, manifest.destination), destination, { recursive: true }); - for (const path of ["README.md", ...Object.values(manifest.workflowMappings)]) { + for (const path of [ + "README.md", + ...Object.values(manifest.workflowMappings), + ]) { const from = join(built, path); const to = join(publicCheckout, path); await mkdir(dirname(to), { recursive: true }); @@ -259,11 +284,15 @@ export async function exportNextingDevices(options) { await buildExport(source, temporary, manifest, files); await desiredRootFiles(publicCheckout, temporary, manifest, source); if (options.check) { - const clean = await sameTree( - join(temporary, manifest.destination), - join(publicCheckout, manifest.destination), - ) && await checkGeneratedRoot(publicCheckout, temporary, manifest); - if (!clean) throw new Error("public checkout differs from the deterministic export"); + const clean = + (await sameTree( + join(temporary, manifest.destination), + join(publicCheckout, manifest.destination), + )) && (await checkGeneratedRoot(publicCheckout, temporary, manifest)); + if (!clean) + throw new Error( + "public checkout differs from the deterministic export", + ); return; } await installExport(publicCheckout, temporary, manifest); @@ -273,8 +302,8 @@ export async function exportNextingDevices(options) { } if ( - process.argv[1] - && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) + process.argv[1] && + realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) ) { try { await exportNextingDevices(parseArguments(process.argv.slice(2))); diff --git a/devices/scripts/export-nexting-devices.test.mjs b/devices/scripts/export-nexting-devices.test.mjs index 5ceb5ba..70ff1eb 100644 --- a/devices/scripts/export-nexting-devices.test.mjs +++ b/devices/scripts/export-nexting-devices.test.mjs @@ -1,6 +1,15 @@ import assert from "node:assert/strict"; import { execFile } from "node:child_process"; -import { cp, lstat, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { + cp, + lstat, + mkdir, + mkdtemp, + readFile, + rm, + symlink, + writeFile, +} from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, resolve } from "node:path"; import test from "node:test"; @@ -17,7 +26,10 @@ async function fixture() { const publicCheckout = join(root, "public"); await mkdir(join(publicCheckout, ".git"), { recursive: true }); await mkdir(join(publicCheckout, "unrelated"), { recursive: true }); - await writeFile(join(publicCheckout, "README.md"), "# Nexting\n\nExisting introduction.\n"); + await writeFile( + join(publicCheckout, "README.md"), + "# Nexting\n\nExisting introduction.\n", + ); await writeFile(join(publicCheckout, "LICENSE"), "existing public license\n"); await writeFile(join(publicCheckout, "unrelated", "keep.txt"), "keep\n"); return { root, publicCheckout }; @@ -27,7 +39,10 @@ async function copiedSource(root) { const destination = join(root, "source"); await cp(source, destination, { recursive: true, - filter: (path) => !/(?:^|\/)(?:\.build|\.gradle|\.kotlin|build|node_modules)(?:\/|$)/.test(path), + filter: (path) => + !/(?:^|\/)(?:\.build|\.gradle|\.kotlin|build|node_modules)(?:\/|$)/.test( + path, + ), }); return destination; } @@ -36,9 +51,17 @@ test("exports a deterministic devices subtree without touching unrelated public const { root, publicCheckout } = await fixture(); try { await exportNextingDevices({ source, publicCheckout, check: false }); - assert.match(await readFile(join(publicCheckout, "README.md"), "utf8"), /Build hardware for Nexting/); - await assert.rejects(readFile(join(publicCheckout, "Package.swift")), { code: "ENOENT" }); - assert.match(await readFile(join(publicCheckout, "devices", "SHA256SUMS"), "utf8"), /SPEC\.md/); + assert.match( + await readFile(join(publicCheckout, "README.md"), "utf8"), + /Build hardware for Nexting/, + ); + await assert.rejects(readFile(join(publicCheckout, "Package.swift")), { + code: "ENOENT", + }); + assert.match( + await readFile(join(publicCheckout, "devices", "SHA256SUMS"), "utf8"), + /SPEC\.md/, + ); assert.match( await readFile( join(publicCheckout, "devices", "docs", "availability.json"), @@ -65,8 +88,14 @@ test("exports a deterministic devices subtree without touching unrelated public ), /npm run check/, ); - assert.equal(await readFile(join(publicCheckout, "LICENSE"), "utf8"), "existing public license\n"); - assert.equal(await readFile(join(publicCheckout, "unrelated", "keep.txt"), "utf8"), "keep\n"); + assert.equal( + await readFile(join(publicCheckout, "LICENSE"), "utf8"), + "existing public license\n", + ); + assert.equal( + await readFile(join(publicCheckout, "unrelated", "keep.txt"), "utf8"), + "keep\n", + ); await exportNextingDevices({ source, publicCheckout, check: true }); } finally { await rm(root, { recursive: true, force: true }); @@ -75,7 +104,9 @@ test("exports a deterministic devices subtree without touching unrelated public test("CLI executes when invoked through a symlinked checkout path", async () => { const { root, publicCheckout } = await fixture(); - const entrypoint = fileURLToPath(new URL("./export-nexting-devices.mjs", import.meta.url)); + const entrypoint = fileURLToPath( + new URL("./export-nexting-devices.mjs", import.meta.url), + ); const linkedEntrypoint = join(root, "export-nexting-devices.mjs"); try { await symlink(entrypoint, linkedEntrypoint); @@ -106,7 +137,10 @@ test("rejects unknown roots and sensitive paths", async () => { /not present in the export allowlist/, ); await rm(join(candidate, "private-app.swift")); - await writeFile(join(candidate, "docs", ".env.production"), "TOKEN=not-a-real-token\n"); + await writeFile( + join(candidate, "docs", ".env.production"), + "TOKEN=not-a-real-token\n", + ); await assert.rejects( exportNextingDevices({ source: candidate, publicCheckout, check: false }), /forbidden path/, @@ -127,13 +161,19 @@ test("rejects private content and symlinks", async () => { try { const candidate = await copiedSource(root); const privatePath = ["hardware", "internal/"].join("-"); - await writeFile(join(candidate, "docs", "bad.md"), `do not export ${privatePath}\n`); + await writeFile( + join(candidate, "docs", "bad.md"), + `do not export ${privatePath}\n`, + ); await assert.rejects( exportNextingDevices({ source: candidate, publicCheckout, check: false }), /private-boundary rule/, ); await rm(join(candidate, "docs", "bad.md")); - await symlink(join(candidate, "README.md"), join(candidate, "docs", "linked-readme.md")); + await symlink( + join(candidate, "README.md"), + join(candidate, "docs", "linked-readme.md"), + ); await assert.rejects( exportNextingDevices({ source: candidate, publicCheckout, check: false }), /symlinks are forbidden/, @@ -146,12 +186,20 @@ test("rejects private content and symlinks", async () => { test("refuses to overwrite an unrelated root Swift package", async () => { const { root, publicCheckout } = await fixture(); try { - await writeFile(join(publicCheckout, "Package.swift"), "// unrelated package\n"); + await writeFile( + join(publicCheckout, "Package.swift"), + "// unrelated package\n", + ); await assert.rejects( exportNextingDevices({ source, publicCheckout, check: false }), /unrelated public Package\.swift/, ); - assert.equal(await lstat(join(publicCheckout, "unrelated", "keep.txt")).then((item) => item.isFile()), true); + assert.equal( + await lstat(join(publicCheckout, "unrelated", "keep.txt")).then((item) => + item.isFile(), + ), + true, + ); } finally { await rm(root, { recursive: true, force: true }); } @@ -172,8 +220,14 @@ test("the exported devices tree can reproduce itself", async () => { check: false, }); assert.equal( - await readFile(join(first.publicCheckout, "devices", "SHA256SUMS"), "utf8"), - await readFile(join(second.publicCheckout, "devices", "SHA256SUMS"), "utf8"), + await readFile( + join(first.publicCheckout, "devices", "SHA256SUMS"), + "utf8", + ), + await readFile( + join(second.publicCheckout, "devices", "SHA256SUMS"), + "utf8", + ), ); } finally { await rm(first.root, { recursive: true, force: true }); diff --git a/devices/scripts/simulator-contract.test.mjs b/devices/scripts/simulator-contract.test.mjs index 8c1da64..d9cf0e4 100644 --- a/devices/scripts/simulator-contract.test.mjs +++ b/devices/scripts/simulator-contract.test.mjs @@ -5,8 +5,16 @@ import test from "node:test"; const read = (path) => readFile(new URL(path, import.meta.url), "utf8"); test("public package keeps the custom Host simulator private", async () => { - await assert.rejects(access(new URL("../examples/macos-device-simulator/", import.meta.url)), { code: "ENOENT" }); - const workflow = await read("../scripts/public-workflows/nexting-devices-ci.yml"); - assert.doesNotMatch(workflow, /NextingDeviceKit|macos-device-simulator|swift test/); + await assert.rejects( + access(new URL("../examples/macos-device-simulator/", import.meta.url)), + { code: "ENOENT" }, + ); + const workflow = await read( + "../scripts/public-workflows/nexting-devices-ci.yml", + ); + assert.doesNotMatch( + workflow, + /NextingDeviceKit|macos-device-simulator|swift test/, + ); assert.match(workflow, /npm run check/); }); diff --git a/devices/sdk/c/CMakeLists.txt b/devices/sdk/c/CMakeLists.txt index c63a491..ca0495d 100644 --- a/devices/sdk/c/CMakeLists.txt +++ b/devices/sdk/c/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(nexting_device_c VERSION 0.2.0 LANGUAGES C) +project(nexting_device_c VERSION 0.3.0 LANGUAGES C) option(NEXTING_DEVICE_SANITIZE "Enable address and undefined behavior sanitizers" OFF) @@ -66,7 +66,7 @@ if(NOT NEXTING_DEVICE_INTERACTION_VECTOR_RESULT EQUAL 0) message(FATAL_ERROR "Could not generate C interaction vectors") endif() -add_library(nexting_device STATIC src/nexting_device.c) +add_library(nexting_device STATIC src/nexting_device.c src/nexting_device_audio.c) target_include_directories(nexting_device PUBLIC include) target_compile_options(nexting_device PRIVATE -Wall -Wextra -Wpedantic -Werror) @@ -76,7 +76,7 @@ if(NEXTING_DEVICE_SANITIZE AND CMAKE_C_COMPILER_ID MATCHES "Clang|GNU") endif() enable_testing() -foreach(test_name IN ITEMS codec stream relay status device_info interactions) +foreach(test_name IN ITEMS codec stream relay status device_info interactions audio) add_executable(test_${test_name} tests/test_${test_name}.c) target_include_directories(test_${test_name} PRIVATE "${CMAKE_CURRENT_BINARY_DIR}") target_link_libraries(test_${test_name} PRIVATE nexting_device) diff --git a/devices/sdk/c/README.md b/devices/sdk/c/README.md index d3dd44b..90a80fa 100644 --- a/devices/sdk/c/README.md +++ b/devices/sdk/c/README.md @@ -2,9 +2,11 @@ This SDK is the chip-neutral part of a Nexting-compatible device. It provides: -- deterministic `0.2.0-experimental.2` message encoding and decoding for +- deterministic `0.4.0-experimental.1` message encoding and decoding for `approval/1`, `status/1`, `navigation/1`, `keys/1`, `rotary/1`, `voice/1`, - `text/1`, `usage/1`, and `config/1`; + `text/1`, `usage/1`, `config/1`, and `device-audio/1`; +- fixed-buffer CRC32C, 16 kHz mono IMA ADPCM, 192-byte logical audio frames, + and strict independent-frame decoding in `nexting_device_audio.h`; - fixed-buffer Device Info 0.2 parsing for typed identity, capabilities, and bounded inert vendor facts; - fixed-buffer newline framing for BLE writes and notifications; @@ -62,4 +64,7 @@ if (nexting_device_encode(&event, wire, sizeof wire, &wire_length) == Sequence counters are per source and monotonic for the current connection. Clear volatile interaction state on disconnect. `voice/1` uses the same pattern -for start/stop/cancel control; it never carries audio or transcripts. +for Host-microphone start/stop/cancel control; it never carries audio or +transcripts. A physical microphone declares `device-audio/1`, encodes PCM with +`nexting_device_audio_encode_frame`, obeys Host credits, and begins only from a +local physical input. See [Device audio](../../docs/device-audio.md). diff --git a/devices/sdk/c/include/nexting_device.h b/devices/sdk/c/include/nexting_device.h index 3e3f507..373fc29 100644 --- a/devices/sdk/c/include/nexting_device.h +++ b/devices/sdk/c/include/nexting_device.h @@ -70,7 +70,13 @@ typedef enum { NEXTING_DEVICE_MESSAGE_USAGE, NEXTING_DEVICE_MESSAGE_USAGE_CLEAR, NEXTING_DEVICE_MESSAGE_CONFIG, - NEXTING_DEVICE_MESSAGE_CONFIG_RESULT + NEXTING_DEVICE_MESSAGE_CONFIG_RESULT, + NEXTING_DEVICE_MESSAGE_AUDIO_CONFIG, + NEXTING_DEVICE_MESSAGE_AUDIO_BEGIN, + NEXTING_DEVICE_MESSAGE_AUDIO_CREDIT, + NEXTING_DEVICE_MESSAGE_AUDIO_END, + NEXTING_DEVICE_MESSAGE_AUDIO_CANCEL, + NEXTING_DEVICE_MESSAGE_AUDIO_STATE } nexting_device_message_type_t; typedef enum { @@ -186,6 +192,56 @@ typedef enum { NEXTING_DEVICE_CONFIG_UNSUPPORTED } nexting_device_config_error_t; +typedef enum { + NEXTING_DEVICE_AUDIO_CONTROL_CODEC_NONE = 0, + NEXTING_DEVICE_AUDIO_CONTROL_CODEC_IMA_ADPCM +} nexting_device_audio_codec_t; + +typedef enum { + NEXTING_DEVICE_AUDIO_END_NONE = 0, + NEXTING_DEVICE_AUDIO_END_SUBMITTED, + NEXTING_DEVICE_AUDIO_END_MAX_DURATION +} nexting_device_audio_end_reason_t; + +typedef enum { + NEXTING_DEVICE_AUDIO_CANCEL_NONE = 0, + NEXTING_DEVICE_AUDIO_CANCEL_LOCAL, + NEXTING_DEVICE_AUDIO_CANCEL_HOST, + NEXTING_DEVICE_AUDIO_CANCEL_DISCONNECT, + NEXTING_DEVICE_AUDIO_CANCEL_TRANSPORT, + NEXTING_DEVICE_AUDIO_CANCEL_TIMEOUT, + NEXTING_DEVICE_AUDIO_CANCEL_HARDWARE, + NEXTING_DEVICE_AUDIO_CANCEL_REJECTED +} nexting_device_audio_cancel_reason_t; + +typedef enum { + NEXTING_DEVICE_AUDIO_STATE_NONE = 0, + NEXTING_DEVICE_AUDIO_STATE_READY, + NEXTING_DEVICE_AUDIO_STATE_RECEIVING, + NEXTING_DEVICE_AUDIO_STATE_TRANSCRIBING, + NEXTING_DEVICE_AUDIO_STATE_SUBMITTED, + NEXTING_DEVICE_AUDIO_STATE_ERROR +} nexting_device_audio_state_t; + +typedef struct { + uint32_t epoch; + bool has_stream_id; + uint32_t stream_id; + nexting_device_audio_codec_t codec; + uint32_t sample_rate; + uint8_t channels; + uint16_t frame_ms; + uint32_t max_duration_ms; + uint16_t startup_buffer_ms; + uint8_t credits; + uint32_t ack_sequence; + uint32_t last_sequence; + uint32_t sample_count; + nexting_device_audio_end_reason_t end_reason; + nexting_device_audio_cancel_reason_t cancel_reason; + nexting_device_audio_state_t state; +} nexting_device_audio_control_payload_t; + typedef struct { size_t item_count; char items[NEXTING_DEVICE_NAV_MAX_ITEMS][NEXTING_DEVICE_NAV_ITEM_CAPACITY]; @@ -272,6 +328,7 @@ typedef struct { nexting_device_config_payload_t config; nexting_device_config_status_t config_status; nexting_device_config_error_t config_error; + nexting_device_audio_control_payload_t audio; } nexting_device_interaction_payload_t; typedef struct { @@ -336,6 +393,7 @@ typedef struct { size_t haptic_count; char haptics[NEXTING_DEVICE_INFO_MAX_HAPTICS] [NEXTING_DEVICE_INFO_HAPTIC_CAPACITY]; + bool has_device_audio; bool has_vendor; char vendor_namespace[NEXTING_DEVICE_INFO_VENDOR_NAMESPACE_CAPACITY]; size_t vendor_fact_count; @@ -350,6 +408,7 @@ typedef struct { bool supports_text_v1; bool supports_usage_v1; bool supports_config_v1; + bool supports_device_audio_v1; } nexting_device_info_t; nexting_device_result_t diff --git a/devices/sdk/c/include/nexting_device_audio.h b/devices/sdk/c/include/nexting_device_audio.h new file mode 100644 index 0000000..ffade6b --- /dev/null +++ b/devices/sdk/c/include/nexting_device_audio.h @@ -0,0 +1,65 @@ +#ifndef NEXTING_DEVICE_AUDIO_H +#define NEXTING_DEVICE_AUDIO_H + +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +#define NEXTING_DEVICE_AUDIO_WIRE_VERSION 1U +#define NEXTING_DEVICE_AUDIO_CODEC_IMA_ADPCM 1U +#define NEXTING_DEVICE_AUDIO_SAMPLE_RATE 16000U +#define NEXTING_DEVICE_AUDIO_FRAME_MS 20U +#define NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME 320U +#define NEXTING_DEVICE_AUDIO_PAYLOAD_BYTES 160U +#define NEXTING_DEVICE_AUDIO_HEADER_BYTES 28U +#define NEXTING_DEVICE_AUDIO_FRAME_BYTES 192U +#define NEXTING_DEVICE_AUDIO_MAX_FRAMES 6000U +#define NEXTING_DEVICE_AUDIO_MAX_SAMPLES 1920000U + +typedef enum { + NEXTING_DEVICE_AUDIO_OK = 0, + NEXTING_DEVICE_AUDIO_BAD_ARGUMENT, + NEXTING_DEVICE_AUDIO_BUFFER_TOO_SMALL, + NEXTING_DEVICE_AUDIO_BAD_FRAME +} nexting_device_audio_result_t; + +typedef struct { + uint32_t epoch; + uint32_t stream_id; + uint32_t sequence; + uint32_t sample_index; + uint16_t sample_count; + int16_t predictor; + uint8_t step_index; + int16_t samples[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME]; +} nexting_device_audio_frame_t; + +uint32_t nexting_device_crc32c(const uint8_t *bytes, size_t length); + +nexting_device_audio_result_t nexting_device_audio_encode_block( + const int16_t *samples, size_t sample_count, uint8_t step_index, + int16_t *predictor, uint8_t *payload, size_t payload_capacity); + +nexting_device_audio_result_t nexting_device_audio_decode_block( + int16_t predictor, uint8_t step_index, const uint8_t *payload, + size_t payload_length, size_t sample_count, int16_t *samples, + size_t sample_capacity); + +nexting_device_audio_result_t nexting_device_audio_encode_frame( + uint32_t epoch, uint32_t stream_id, uint32_t sequence, + uint32_t sample_index, const int16_t *samples, size_t sample_count, + uint8_t step_index, uint8_t *output, size_t output_capacity, + size_t *output_length); + +nexting_device_audio_result_t nexting_device_audio_decode_frame( + const uint8_t *wire, size_t wire_length, + nexting_device_audio_frame_t *output); + +#ifdef __cplusplus +} +#endif + +#endif diff --git a/devices/sdk/c/src/nexting_device.c b/devices/sdk/c/src/nexting_device.c index 699280d..b2b6bfc 100644 --- a/devices/sdk/c/src/nexting_device.c +++ b/devices/sdk/c/src/nexting_device.c @@ -52,7 +52,20 @@ typedef struct { #define FIELD_CONTEXT_LIMIT (UINT64_C(1) << 31) #define FIELD_ENTRIES (UINT64_C(1) << 32) #define FIELD_STATUS (UINT64_C(1) << 33) -#define FIELD_UNKNOWN (UINT64_C(1) << 34) +#define FIELD_EPOCH (UINT64_C(1) << 34) +#define FIELD_CODEC (UINT64_C(1) << 35) +#define FIELD_SAMPLE_RATE (UINT64_C(1) << 36) +#define FIELD_CHANNELS (UINT64_C(1) << 37) +#define FIELD_FRAME_MS (UINT64_C(1) << 38) +#define FIELD_MAX_DURATION_MS (UINT64_C(1) << 39) +#define FIELD_STARTUP_BUFFER_MS (UINT64_C(1) << 40) +#define FIELD_CREDITS (UINT64_C(1) << 41) +#define FIELD_STREAM_ID (UINT64_C(1) << 42) +#define FIELD_ACK_SEQ (UINT64_C(1) << 43) +#define FIELD_LAST_SEQ (UINT64_C(1) << 44) +#define FIELD_SAMPLE_COUNT (UINT64_C(1) << 45) +#define FIELD_REASON (UINT64_C(1) << 46) +#define FIELD_UNKNOWN (UINT64_C(1) << 47) static bool is_space(char value) { return value == ' ' || value == '\t' || value == '\n' || value == '\r'; @@ -748,6 +761,54 @@ static nexting_device_voice_state_t parse_voice_state(const char *value) { return NEXTING_DEVICE_VOICE_NONE; } +static nexting_device_audio_codec_t parse_audio_codec(const char *value) { + return strcmp(value, "ima_adpcm") == 0 + ? NEXTING_DEVICE_AUDIO_CONTROL_CODEC_IMA_ADPCM + : NEXTING_DEVICE_AUDIO_CONTROL_CODEC_NONE; +} + +static nexting_device_audio_end_reason_t +parse_audio_end_reason(const char *value) { + if (strcmp(value, "submitted") == 0) + return NEXTING_DEVICE_AUDIO_END_SUBMITTED; + if (strcmp(value, "max_duration") == 0) + return NEXTING_DEVICE_AUDIO_END_MAX_DURATION; + return NEXTING_DEVICE_AUDIO_END_NONE; +} + +static nexting_device_audio_cancel_reason_t +parse_audio_cancel_reason(const char *value) { + if (strcmp(value, "local") == 0) + return NEXTING_DEVICE_AUDIO_CANCEL_LOCAL; + if (strcmp(value, "host") == 0) + return NEXTING_DEVICE_AUDIO_CANCEL_HOST; + if (strcmp(value, "disconnect") == 0) + return NEXTING_DEVICE_AUDIO_CANCEL_DISCONNECT; + if (strcmp(value, "transport") == 0) + return NEXTING_DEVICE_AUDIO_CANCEL_TRANSPORT; + if (strcmp(value, "timeout") == 0) + return NEXTING_DEVICE_AUDIO_CANCEL_TIMEOUT; + if (strcmp(value, "hardware") == 0) + return NEXTING_DEVICE_AUDIO_CANCEL_HARDWARE; + if (strcmp(value, "rejected") == 0) + return NEXTING_DEVICE_AUDIO_CANCEL_REJECTED; + return NEXTING_DEVICE_AUDIO_CANCEL_NONE; +} + +static nexting_device_audio_state_t parse_audio_state(const char *value) { + if (strcmp(value, "ready") == 0) + return NEXTING_DEVICE_AUDIO_STATE_READY; + if (strcmp(value, "receiving") == 0) + return NEXTING_DEVICE_AUDIO_STATE_RECEIVING; + if (strcmp(value, "transcribing") == 0) + return NEXTING_DEVICE_AUDIO_STATE_TRANSCRIBING; + if (strcmp(value, "submitted") == 0) + return NEXTING_DEVICE_AUDIO_STATE_SUBMITTED; + if (strcmp(value, "error") == 0) + return NEXTING_DEVICE_AUDIO_STATE_ERROR; + return NEXTING_DEVICE_AUDIO_STATE_NONE; +} + static nexting_device_config_status_t parse_config_status(const char *value) { if (strcmp(value, "applied") == 0) return NEXTING_DEVICE_CONFIG_APPLIED; @@ -1207,6 +1268,7 @@ static bool parse_known_field(parser_t *parser, const char *key, !parse_string(parser, value, sizeof value)) return false; message->interaction.voice_state = parse_voice_state(value); + message->interaction.audio.state = parse_audio_state(value); return true; } if (strcmp(key, "label") == 0) { @@ -1279,6 +1341,87 @@ static bool parse_known_field(parser_t *parser, const char *key, message->interaction.config_status = parse_config_status(value); return true; } + if (strcmp(key, "epoch") == 0 || strcmp(key, "sample_rate") == 0 || + strcmp(key, "max_duration_ms") == 0 || + strcmp(key, "stream_id") == 0 || strcmp(key, "ack_seq") == 0 || + strcmp(key, "last_seq") == 0 || strcmp(key, "sample_count") == 0) { + const uint64_t field = + strcmp(key, "epoch") == 0 + ? FIELD_EPOCH + : strcmp(key, "sample_rate") == 0 + ? FIELD_SAMPLE_RATE + : strcmp(key, "max_duration_ms") == 0 + ? FIELD_MAX_DURATION_MS + : strcmp(key, "stream_id") == 0 + ? FIELD_STREAM_ID + : strcmp(key, "ack_seq") == 0 + ? FIELD_ACK_SEQ + : strcmp(key, "last_seq") == 0 + ? FIELD_LAST_SEQ + : FIELD_SAMPLE_COUNT; + if (!claim_field(fields, field) || !parse_uint(parser, &number) || + number > UINT32_MAX) + return false; + if (field == FIELD_EPOCH) + message->interaction.audio.epoch = (uint32_t)number; + else if (field == FIELD_SAMPLE_RATE) + message->interaction.audio.sample_rate = (uint32_t)number; + else if (field == FIELD_MAX_DURATION_MS) + message->interaction.audio.max_duration_ms = (uint32_t)number; + else if (field == FIELD_STREAM_ID) { + message->interaction.audio.stream_id = (uint32_t)number; + message->interaction.audio.has_stream_id = true; + } else if (field == FIELD_ACK_SEQ) + message->interaction.audio.ack_sequence = (uint32_t)number; + else if (field == FIELD_LAST_SEQ) + message->interaction.audio.last_sequence = (uint32_t)number; + else + message->interaction.audio.sample_count = (uint32_t)number; + return true; + } + if (strcmp(key, "channels") == 0 || strcmp(key, "frame_ms") == 0 || + strcmp(key, "startup_buffer_ms") == 0 || strcmp(key, "credits") == 0) { + const uint64_t field = + strcmp(key, "channels") == 0 + ? FIELD_CHANNELS + : strcmp(key, "frame_ms") == 0 + ? FIELD_FRAME_MS + : strcmp(key, "startup_buffer_ms") == 0 + ? FIELD_STARTUP_BUFFER_MS + : FIELD_CREDITS; + if (!claim_field(fields, field) || !parse_uint(parser, &number) || + number > UINT16_MAX) + return false; + if (field == FIELD_CHANNELS) { + if (number > UINT8_MAX) + return false; + message->interaction.audio.channels = (uint8_t)number; + } else if (field == FIELD_FRAME_MS) + message->interaction.audio.frame_ms = (uint16_t)number; + else if (field == FIELD_STARTUP_BUFFER_MS) + message->interaction.audio.startup_buffer_ms = (uint16_t)number; + else { + if (number > UINT8_MAX) + return false; + message->interaction.audio.credits = (uint8_t)number; + } + return true; + } + if (strcmp(key, "codec") == 0) { + if (!claim_field(fields, FIELD_CODEC) || + !parse_string(parser, value, sizeof value)) + return false; + message->interaction.audio.codec = parse_audio_codec(value); + return true; + } + if (strcmp(key, "reason") == 0) { + if (!claim_field(fields, FIELD_REASON) || + !parse_string(parser, value, sizeof value)) + return false; + message->interaction.audio.end_reason = parse_audio_end_reason(value); + message->interaction.audio.cancel_reason = parse_audio_cancel_reason(value); + return true; + } *fields |= FIELD_UNKNOWN; return skip_value(parser, 0); } @@ -1491,6 +1634,82 @@ static bool validate_message(nexting_device_message_t *message, message->type = NEXTING_DEVICE_MESSAGE_CONFIG_RESULT; return true; } + if (strcmp(type, "audio_config") == 0) { + const uint64_t required = + FIELD_V | FIELD_T | FIELD_EPOCH | FIELD_CODEC | FIELD_SAMPLE_RATE | + FIELD_CHANNELS | FIELD_FRAME_MS | FIELD_MAX_DURATION_MS | + FIELD_STARTUP_BUFFER_MS | FIELD_CREDITS; + if (fields != required || + message->interaction.audio.codec != + NEXTING_DEVICE_AUDIO_CONTROL_CODEC_IMA_ADPCM || + message->interaction.audio.sample_rate != 16000U || + message->interaction.audio.channels != 1U || + message->interaction.audio.frame_ms != 20U || + message->interaction.audio.max_duration_ms != 120000U || + message->interaction.audio.startup_buffer_ms != 200U || + message->interaction.audio.credits != 8U) + return false; + message->type = NEXTING_DEVICE_MESSAGE_AUDIO_CONFIG; + return true; + } + if (strcmp(type, "audio_begin") == 0) { + const uint64_t required = + FIELD_V | FIELD_T | FIELD_EPOCH | FIELD_STREAM_ID | FIELD_CODEC | + FIELD_SAMPLE_RATE | FIELD_CHANNELS | FIELD_FRAME_MS; + if (fields != required || + message->interaction.audio.codec != + NEXTING_DEVICE_AUDIO_CONTROL_CODEC_IMA_ADPCM || + message->interaction.audio.sample_rate != 16000U || + message->interaction.audio.channels != 1U || + message->interaction.audio.frame_ms != 20U) + return false; + message->type = NEXTING_DEVICE_MESSAGE_AUDIO_BEGIN; + return true; + } + if (strcmp(type, "audio_credit") == 0) { + const uint64_t required = FIELD_V | FIELD_T | FIELD_EPOCH | + FIELD_STREAM_ID | FIELD_ACK_SEQ | FIELD_CREDITS; + if (fields != required || message->interaction.audio.credits < 1U || + message->interaction.audio.credits > 32U) + return false; + message->type = NEXTING_DEVICE_MESSAGE_AUDIO_CREDIT; + return true; + } + if (strcmp(type, "audio_end") == 0) { + const uint64_t required = FIELD_V | FIELD_T | FIELD_EPOCH | + FIELD_STREAM_ID | FIELD_LAST_SEQ | + FIELD_SAMPLE_COUNT | FIELD_REASON; + if (fields != required || + message->interaction.audio.last_sequence >= 6000U || + message->interaction.audio.sample_count != + (message->interaction.audio.last_sequence + 1U) * 320U || + message->interaction.audio.end_reason == NEXTING_DEVICE_AUDIO_END_NONE) + return false; + message->type = NEXTING_DEVICE_MESSAGE_AUDIO_END; + return true; + } + if (strcmp(type, "audio_cancel") == 0) { + const uint64_t required = FIELD_V | FIELD_T | FIELD_EPOCH | + FIELD_STREAM_ID | FIELD_REASON; + if (fields != required || + message->interaction.audio.cancel_reason == + NEXTING_DEVICE_AUDIO_CANCEL_NONE) + return false; + message->type = NEXTING_DEVICE_MESSAGE_AUDIO_CANCEL; + return true; + } + if (strcmp(type, "audio_state") == 0) { + const uint64_t base = FIELD_V | FIELD_T | FIELD_EPOCH | FIELD_STATE; + const bool ready = message->interaction.audio.state == + NEXTING_DEVICE_AUDIO_STATE_READY; + if ((fields != base && fields != (base | FIELD_STREAM_ID)) || + message->interaction.audio.state == NEXTING_DEVICE_AUDIO_STATE_NONE || + (ready && (fields & FIELD_STREAM_ID) != 0U) || + (!ready && (fields & FIELD_STREAM_ID) == 0U)) + return false; + message->type = NEXTING_DEVICE_MESSAGE_AUDIO_STATE; + return true; + } return false; } @@ -1525,7 +1744,7 @@ nexting_device_decode(const char *wire, size_t wire_length, if (take(&parser, '}')) return NEXTING_DEVICE_BAD_MESSAGE; for (;;) { - char key[16]; + char key[24]; if (!parse_object_key(&parser, key, sizeof key) || !take(&parser, ':') || !parse_known_field(&parser, key, &fields, &version, type, sizeof type, &message)) @@ -1566,7 +1785,8 @@ enum { DI_FIELD_BATTERY_SERVICE = 1U << 18, DI_FIELD_DISPLAY = 1U << 19, DI_FIELD_HAPTICS = 1U << 20, - DI_FIELD_VENDOR = 1U << 21 + DI_FIELD_VENDOR = 1U << 21, + DI_FIELD_AUDIO = 1U << 22 }; static bool parse_boolean(parser_t *parser, bool *value) { @@ -1642,6 +1862,88 @@ static bool parse_haptics(parser_t *parser, nexting_device_info_t *info) { } } +static bool parse_audio_string_array(parser_t *parser, + const char *expected) { + char value[24]; + return take(parser, '[') && + parse_string(parser, value, sizeof value) && + strcmp(value, expected) == 0 && take(parser, ']'); +} + +static bool parse_audio_uint_array(parser_t *parser, uint64_t expected) { + uint64_t value = 0U; + return take(parser, '[') && parse_uint(parser, &value) && + value == expected && take(parser, ']'); +} + +static bool parse_audio_capabilities(parser_t *parser, + nexting_device_info_t *info) { + enum { + AUDIO_SOURCE = 1U << 0, + AUDIO_CODECS = 1U << 1, + AUDIO_SAMPLE_RATES = 1U << 2, + AUDIO_CHANNELS = 1U << 3, + AUDIO_FRAME_MS = 1U << 4, + AUDIO_MAX_DURATION = 1U << 5, + AUDIO_STARTUP_BUFFER = 1U << 6 + }; + uint8_t fields = 0U; + if (!take(parser, '{')) + return false; + for (;;) { + char key[24]; + uint8_t bit = 0U; + uint64_t number = 0U; + char value[24]; + if (!parse_object_key(parser, key, sizeof key) || !take(parser, ':')) + return false; + if (strcmp(key, "source") == 0) { + bit = AUDIO_SOURCE; + if (!parse_string(parser, value, sizeof value) || + strcmp(value, "device_microphone") != 0) + return false; + } else if (strcmp(key, "codecs") == 0) { + bit = AUDIO_CODECS; + if (!parse_audio_string_array(parser, "ima_adpcm")) + return false; + } else if (strcmp(key, "sample_rates") == 0) { + bit = AUDIO_SAMPLE_RATES; + if (!parse_audio_uint_array(parser, 16000U)) + return false; + } else if (strcmp(key, "channels") == 0) { + bit = AUDIO_CHANNELS; + if (!parse_uint(parser, &number) || number != 1U) + return false; + } else if (strcmp(key, "frame_ms") == 0) { + bit = AUDIO_FRAME_MS; + if (!parse_audio_uint_array(parser, 20U)) + return false; + } else if (strcmp(key, "max_duration_ms") == 0) { + bit = AUDIO_MAX_DURATION; + if (!parse_uint(parser, &number) || number != 120000U) + return false; + } else if (strcmp(key, "startup_buffer_ms") == 0) { + bit = AUDIO_STARTUP_BUFFER; + if (!parse_uint(parser, &number) || number != 200U) + return false; + } else { + return false; + } + if ((fields & bit) != 0U) + return false; + fields |= bit; + skip_space(parser); + if (take(parser, '}')) + break; + if (!take(parser, ',')) + return false; + } + if (fields != UINT8_C(0x7f)) + return false; + info->has_device_audio = true; + return true; +} + static bool parse_display(parser_t *parser, nexting_device_info_t *info) { bool has_type = false; bool has_width = false; @@ -1987,6 +2289,8 @@ static bool validate_device_info(nexting_device_info_t *info, info->status_slots > NEXTING_DEVICE_STATUS_MAX_AGENTS || (info->status_slots > 0U && !list_contains_profile(info, "status/1")) || + (info->has_device_audio != + list_contains_profile(info, "device-audio/1")) || (info->has_approval_button_count && info->has_button_count && info->approval_button_count > info->button_count) || (info->has_custom_button_count && info->has_button_count && @@ -2005,6 +2309,9 @@ static bool validate_device_info(nexting_device_info_t *info, info->supports_text_v1 = list_contains_profile(info, "text/1"); info->supports_usage_v1 = list_contains_profile(info, "usage/1"); info->supports_config_v1 = list_contains_profile(info, "config/1"); + info->supports_device_audio_v1 = + info->has_device_audio && + list_contains_profile(info, "device-audio/1"); return true; } @@ -2125,6 +2432,9 @@ static bool parse_device_info_field(parser_t *parser, const char *key, if (strcmp(key, "haptics") == 0) return CLAIM_INFO_FIELD(DI_FIELD_HAPTICS) && parse_haptics(parser, info); + if (strcmp(key, "audio") == 0) + return CLAIM_INFO_FIELD(DI_FIELD_AUDIO) && + parse_audio_capabilities(parser, info); if (strcmp(key, "vendor") == 0) { const size_t start = parser->position; nexting_device_info_t candidate = {0}; @@ -2422,6 +2732,59 @@ config_error_string(nexting_device_config_error_t error) { } } +static const char *audio_codec_string(nexting_device_audio_codec_t codec) { + return codec == NEXTING_DEVICE_AUDIO_CONTROL_CODEC_IMA_ADPCM ? "ima_adpcm" + : NULL; +} + +static const char * +audio_end_reason_string(nexting_device_audio_end_reason_t reason) { + if (reason == NEXTING_DEVICE_AUDIO_END_SUBMITTED) + return "submitted"; + if (reason == NEXTING_DEVICE_AUDIO_END_MAX_DURATION) + return "max_duration"; + return NULL; +} + +static const char * +audio_cancel_reason_string(nexting_device_audio_cancel_reason_t reason) { + switch (reason) { + case NEXTING_DEVICE_AUDIO_CANCEL_LOCAL: + return "local"; + case NEXTING_DEVICE_AUDIO_CANCEL_HOST: + return "host"; + case NEXTING_DEVICE_AUDIO_CANCEL_DISCONNECT: + return "disconnect"; + case NEXTING_DEVICE_AUDIO_CANCEL_TRANSPORT: + return "transport"; + case NEXTING_DEVICE_AUDIO_CANCEL_TIMEOUT: + return "timeout"; + case NEXTING_DEVICE_AUDIO_CANCEL_HARDWARE: + return "hardware"; + case NEXTING_DEVICE_AUDIO_CANCEL_REJECTED: + return "rejected"; + default: + return NULL; + } +} + +static const char *audio_state_string(nexting_device_audio_state_t state) { + switch (state) { + case NEXTING_DEVICE_AUDIO_STATE_READY: + return "ready"; + case NEXTING_DEVICE_AUDIO_STATE_RECEIVING: + return "receiving"; + case NEXTING_DEVICE_AUDIO_STATE_TRANSCRIBING: + return "transcribing"; + case NEXTING_DEVICE_AUDIO_STATE_SUBMITTED: + return "submitted"; + case NEXTING_DEVICE_AUDIO_STATE_ERROR: + return "error"; + default: + return NULL; + } +} + static bool valid_message_for_encode(const nexting_device_message_t *message) { if (message == NULL) return false; @@ -2592,6 +2955,44 @@ static bool valid_message_for_encode(const nexting_device_message_t *message) { (message->interaction.config_status == NEXTING_DEVICE_CONFIG_REJECTED && config_error_string(message->interaction.config_error) != NULL)); + case NEXTING_DEVICE_MESSAGE_AUDIO_CONFIG: + return !message->interaction.audio.has_stream_id && + audio_codec_string(message->interaction.audio.codec) != NULL && + message->interaction.audio.sample_rate == 16000U && + message->interaction.audio.channels == 1U && + message->interaction.audio.frame_ms == 20U && + message->interaction.audio.max_duration_ms == 120000U && + message->interaction.audio.startup_buffer_ms == 200U && + message->interaction.audio.credits == 8U; + case NEXTING_DEVICE_MESSAGE_AUDIO_BEGIN: + return message->interaction.audio.has_stream_id && + audio_codec_string(message->interaction.audio.codec) != NULL && + message->interaction.audio.sample_rate == 16000U && + message->interaction.audio.channels == 1U && + message->interaction.audio.frame_ms == 20U; + case NEXTING_DEVICE_MESSAGE_AUDIO_CREDIT: + return message->interaction.audio.has_stream_id && + message->interaction.audio.credits >= 1U && + message->interaction.audio.credits <= 32U; + case NEXTING_DEVICE_MESSAGE_AUDIO_END: + return message->interaction.audio.has_stream_id && + message->interaction.audio.last_sequence < 6000U && + message->interaction.audio.sample_count == + (message->interaction.audio.last_sequence + 1U) * 320U && + audio_end_reason_string(message->interaction.audio.end_reason) != + NULL; + case NEXTING_DEVICE_MESSAGE_AUDIO_CANCEL: + return message->interaction.audio.has_stream_id && + audio_cancel_reason_string( + message->interaction.audio.cancel_reason) != NULL; + case NEXTING_DEVICE_MESSAGE_AUDIO_STATE: + return audio_state_string(message->interaction.audio.state) != NULL && + ((message->interaction.audio.state == + NEXTING_DEVICE_AUDIO_STATE_READY && + !message->interaction.audio.has_stream_id) || + (message->interaction.audio.state != + NEXTING_DEVICE_AUDIO_STATE_READY && + message->interaction.audio.has_stream_id)); default: return false; } @@ -2914,6 +3315,101 @@ nexting_device_encode(const nexting_device_message_t *message, char *output, &writer, config_error_string(message->interaction.config_error)); } break; + case NEXTING_DEVICE_MESSAGE_AUDIO_CONFIG: + write_text(&writer, "{\"v\":1,\"t\":\"audio_config\",\"epoch\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.epoch); + write_text(&writer, number); + write_text(&writer, ",\"codec\":"); + write_json_string(&writer, + audio_codec_string(message->interaction.audio.codec)); + write_text(&writer, ",\"sample_rate\":16000,\"channels\":1,\"frame_ms\":20," + "\"max_duration_ms\":120000,\"startup_buffer_ms\":200," + "\"credits\":8"); + break; + case NEXTING_DEVICE_MESSAGE_AUDIO_BEGIN: + write_text(&writer, "{\"v\":1,\"t\":\"audio_begin\",\"epoch\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.epoch); + write_text(&writer, number); + write_text(&writer, ",\"stream_id\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.stream_id); + write_text(&writer, number); + write_text(&writer, ",\"codec\":"); + write_json_string(&writer, + audio_codec_string(message->interaction.audio.codec)); + write_text(&writer, + ",\"sample_rate\":16000,\"channels\":1,\"frame_ms\":20"); + break; + case NEXTING_DEVICE_MESSAGE_AUDIO_CREDIT: + write_text(&writer, "{\"v\":1,\"t\":\"audio_credit\",\"epoch\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.epoch); + write_text(&writer, number); + write_text(&writer, ",\"stream_id\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.stream_id); + write_text(&writer, number); + write_text(&writer, ",\"ack_seq\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.ack_sequence); + write_text(&writer, number); + write_text(&writer, ",\"credits\":"); + (void)snprintf(number, sizeof number, "%u", + (unsigned)message->interaction.audio.credits); + write_text(&writer, number); + break; + case NEXTING_DEVICE_MESSAGE_AUDIO_END: + write_text(&writer, "{\"v\":1,\"t\":\"audio_end\",\"epoch\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.epoch); + write_text(&writer, number); + write_text(&writer, ",\"stream_id\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.stream_id); + write_text(&writer, number); + write_text(&writer, ",\"last_seq\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.last_sequence); + write_text(&writer, number); + write_text(&writer, ",\"sample_count\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.sample_count); + write_text(&writer, number); + write_text(&writer, ",\"reason\":"); + write_json_string( + &writer, audio_end_reason_string(message->interaction.audio.end_reason)); + break; + case NEXTING_DEVICE_MESSAGE_AUDIO_CANCEL: + write_text(&writer, "{\"v\":1,\"t\":\"audio_cancel\",\"epoch\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.epoch); + write_text(&writer, number); + write_text(&writer, ",\"stream_id\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.stream_id); + write_text(&writer, number); + write_text(&writer, ",\"reason\":"); + write_json_string( + &writer, + audio_cancel_reason_string(message->interaction.audio.cancel_reason)); + break; + case NEXTING_DEVICE_MESSAGE_AUDIO_STATE: + write_text(&writer, "{\"v\":1,\"t\":\"audio_state\",\"epoch\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.epoch); + write_text(&writer, number); + if (message->interaction.audio.has_stream_id) { + write_text(&writer, ",\"stream_id\":"); + (void)snprintf(number, sizeof number, "%u", + message->interaction.audio.stream_id); + write_text(&writer, number); + } + write_text(&writer, ",\"state\":"); + write_json_string(&writer, + audio_state_string(message->interaction.audio.state)); + break; default: return NEXTING_DEVICE_BAD_MESSAGE; } diff --git a/devices/sdk/c/src/nexting_device_audio.c b/devices/sdk/c/src/nexting_device_audio.c new file mode 100644 index 0000000..9e964bc --- /dev/null +++ b/devices/sdk/c/src/nexting_device_audio.c @@ -0,0 +1,238 @@ +#include "nexting_device_audio.h" + +#include + +static const int16_t step_table[89] = { + 7, 8, 9, 10, 11, 12, 13, 14, 16, + 17, 19, 21, 23, 25, 28, 31, 34, 37, + 41, 45, 50, 55, 60, 66, 73, 80, 88, + 97, 107, 118, 130, 143, 157, 173, 190, 209, + 230, 253, 279, 307, 337, 371, 408, 449, 494, + 544, 598, 658, 724, 796, 876, 963, 1060, 1166, + 1282, 1411, 1552, 1707, 1878, 2066, 2272, 2499, 2749, + 3024, 3327, 3660, 4026, 4428, 4871, 5358, 5894, 6484, + 7132, 7845, 8630, 9493, 10442, 11487, 12635, 13899, 15289, + 16818, 18500, 20350, 22385, 24623, 27086, 29794, 32767}; +static const int8_t index_table[8] = {-1, -1, -1, -1, 2, 4, 6, 8}; + +static int32_t clamp_i32(int32_t value, int32_t minimum, int32_t maximum) { + if (value < minimum) + return minimum; + if (value > maximum) + return maximum; + return value; +} + +static int16_t apply_code(int16_t predictor, int16_t step, uint8_t code) { + int32_t difference = step >> 3; + int32_t next; + if ((code & 4U) != 0U) + difference += step; + if ((code & 2U) != 0U) + difference += step >> 1; + if ((code & 1U) != 0U) + difference += step >> 2; + next = (int32_t)predictor + (((code & 8U) != 0U) ? -difference : difference); + return (int16_t)clamp_i32(next, -32768, 32767); +} + +static void write_u16_le(uint8_t *output, uint16_t value) { + output[0] = (uint8_t)(value & 0xffU); + output[1] = (uint8_t)(value >> 8U); +} + +static void write_u32_le(uint8_t *output, uint32_t value) { + output[0] = (uint8_t)(value & 0xffU); + output[1] = (uint8_t)((value >> 8U) & 0xffU); + output[2] = (uint8_t)((value >> 16U) & 0xffU); + output[3] = (uint8_t)(value >> 24U); +} + +static uint16_t read_u16_le(const uint8_t *input) { + return (uint16_t)((uint16_t)input[0] | ((uint16_t)input[1] << 8U)); +} + +static uint32_t read_u32_le(const uint8_t *input) { + return (uint32_t)input[0] | ((uint32_t)input[1] << 8U) | + ((uint32_t)input[2] << 16U) | ((uint32_t)input[3] << 24U); +} + +uint32_t nexting_device_crc32c(const uint8_t *bytes, size_t length) { + uint32_t crc = UINT32_MAX; + if (bytes == NULL && length != 0U) + return 0U; + for (size_t index = 0; index < length; ++index) { + crc ^= bytes[index]; + for (uint8_t bit = 0; bit < 8U; ++bit) + crc = (crc >> 1U) ^ (((crc & 1U) != 0U) ? UINT32_C(0x82f63b78) : 0U); + } + return crc ^ UINT32_MAX; +} + +nexting_device_audio_result_t nexting_device_audio_encode_block( + const int16_t *samples, size_t sample_count, uint8_t step_index, + int16_t *predictor, uint8_t *payload, size_t payload_capacity) { + int16_t current_predictor; + int32_t current_index; + if (samples == NULL || predictor == NULL || payload == NULL || + sample_count != NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME || + step_index > 88U) + return NEXTING_DEVICE_AUDIO_BAD_ARGUMENT; + if (payload_capacity < NEXTING_DEVICE_AUDIO_PAYLOAD_BYTES) + return NEXTING_DEVICE_AUDIO_BUFFER_TOO_SMALL; + + memset(payload, 0, NEXTING_DEVICE_AUDIO_PAYLOAD_BYTES); + *predictor = samples[0]; + current_predictor = samples[0]; + current_index = step_index; + for (size_t sample_index = 1U; sample_index < sample_count; ++sample_index) { + const int16_t step = step_table[current_index]; + int32_t difference = (int32_t)samples[sample_index] - current_predictor; + int32_t threshold = step; + uint8_t code = 0U; + const size_t code_index = sample_index - 1U; + const size_t byte_index = code_index >> 1U; + if (difference < 0) { + code = 8U; + difference = -difference; + } + if (difference >= threshold) { + code |= 4U; + difference -= threshold; + } + threshold >>= 1; + if (difference >= threshold) { + code |= 2U; + difference -= threshold; + } + threshold >>= 1; + if (difference >= threshold) + code |= 1U; + + current_predictor = apply_code(current_predictor, step, code); + current_index = clamp_i32(current_index + index_table[code & 7U], 0, 88); + if ((code_index & 1U) == 0U) + payload[byte_index] = code; + else + payload[byte_index] |= (uint8_t)(code << 4U); + } + return NEXTING_DEVICE_AUDIO_OK; +} + +nexting_device_audio_result_t nexting_device_audio_decode_block( + int16_t predictor, uint8_t step_index, const uint8_t *payload, + size_t payload_length, size_t sample_count, int16_t *samples, + size_t sample_capacity) { + int16_t current_predictor = predictor; + int32_t current_index = step_index; + if (payload == NULL || samples == NULL || sample_count < 1U || + sample_count > NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME || + step_index > 88U) + return NEXTING_DEVICE_AUDIO_BAD_ARGUMENT; + const size_t expected_payload = sample_count / 2U; + if (payload_length != expected_payload) + return NEXTING_DEVICE_AUDIO_BAD_ARGUMENT; + if (sample_capacity < sample_count) + return NEXTING_DEVICE_AUDIO_BUFFER_TOO_SMALL; + if (((sample_count - 1U) & 1U) != 0U && + (payload[payload_length - 1U] & 0xf0U) != 0U) + return NEXTING_DEVICE_AUDIO_BAD_FRAME; + + samples[0] = predictor; + for (size_t sample_index = 1U; sample_index < sample_count; ++sample_index) { + const size_t code_index = sample_index - 1U; + const uint8_t packed = payload[code_index >> 1U]; + const uint8_t code = ((code_index & 1U) == 0U) + ? (uint8_t)(packed & 0x0fU) + : (uint8_t)(packed >> 4U); + current_predictor = + apply_code(current_predictor, step_table[current_index], code); + current_index = clamp_i32(current_index + index_table[code & 7U], 0, 88); + samples[sample_index] = current_predictor; + } + return NEXTING_DEVICE_AUDIO_OK; +} + +nexting_device_audio_result_t nexting_device_audio_encode_frame( + uint32_t epoch, uint32_t stream_id, uint32_t sequence, + uint32_t sample_index, const int16_t *samples, size_t sample_count, + uint8_t step_index, uint8_t *output, size_t output_capacity, + size_t *output_length) { + int16_t predictor; + nexting_device_audio_result_t result; + if (samples == NULL || output == NULL || output_length == NULL || + sample_count != NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME || + step_index > 88U || sequence >= NEXTING_DEVICE_AUDIO_MAX_FRAMES || + sample_index != sequence * NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME) + return NEXTING_DEVICE_AUDIO_BAD_ARGUMENT; + if (output_capacity < NEXTING_DEVICE_AUDIO_FRAME_BYTES) + return NEXTING_DEVICE_AUDIO_BUFFER_TOO_SMALL; + + memset(output, 0, NEXTING_DEVICE_AUDIO_FRAME_BYTES); + output[0] = NEXTING_DEVICE_AUDIO_WIRE_VERSION; + output[1] = 1U; + output[2] = NEXTING_DEVICE_AUDIO_CODEC_IMA_ADPCM; + write_u32_le(output + 4U, epoch); + write_u32_le(output + 8U, stream_id); + write_u32_le(output + 12U, sequence); + write_u32_le(output + 16U, sample_index); + write_u16_le(output + 20U, (uint16_t)sample_count); + output[24] = step_index; + write_u16_le(output + 26U, NEXTING_DEVICE_AUDIO_PAYLOAD_BYTES); + result = nexting_device_audio_encode_block( + samples, sample_count, step_index, &predictor, + output + NEXTING_DEVICE_AUDIO_HEADER_BYTES, + output_capacity - NEXTING_DEVICE_AUDIO_HEADER_BYTES); + if (result != NEXTING_DEVICE_AUDIO_OK) + return result; + write_u16_le(output + 22U, (uint16_t)predictor); + write_u32_le(output + NEXTING_DEVICE_AUDIO_FRAME_BYTES - 4U, + nexting_device_crc32c(output, + NEXTING_DEVICE_AUDIO_FRAME_BYTES - 4U)); + *output_length = NEXTING_DEVICE_AUDIO_FRAME_BYTES; + return NEXTING_DEVICE_AUDIO_OK; +} + +nexting_device_audio_result_t nexting_device_audio_decode_frame( + const uint8_t *wire, size_t wire_length, + nexting_device_audio_frame_t *output) { + nexting_device_audio_result_t result; + if (wire == NULL || output == NULL) + return NEXTING_DEVICE_AUDIO_BAD_ARGUMENT; + if (wire_length != NEXTING_DEVICE_AUDIO_FRAME_BYTES || + wire[0] != NEXTING_DEVICE_AUDIO_WIRE_VERSION || wire[1] != 1U || + wire[2] != NEXTING_DEVICE_AUDIO_CODEC_IMA_ADPCM || wire[3] != 0U || + read_u16_le(wire + 20U) != NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME || + wire[24] > 88U || wire[25] != 0U || + read_u16_le(wire + 26U) != NEXTING_DEVICE_AUDIO_PAYLOAD_BYTES || + read_u32_le(wire + 12U) >= NEXTING_DEVICE_AUDIO_MAX_FRAMES || + read_u32_le(wire + 16U) != + read_u32_le(wire + 12U) * NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME || + (wire[NEXTING_DEVICE_AUDIO_FRAME_BYTES - 5U] & 0xf0U) != 0U || + read_u32_le(wire + NEXTING_DEVICE_AUDIO_FRAME_BYTES - 4U) != + nexting_device_crc32c(wire, + NEXTING_DEVICE_AUDIO_FRAME_BYTES - 4U)) + return NEXTING_DEVICE_AUDIO_BAD_FRAME; + + memset(output, 0, sizeof(*output)); + output->epoch = read_u32_le(wire + 4U); + output->stream_id = read_u32_le(wire + 8U); + output->sequence = read_u32_le(wire + 12U); + output->sample_index = read_u32_le(wire + 16U); + output->sample_count = read_u16_le(wire + 20U); + { + const uint16_t raw_predictor = read_u16_le(wire + 22U); + const int32_t signed_predictor = + raw_predictor <= INT16_MAX ? (int32_t)raw_predictor + : (int32_t)raw_predictor - 65536; + output->predictor = (int16_t)signed_predictor; + } + output->step_index = wire[24]; + result = nexting_device_audio_decode_block( + output->predictor, output->step_index, + wire + NEXTING_DEVICE_AUDIO_HEADER_BYTES, + NEXTING_DEVICE_AUDIO_PAYLOAD_BYTES, output->sample_count, output->samples, + NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME); + return result == NEXTING_DEVICE_AUDIO_OK ? NEXTING_DEVICE_AUDIO_OK + : NEXTING_DEVICE_AUDIO_BAD_FRAME; +} diff --git a/devices/sdk/c/tests/generate_interaction_vectors.mjs b/devices/sdk/c/tests/generate_interaction_vectors.mjs index 26a7b55..a3d70e6 100644 --- a/devices/sdk/c/tests/generate_interaction_vectors.mjs +++ b/devices/sdk/c/tests/generate_interaction_vectors.mjs @@ -4,7 +4,15 @@ import { join } from "node:path"; const [, , vectorDirectory, outputPath] = process.argv; if (!vectorDirectory || !outputPath) process.exit(2); -const profiles = ["navigation", "keys", "rotary", "voice", "text", "usage", "config"]; +const profiles = [ + "navigation", + "keys", + "rotary", + "voice", + "text", + "usage", + "config", +]; const valid = []; const invalid = []; for (const profile of profiles) { diff --git a/devices/sdk/c/tests/generate_vectors.mjs b/devices/sdk/c/tests/generate_vectors.mjs index 0ed95ba..19d5be6 100644 --- a/devices/sdk/c/tests/generate_vectors.mjs +++ b/devices/sdk/c/tests/generate_vectors.mjs @@ -4,7 +4,8 @@ const [, , inputPath, outputPath] = process.argv; if (!inputPath || !outputPath) process.exit(2); const vectors = JSON.parse(readFileSync(inputPath, "utf8")); -const invalidVectors = vectors.invalid ?? vectors.invalidCore.map(({ wire }) => wire); +const invalidVectors = + vectors.invalid ?? vectors.invalidCore.map(({ wire }) => wire); function emitCString(value) { const fragments = []; @@ -49,12 +50,18 @@ function emitArray(name, values) { ].join("\n"); } -writeFileSync(outputPath, [ - "#ifndef NEXTING_DEVICE_GENERATED_VECTORS_H", - "#define NEXTING_DEVICE_GENERATED_VECTORS_H", - "#include ", - emitArray("nexting_device_valid_vectors", vectors.valid.map(({ wire }) => wire)), - emitArray("nexting_device_invalid_vectors", invalidVectors), - "#endif", - "", -].join("\n")); +writeFileSync( + outputPath, + [ + "#ifndef NEXTING_DEVICE_GENERATED_VECTORS_H", + "#define NEXTING_DEVICE_GENERATED_VECTORS_H", + "#include ", + emitArray( + "nexting_device_valid_vectors", + vectors.valid.map(({ wire }) => wire), + ), + emitArray("nexting_device_invalid_vectors", invalidVectors), + "#endif", + "", + ].join("\n"), +); diff --git a/devices/sdk/c/tests/test_audio.c b/devices/sdk/c/tests/test_audio.c new file mode 100644 index 0000000..2382c07 --- /dev/null +++ b/devices/sdk/c/tests/test_audio.c @@ -0,0 +1,241 @@ +#include "nexting_device.h" +#include "nexting_device_audio.h" + +#include +#include +#include +#include + +static uint8_t hex_nibble(char value) { + if (value >= '0' && value <= '9') + return (uint8_t)(value - '0'); + if (value >= 'a' && value <= 'f') + return (uint8_t)(value - 'a' + 10); + return (uint8_t)(value - 'A' + 10); +} + +static size_t decode_hex(const char *hex, uint8_t *output, size_t capacity) { + const size_t length = strlen(hex) / 2U; + assert(length <= capacity); + for (size_t index = 0; index < length; ++index) { + output[index] = (uint8_t)((hex_nibble(hex[index * 2U]) << 4U) | + hex_nibble(hex[index * 2U + 1U])); + } + return length; +} + +static void test_crc32c(void) { + static const uint8_t check[] = "123456789"; + assert(nexting_device_crc32c(check, sizeof(check) - 1U) == UINT32_C(0xe3069283)); +} + +static void test_zero_golden_frame(void) { + static const char *golden_hex = + "0101010044332211ddccbbaa0000000000000000400100000000a00000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "0000000000000000000000000000000000000000000000000000000000000000" + "00000000000000000000000000000000000000000000000000000000c78c4635"; + int16_t samples[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME] = {0}; + uint8_t encoded[NEXTING_DEVICE_AUDIO_FRAME_BYTES]; + uint8_t expected[NEXTING_DEVICE_AUDIO_FRAME_BYTES]; + nexting_device_audio_frame_t decoded; + size_t encoded_length = 0U; + + assert(decode_hex(golden_hex, expected, sizeof(expected)) == sizeof(expected)); + assert(nexting_device_audio_encode_frame( + UINT32_C(0x11223344), UINT32_C(0xaabbccdd), 0U, 0U, samples, + NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME, 0U, encoded, + sizeof(encoded), &encoded_length) == NEXTING_DEVICE_AUDIO_OK); + assert(encoded_length == sizeof(encoded)); + assert(memcmp(encoded, expected, sizeof(encoded)) == 0); + + assert(nexting_device_audio_decode_frame(encoded, sizeof(encoded), &decoded) == + NEXTING_DEVICE_AUDIO_OK); + assert(decoded.epoch == UINT32_C(0x11223344)); + assert(decoded.stream_id == UINT32_C(0xaabbccdd)); + assert(decoded.sequence == 0U); + assert(decoded.sample_index == 0U); + assert(decoded.predictor == 0); + assert(decoded.step_index == 0U); + for (size_t index = 0; index < NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME; ++index) + assert(decoded.samples[index] == 0); +} + +static void test_ramp_round_trip(void) { + int16_t samples[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME]; + uint8_t payload[NEXTING_DEVICE_AUDIO_PAYLOAD_BYTES]; + int16_t decoded[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME]; + int16_t predictor = 0; + for (size_t index = 0; index < NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME; ++index) + samples[index] = (int16_t)((int32_t)index * 97 - 15000); + + assert(nexting_device_audio_encode_block( + samples, NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME, 7U, &predictor, + payload, sizeof(payload)) == NEXTING_DEVICE_AUDIO_OK); + assert(predictor == samples[0]); + assert((payload[sizeof(payload) - 1U] & 0xf0U) == 0U); + assert(nexting_device_audio_decode_block( + predictor, 7U, payload, sizeof(payload), + NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME, decoded, + NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME) == NEXTING_DEVICE_AUDIO_OK); + assert(decoded[0] == samples[0]); + for (size_t index = 0; index < NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME; ++index) { + int32_t error = (int32_t)decoded[index] - samples[index]; + if (error < 0) + error = -error; + assert(error < 4000); + } +} + +static void test_invalid_frames_fail_closed(void) { + int16_t samples[NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME] = {0}; + uint8_t frame[NEXTING_DEVICE_AUDIO_FRAME_BYTES]; + nexting_device_audio_frame_t decoded; + size_t length = 0U; + assert(nexting_device_audio_encode_frame( + 1U, 2U, 1U, 0U, samples, + NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME, 0U, frame, sizeof(frame), + &length) == NEXTING_DEVICE_AUDIO_BAD_ARGUMENT); + assert(nexting_device_audio_encode_frame( + 1U, 2U, 6000U, 1920000U, samples, + NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME, 0U, frame, sizeof(frame), + &length) == NEXTING_DEVICE_AUDIO_BAD_ARGUMENT); + assert(nexting_device_audio_encode_frame( + 1U, 2U, 0U, 0U, samples, NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME, + 0U, frame, sizeof(frame), &length) == NEXTING_DEVICE_AUDIO_OK); + assert(nexting_device_audio_decode_frame(frame, length - 1U, &decoded) == + NEXTING_DEVICE_AUDIO_BAD_FRAME); + frame[24] = 89U; + { + const uint32_t crc = nexting_device_crc32c(frame, length - 4U); + frame[length - 4U] = (uint8_t)crc; + frame[length - 3U] = (uint8_t)(crc >> 8U); + frame[length - 2U] = (uint8_t)(crc >> 16U); + frame[length - 1U] = (uint8_t)(crc >> 24U); + } + assert(nexting_device_audio_decode_frame(frame, length, &decoded) == + NEXTING_DEVICE_AUDIO_BAD_FRAME); + frame[24] = 0U; + { + const uint32_t crc = nexting_device_crc32c(frame, length - 4U); + frame[length - 4U] = (uint8_t)crc; + frame[length - 3U] = (uint8_t)(crc >> 8U); + frame[length - 2U] = (uint8_t)(crc >> 16U); + frame[length - 1U] = (uint8_t)(crc >> 24U); + } + frame[length - 1U] ^= 1U; + assert(nexting_device_audio_decode_frame(frame, length, &decoded) == + NEXTING_DEVICE_AUDIO_BAD_FRAME); + + assert(nexting_device_audio_encode_frame( + 1U, 2U, 0U, 0U, samples, NEXTING_DEVICE_AUDIO_SAMPLES_PER_FRAME, + 0U, frame, sizeof(frame), &length) == NEXTING_DEVICE_AUDIO_OK); + frame[16] = 1U; + { + const uint32_t crc = nexting_device_crc32c(frame, length - 4U); + frame[length - 4U] = (uint8_t)crc; + frame[length - 3U] = (uint8_t)(crc >> 8U); + frame[length - 2U] = (uint8_t)(crc >> 16U); + frame[length - 1U] = (uint8_t)(crc >> 24U); + } + assert(nexting_device_audio_decode_frame(frame, length, &decoded) == + NEXTING_DEVICE_AUDIO_BAD_FRAME); +} + +static void assert_control_round_trip(const char *wire, + nexting_device_message_type_t type) { + nexting_device_message_t message = {0}; + char encoded[NEXTING_DEVICE_DEFAULT_MAX_MESSAGE_BYTES]; + size_t encoded_length = 0U; + assert(nexting_device_decode(wire, strlen(wire), &message) == + NEXTING_DEVICE_OK); + assert(message.type == type); + assert(nexting_device_encode(&message, encoded, sizeof(encoded), + &encoded_length) == NEXTING_DEVICE_OK); + assert(encoded_length == strlen(wire)); + assert(memcmp(encoded, wire, encoded_length) == 0); +} + +static void test_audio_control_messages(void) { + assert_control_round_trip( + "{\"v\":1,\"t\":\"audio_config\",\"epoch\":287454020,\"codec\":" + "\"ima_adpcm\",\"sample_rate\":16000,\"channels\":1,\"frame_ms\":20," + "\"max_duration_ms\":120000,\"startup_buffer_ms\":200,\"credits\":8}\n", + NEXTING_DEVICE_MESSAGE_AUDIO_CONFIG); + assert_control_round_trip( + "{\"v\":1,\"t\":\"audio_begin\",\"epoch\":287454020,\"stream_id\":" + "2864434397,\"codec\":\"ima_adpcm\",\"sample_rate\":16000," + "\"channels\":1,\"frame_ms\":20}\n", + NEXTING_DEVICE_MESSAGE_AUDIO_BEGIN); + assert_control_round_trip( + "{\"v\":1,\"t\":\"audio_credit\",\"epoch\":287454020,\"stream_id\":" + "2864434397,\"ack_seq\":7,\"credits\":4}\n", + NEXTING_DEVICE_MESSAGE_AUDIO_CREDIT); + assert_control_round_trip( + "{\"v\":1,\"t\":\"audio_end\",\"epoch\":287454020,\"stream_id\":" + "2864434397,\"last_seq\":49,\"sample_count\":16000,\"reason\":" + "\"submitted\"}\n", + NEXTING_DEVICE_MESSAGE_AUDIO_END); + assert_control_round_trip( + "{\"v\":1,\"t\":\"audio_cancel\",\"epoch\":287454020,\"stream_id\":" + "2864434397,\"reason\":\"rejected\"}\n", + NEXTING_DEVICE_MESSAGE_AUDIO_CANCEL); + assert_control_round_trip( + "{\"v\":1,\"t\":\"audio_state\",\"epoch\":287454020,\"stream_id\":" + "2864434397,\"state\":\"transcribing\"}\n", + NEXTING_DEVICE_MESSAGE_AUDIO_STATE); + assert_control_round_trip( + "{\"v\":1,\"t\":\"audio_state\",\"epoch\":287454020,\"state\":" + "\"ready\"}\n", + NEXTING_DEVICE_MESSAGE_AUDIO_STATE); + + nexting_device_message_t invalid = {0}; + static const char remote_start[] = + "{\"v\":1,\"t\":\"audio_begin\",\"epoch\":1,\"stream_id\":1," + "\"codec\":\"ima_adpcm\",\"sample_rate\":16000,\"channels\":1," + "\"frame_ms\":20,\"remote_start\":true}\n"; + assert(nexting_device_decode(remote_start, strlen(remote_start), &invalid) == + NEXTING_DEVICE_BAD_MESSAGE); + static const char inconsistent_end[] = + "{\"v\":1,\"t\":\"audio_end\",\"epoch\":1,\"stream_id\":1," + "\"last_seq\":1,\"sample_count\":320,\"reason\":\"submitted\"}\n"; + assert(nexting_device_decode(inconsistent_end, strlen(inconsistent_end), + &invalid) == NEXTING_DEVICE_BAD_MESSAGE); +} + +static void test_device_info_audio_capability(void) { + static const char valid[] = + "{\"protocol\":\"nexting-device\",\"spec\":\"0.4.0-experimental.1\"," + "\"wire\":[1],\"profiles\":[\"approval/1\",\"device-audio/1\"]," + "\"model\":\"audio-reference\",\"fw\":\"0.3.0\"," + "\"max_message_bytes\":4096,\"max_summary_bytes\":240,\"audio\":{" + "\"source\":\"device_microphone\",\"codecs\":[\"ima_adpcm\"]," + "\"sample_rates\":[16000],\"channels\":1,\"frame_ms\":[20]," + "\"max_duration_ms\":120000,\"startup_buffer_ms\":200}}"; + static const char missing_capability[] = + "{\"protocol\":\"nexting-device\",\"spec\":\"0.4.0-experimental.1\"," + "\"wire\":[1],\"profiles\":[\"approval/1\",\"device-audio/1\"]," + "\"model\":\"audio-reference\",\"fw\":\"0.3.0\"," + "\"max_message_bytes\":4096,\"max_summary_bytes\":240}"; + nexting_device_info_t info = {0}; + assert(nexting_device_info_decode(valid, sizeof(valid) - 1U, &info) == + NEXTING_DEVICE_OK); + assert(info.has_device_audio); + assert(info.supports_device_audio_v1); + assert(nexting_device_info_decode(missing_capability, + sizeof(missing_capability) - 1U, + &info) == NEXTING_DEVICE_BAD_MESSAGE); +} + +int main(void) { + test_crc32c(); + test_zero_golden_frame(); + test_ramp_round_trip(); + test_invalid_frames_fail_closed(); + test_audio_control_messages(); + test_device_info_audio_capability(); + puts("audio tests passed"); + return 0; +} diff --git a/devices/simulator/device-audio-simulator.mjs b/devices/simulator/device-audio-simulator.mjs new file mode 100644 index 0000000..817ffaa --- /dev/null +++ b/devices/simulator/device-audio-simulator.mjs @@ -0,0 +1,293 @@ +const INITIAL_CREDITS = 8; +const MAX_QUEUE_FRAMES = 10; +const CREDIT_STALL_MS = 500; +const HOLD_THRESHOLD_MS = 450; +const MAX_DURATION_MS = 120_000; +const MAX_FRAMES = 6000; + +export class DeviceAudioSimulator { + constructor({ now = () => Date.now() } = {}) { + this.now = now; + this.state = "unavailable"; + this.events = []; + this.sentFrames = []; + this.frameQueue = []; + this.credits = 0; + this.sequence = 0; + this.epoch = null; + this.streamId = 0; + this.captureStartedAt = null; + this.creditStallStartedAt = null; + this.lastAckSequence = -1; + this.startupBufferMs = 200; + } + + get queuedFrames() { + return this.frameQueue.length; + } + + get audioInRamBytes() { + return this.frameQueue.reduce( + (total, record) => total + record.bytes.length, + 0, + ); + } + + configure({ authorized, encrypted, attPayload, epoch }) { + this.cancelActive("host", false); + if ( + authorized !== true || + encrypted !== true || + !Number.isInteger(attPayload) || + attPayload < 64 || + !Number.isInteger(epoch) || + epoch < 0 || + epoch > 0xffff_ffff + ) { + this.state = "unavailable"; + return false; + } + this.epoch = epoch; + this.credits = INITIAL_CREDITS; + this.state = "ready"; + return true; + } + + handleHostControl(message) { + if ( + message?.type === "audioCancel" && + message.epoch === this.epoch && + message.streamId === this.streamId && + this.isActive() + ) { + this.cancelActive("host"); + return true; + } + return false; + } + + physicalVoiceDown() { + if (this.state === "latchedRecording") { + this.drain("submitted"); + return true; + } + if (this.state !== "ready") return false; + this.streamId = (this.streamId + 1) >>> 0; + this.captureStartedAt = this.now(); + this.sequence = 0; + this.credits = INITIAL_CREDITS; + this.sentFrames = []; + this.frameQueue = []; + this.creditStallStartedAt = null; + this.lastAckSequence = -1; + this.state = "capturing"; + this.events.push({ + type: "audioBegin", + epoch: this.epoch, + streamId: this.streamId, + localPhysicalInput: true, + }); + return true; + } + + physicalVoiceUp() { + if (this.state !== "capturing") return false; + const heldMs = this.now() - this.captureStartedAt; + if (heldMs >= HOLD_THRESHOLD_MS) this.drain("submitted"); + else this.state = "latchedRecording"; + return true; + } + + localSubmit() { + if (!this.isCapturing()) return false; + this.drain("submitted"); + return true; + } + + localReject() { + if (!this.isActive()) return false; + this.cancelActive("rejected"); + return true; + } + + disconnect() { + const active = this.isActive(); + this.cancelActive("disconnect", active, "unavailable"); + this.state = "unavailable"; + } + + captureFrame(frame) { + if (!this.isCapturing() || !(frame instanceof Uint8Array)) return false; + if (this.sequence >= MAX_FRAMES) { + this.drain("max_duration"); + return false; + } + const record = { sequence: this.sequence, bytes: frame.slice() }; + this.sequence = (this.sequence + 1) >>> 0; + if (this.credits > 0) { + this.sendRecord(record); + return true; + } + if (this.frameQueue.length >= MAX_QUEUE_FRAMES) { + this.cancelActive("transport"); + return false; + } + this.frameQueue.push(record); + if (this.creditStallStartedAt === null) this.creditStallStartedAt = this.now(); + return true; + } + + grantCredit({ epoch, streamId, ackSequence, credits }) { + const highestSentSequence = this.sentFrames.at(-1)?.sequence ?? -1; + if ( + !this.isActive() || + epoch !== this.epoch || + streamId !== this.streamId || + !Number.isInteger(ackSequence) || + ackSequence <= this.lastAckSequence || + ackSequence > highestSentSequence || + !Number.isInteger(credits) || + credits < 1 || + credits > 32 || + this.credits + credits > INITIAL_CREDITS + ) { + return false; + } + this.lastAckSequence = ackSequence; + this.credits += credits; + while (this.credits > 0 && this.frameQueue.length > 0) { + this.sendRecord(this.frameQueue.shift()); + } + this.creditStallStartedAt = + this.frameQueue.length === 0 ? null : this.now(); + if (this.state === "draining" && this.frameQueue.length === 0) { + this.finishDrain(this.pendingEndReason); + } + return true; + } + + tick() { + if ( + this.isCapturing() && + this.captureStartedAt !== null && + this.now() - this.captureStartedAt >= MAX_DURATION_MS + ) { + this.drain("max_duration"); + return; + } + if ( + this.isActive() && + this.creditStallStartedAt !== null && + this.now() - this.creditStallStartedAt >= CREDIT_STALL_MS + ) { + this.cancelActive("timeout"); + } + } + + sendRecord(record) { + this.credits -= 1; + this.sentFrames.push({ + sequence: record.sequence, + byteLength: record.bytes.length, + }); + record.bytes.fill(0); + } + + drain(reason) { + if (!this.isCapturing()) return; + this.state = "draining"; + this.pendingEndReason = reason; + if (this.frameQueue.length === 0) this.finishDrain(reason); + } + + finishDrain(reason) { + this.state = "waitingResult"; + this.events.push({ + type: "audioEnd", + epoch: this.epoch, + streamId: this.streamId, + lastSequence: Math.max(0, this.sequence - 1), + sampleCount: this.sequence * 320, + reason, + }); + this.clearRam(); + } + + cancelActive(reason, emit = true, finalState = "ready") { + if (emit) { + this.events.push({ + type: "audioCancel", + epoch: this.epoch, + streamId: this.streamId, + reason, + }); + } + this.clearRam(); + this.captureStartedAt = null; + this.state = finalState; + } + + clearRam() { + for (const record of this.frameQueue) record.bytes.fill(0); + this.frameQueue = []; + this.creditStallStartedAt = null; + } + + isCapturing() { + return this.state === "capturing" || this.state === "latchedRecording"; + } + + isActive() { + return this.isCapturing() || this.state === "draining"; + } +} + +export class DeviceAudioReceiver { + constructor({ maxFrames = MAX_FRAMES } = {}) { + if (!Number.isInteger(maxFrames) || maxFrames < 1 || maxFrames > MAX_FRAMES) { + throw new RangeError("maxFrames must represent at most 120 seconds"); + } + this.maxFrames = maxFrames; + this.expectedSequence = 0; + this.missingFrames = 0; + this.samples = []; + this.terminated = false; + } + + accept(frame) { + if ( + this.terminated || + !Number.isInteger(frame?.sequence) || + frame.sequence < 0 || + !(frame.samples instanceof Int16Array) || + frame.samples.length !== 320 + ) { + return { accepted: false, terminated: this.terminated, reason: "bad_frame" }; + } + if (frame.sequence < this.expectedSequence) { + return { accepted: false, duplicate: true }; + } + if (frame.sequence >= this.maxFrames) { + this.terminate(); + return { accepted: false, terminated: true, reason: "max_duration" }; + } + const gap = frame.sequence - this.expectedSequence; + if (gap >= 3 || this.missingFrames + gap >= 10) { + this.terminate(); + return { accepted: false, terminated: true, reason: "missing_frames" }; + } + for (let missing = 0; missing < gap; missing += 1) { + this.samples.push(...new Int16Array(320)); + } + this.missingFrames += gap; + this.samples.push(...frame.samples); + this.expectedSequence = frame.sequence + 1; + return { accepted: true, insertedSilenceFrames: gap }; + } + + terminate() { + this.samples.fill(0); + this.samples = []; + this.terminated = true; + } +} diff --git a/devices/simulator/device-audio-simulator.test.mjs b/devices/simulator/device-audio-simulator.test.mjs new file mode 100644 index 0000000..df5e660 --- /dev/null +++ b/devices/simulator/device-audio-simulator.test.mjs @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DeviceAudioReceiver, + DeviceAudioSimulator, +} from "./device-audio-simulator.mjs"; + +function readyDevice() { + let now = 0; + const device = new DeviceAudioSimulator({ now: () => now }); + device.configure({ authorized: true, encrypted: true, attPayload: 64, epoch: 7 }); + return { + device, + setNow(value) { + now = value; + }, + }; +} + +test("Host configuration prepares reception but cannot open the microphone", () => { + const { device } = readyDevice(); + assert.equal(device.state, "ready"); + assert.equal(device.handleHostControl({ type: "audioConfig" }), false); + assert.equal(device.state, "ready"); + assert.equal(device.audioInRamBytes, 0); +}); + +test("late Host cancellation cannot affect a different stream", () => { + const { device } = readyDevice(); + device.physicalVoiceDown(); + assert.equal( + device.handleHostControl({ + type: "audioCancel", + epoch: 7, + streamId: device.streamId + 1, + }), + false, + ); + assert.equal(device.state, "capturing"); + assert.equal( + device.handleHostControl({ + type: "audioCancel", + epoch: 7, + streamId: device.streamId, + }), + true, + ); + assert.equal(device.state, "ready"); +}); + +test("physical short press latches and the next press submits", () => { + const { device, setNow } = readyDevice(); + assert.equal(device.physicalVoiceDown(), true); + assert.equal(device.state, "capturing"); + setNow(200); + device.physicalVoiceUp(); + assert.equal(device.state, "latchedRecording"); + assert.equal(device.physicalVoiceDown(), true); + assert.equal(device.state, "waitingResult"); + assert.equal(device.events.at(-1).type, "audioEnd"); +}); + +test("physical hold, Submit, Reject, disconnect, and duration limit are deterministic", () => { + const held = readyDevice(); + held.device.physicalVoiceDown(); + held.setNow(450); + held.device.physicalVoiceUp(); + assert.equal(held.device.state, "waitingResult"); + + const submitted = readyDevice(); + submitted.device.physicalVoiceDown(); + submitted.device.localSubmit(); + assert.equal(submitted.device.state, "waitingResult"); + + for (const action of ["localReject", "disconnect"]) { + const fixture = readyDevice(); + fixture.device.physicalVoiceDown(); + fixture.device.captureFrame(new Uint8Array(192)); + fixture.device[action](); + assert.equal(fixture.device.state, action === "disconnect" ? "unavailable" : "ready"); + assert.equal(fixture.device.audioInRamBytes, 0); + assert.equal(fixture.device.events.at(-1).type, "audioCancel"); + } + + const limited = readyDevice(); + limited.device.physicalVoiceDown(); + limited.setNow(120_000); + limited.device.tick(); + assert.equal(limited.device.state, "waitingResult"); + assert.equal(limited.device.events.at(-1).reason, "max_duration"); +}); + +test("eight credits and a ten-frame RAM queue fail closed on overflow", () => { + const { device } = readyDevice(); + device.physicalVoiceDown(); + for (let index = 0; index < 8; index += 1) { + assert.equal(device.captureFrame(new Uint8Array(192).fill(index)), true); + } + assert.equal(device.sentFrames.length, 8); + assert.equal(Object.hasOwn(device.sentFrames[0], "bytes"), false); + for (let index = 0; index < 10; index += 1) { + assert.equal(device.captureFrame(new Uint8Array(192).fill(index + 8)), true); + } + assert.equal(device.queuedFrames, 10); + assert.equal(device.captureFrame(new Uint8Array(192)), false); + assert.equal(device.state, "ready"); + assert.equal(device.audioInRamBytes, 0); + assert.equal(device.events.at(-1).reason, "transport"); +}); + +test("credits drain queued frames and a 500 ms stall cancels", () => { + const draining = readyDevice(); + draining.device.physicalVoiceDown(); + for (let index = 0; index < 10; index += 1) + draining.device.captureFrame(new Uint8Array(192)); + assert.equal(draining.device.queuedFrames, 2); + assert.equal( + draining.device.grantCredit({ + epoch: 7, + streamId: draining.device.streamId, + ackSequence: 7, + credits: 2, + }), + true, + ); + assert.equal(draining.device.queuedFrames, 0); + assert.equal(draining.device.sentFrames.length, 10); + + const stalled = readyDevice(); + stalled.device.physicalVoiceDown(); + for (let index = 0; index < 9; index += 1) + stalled.device.captureFrame(new Uint8Array(192)); + stalled.setNow(499); + stalled.device.tick(); + assert.equal(stalled.device.state, "capturing"); + stalled.setNow(500); + stalled.device.tick(); + assert.equal(stalled.device.state, "ready"); + assert.equal(stalled.device.events.at(-1).reason, "timeout"); +}); + +test("credits are matching, cumulative, and cannot exceed the outstanding window", () => { + const { device } = readyDevice(); + device.physicalVoiceDown(); + for (let index = 0; index < 8; index += 1) + device.captureFrame(new Uint8Array(192)); + const current = { epoch: 7, streamId: device.streamId }; + assert.equal( + device.grantCredit({ ...current, ackSequence: 8, credits: 1 }), + false, + ); + assert.equal( + device.grantCredit({ ...current, ackSequence: 7, credits: 8 }), + true, + ); + assert.equal( + device.grantCredit({ ...current, ackSequence: 7, credits: 1 }), + false, + ); + assert.equal( + device.grantCredit({ ...current, ackSequence: 7, credits: 9 }), + false, + ); + assert.equal( + device.grantCredit({ + epoch: 8, + streamId: device.streamId, + ackSequence: 7, + credits: 1, + }), + false, + ); +}); + +test("sample-count bounds stop both device and receiver at 120 seconds", () => { + const { device } = readyDevice(); + device.physicalVoiceDown(); + device.sequence = 6000; + assert.equal(device.captureFrame(new Uint8Array(192)), false); + assert.equal(device.state, "waitingResult"); + assert.equal(device.events.at(-1).reason, "max_duration"); + + const receiver = new DeviceAudioReceiver({ maxFrames: 2 }); + assert.equal( + receiver.accept({ sequence: 0, samples: new Int16Array(320) }).accepted, + true, + ); + assert.equal( + receiver.accept({ sequence: 1, samples: new Int16Array(320) }).accepted, + true, + ); + assert.deepEqual( + receiver.accept({ sequence: 2, samples: new Int16Array(320) }), + { accepted: false, terminated: true, reason: "max_duration" }, + ); + assert.equal(receiver.samples.length, 0); +}); + +test("receiver inserts one silence frame but rejects excessive loss", () => { + const receiver = new DeviceAudioReceiver(); + assert.deepEqual(receiver.accept({ sequence: 0, samples: new Int16Array(320).fill(1) }), { + accepted: true, + insertedSilenceFrames: 0, + }); + assert.deepEqual(receiver.accept({ sequence: 2, samples: new Int16Array(320).fill(2) }), { + accepted: true, + insertedSilenceFrames: 1, + }); + assert.equal(receiver.samples.length, 960); + assert.equal(receiver.samples[320], 0); + assert.deepEqual(receiver.accept({ sequence: 2, samples: new Int16Array(320) }), { + accepted: false, + duplicate: true, + }); + assert.deepEqual(receiver.accept({ sequence: 6, samples: new Int16Array(320) }), { + accepted: false, + terminated: true, + reason: "missing_frames", + }); + assert.equal(receiver.samples.length, 0); +}); + +test("receiver terminates after ten total isolated missing frames", () => { + const receiver = new DeviceAudioReceiver(); + receiver.accept({ sequence: 0, samples: new Int16Array(320) }); + for (let index = 1; index <= 9; index += 1) { + const sequence = index * 2; + assert.equal( + receiver.accept({ sequence, samples: new Int16Array(320) }).accepted, + true, + ); + } + assert.equal(receiver.missingFrames, 9); + assert.equal( + receiver.accept({ sequence: 20, samples: new Int16Array(320) }).terminated, + true, + ); + assert.equal(receiver.samples.length, 0); +});