From 9463e05191163aa2b7c9a32cf84219d82863e7f7 Mon Sep 17 00:00:00 2001 From: Shang Date: Fri, 31 Jul 2026 16:03:04 +0800 Subject: [PATCH 1/3] feat(devices): add MultiPad USB CDC developer track Export the audited source-first adapter, tests, flash preparation, and GitHub documentation into the public devices subtree. Co-Authored-By: Claude Fable 5 --- devices/CHANGELOG.md | 67 +- devices/CONTRIBUTING.md | 20 +- devices/QUICKSTART.md | 97 -- devices/README.md | 80 +- devices/SECURITY.md | 41 +- devices/SHA256SUMS | 155 +- devices/SPEC.md | 215 +-- devices/docs/README.md | 16 +- devices/docs/availability.json | 17 - devices/docs/board-verification.md | 68 +- devices/docs/codex-app-server.md | 122 -- devices/docs/conformance.md | 22 +- devices/docs/development.md | 12 +- devices/docs/first-approval.md | 42 +- devices/docs/foundation-development.md | 110 +- devices/docs/hardware-support.md | 51 +- devices/docs/implementation-tracks.md | 36 +- devices/docs/interfaces.md | 157 +- devices/docs/migration-0.1-to-0.2.md | 16 +- devices/docs/multipad-usb.md | 110 ++ devices/docs/porting-guide.md | 14 +- devices/docs/project-status.md | 57 +- devices/docs/reference-approval-controller.md | 96 -- devices/docs/troubleshooting.md | 56 - devices/docs/use-cases.md | 28 +- .../macos-device-simulator/main.swift | 2 +- devices/firmware/multipad/CMakeLists.txt | 25 + devices/firmware/multipad/README.md | 101 ++ ...nexting-multipad-device-info.template.json | 32 + .../multipad/nexting_multipad_adapter.c | 152 ++ .../multipad/nexting_multipad_adapter.h | 72 + .../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 +- devices/firmware/zephyr/package.json | 2 +- devices/firmware/zephyr/src/main.c | 2 +- .../zephyr/tests/firmware-contract.test.mjs | 121 +- devices/package.json | 5 +- devices/protocol/vectors/approval-v1.json | 5 +- devices/protocol/vectors/config-v1.json | 36 - devices/protocol/vectors/device-info-v1.json | 24 +- devices/protocol/vectors/keys-v1.json | 31 - devices/protocol/vectors/navigation-v1.json | 37 - devices/protocol/vectors/rotary-v1.json | 30 - devices/protocol/vectors/text-v1.json | 28 - devices/protocol/vectors/usage-v1.json | 31 - devices/protocol/vectors/voice-v1.json | 34 - devices/reference/js/package.json | 2 +- devices/reference/js/src/device-info.mjs | 22 +- devices/reference/js/src/framing.mjs | 4 +- devices/reference/js/src/protocol.mjs | 690 +-------- devices/reference/js/src/relay.mjs | 16 +- .../reference/js/test/device-info.test.mjs | 65 +- devices/reference/js/test/framing.test.mjs | 5 +- devices/reference/js/test/package.test.mjs | 6 +- 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/vectors.test.mjs | 47 +- devices/schemas/message.schema.json | 294 +--- devices/scripts/bootstrap-zephyr.sh | 154 -- devices/scripts/bootstrap-zephyr.test.mjs | 49 - devices/scripts/check-naming.mjs | 13 +- devices/scripts/check-public-boundary.mjs | 17 +- .../scripts/documentation-contract.test.mjs | 327 +--- devices/scripts/export-manifest.json | 35 +- devices/scripts/export-nexting-devices.mjs | 79 +- .../scripts/export-nexting-devices.test.mjs | 120 +- .../public-workflows/nexting-devices-ci.yml | 16 +- .../nexting-devices-firmware.yml | 93 +- devices/scripts/simulator-contract.test.mjs | 7 +- devices/sdk/c/CMakeLists.txt | 15 +- devices/sdk/c/README.md | 29 +- devices/sdk/c/include/nexting_device.h | 207 +-- devices/sdk/c/src/nexting_device.c | 1371 +---------------- .../c/tests/generate_interaction_vectors.mjs | 42 - devices/sdk/c/tests/generate_vectors.mjs | 27 +- devices/sdk/c/tests/test_interactions.c | 132 -- devices/sdk/kotlin/README.md | 41 +- devices/sdk/kotlin/build.gradle.kts | 2 +- devices/sdk/kotlin/gradlew | 39 - .../kotlin/ai/nexting/devices/DeviceInfo.kt | 18 - .../kotlin/ai/nexting/devices/Protocol.kt | 719 --------- .../nexting/devices/InteractionProfileTest.kt | 58 - devices/sdk/swift/Package.swift | 8 - devices/sdk/swift/README.md | 72 +- .../Sources/NextingDeviceHostSmoke/main.swift | 139 -- .../Sources/NextingDeviceKit/DeviceInfo.swift | 32 - .../Sources/NextingDeviceKit/HostSmoke.swift | 60 - .../Sources/NextingDeviceKit/Protocol.swift | 662 +------- .../DeviceInfoTests.swift | 2 +- .../HostSmokeTests.swift | 35 - .../InteractionProfileTests.swift | 82 - 94 files changed, 1734 insertions(+), 6894 deletions(-) delete mode 100644 devices/QUICKSTART.md delete mode 100644 devices/docs/availability.json delete mode 100644 devices/docs/codex-app-server.md create mode 100644 devices/docs/multipad-usb.md delete mode 100644 devices/docs/reference-approval-controller.md delete mode 100644 devices/docs/troubleshooting.md create mode 100644 devices/firmware/multipad/CMakeLists.txt create mode 100644 devices/firmware/multipad/README.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/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 delete mode 100644 devices/protocol/vectors/config-v1.json delete mode 100644 devices/protocol/vectors/keys-v1.json delete mode 100644 devices/protocol/vectors/navigation-v1.json delete mode 100644 devices/protocol/vectors/rotary-v1.json delete mode 100644 devices/protocol/vectors/text-v1.json delete mode 100644 devices/protocol/vectors/usage-v1.json delete mode 100644 devices/protocol/vectors/voice-v1.json delete mode 100755 devices/scripts/bootstrap-zephyr.sh delete mode 100644 devices/scripts/bootstrap-zephyr.test.mjs delete mode 100644 devices/sdk/c/tests/generate_interaction_vectors.mjs delete mode 100644 devices/sdk/c/tests/test_interactions.c delete mode 100755 devices/sdk/kotlin/gradlew delete mode 100644 devices/sdk/kotlin/src/test/kotlin/ai/nexting/devices/InteractionProfileTest.kt delete mode 100644 devices/sdk/swift/Sources/NextingDeviceHostSmoke/main.swift delete mode 100644 devices/sdk/swift/Sources/NextingDeviceKit/HostSmoke.swift delete mode 100644 devices/sdk/swift/Tests/NextingDeviceKitTests/HostSmokeTests.swift delete mode 100644 devices/sdk/swift/Tests/NextingDeviceKitTests/InteractionProfileTests.swift diff --git a/devices/CHANGELOG.md b/devices/CHANGELOG.md index c34f1b7..4863405 100644 --- a/devices/CHANGELOG.md +++ b/devices/CHANGELOG.md @@ -4,69 +4,14 @@ This file records changes to public protocol behavior, shared vectors, SDK surfa ## Unreleased -### Changed - -- Reframed the Quickstart around the device–Host–Agent architecture and split - the XIAO two-button exercise into a clearly labeled Developer Reference - tutorial. -- Added a machine-readable SDK and public developer-enrollment availability - source for the repository and website. -- Expanded the security model with implemented BLE protection, Host - authorization and freshness checks, minimum disclosure, volatile-state - clearing, and the production identity boundary. - -## 0.2.0-experimental.2 — 2026-07-29 - -This release keeps wire major 1 and adds seven frozen interaction profiles -without changing `approval/1` or `status/1`. - ### Added -- Frozen interaction profiles `navigation/1`, `keys/1`, `rotary/1`, `voice/1`, - `text/1`, `usage/1`, and `config/1`. -- Shared valid and hostile vectors plus JSON Schema definitions for all 15 new - messages. -- Strict JavaScript, fixed-buffer C99, Swift 6, and Kotlin implementations of - every new message. -- Device Info profile negotiation helpers and Host-side profile gating. -- Sequence numbers for replay/out-of-order rejection on navigation, key, - rotary, and push-to-talk input. -- Versioned, atomic configuration results. Invalid configuration leaves the - current device configuration unchanged. - -### Changed - -- The Codex Host guide now distinguishes the official high-level SDK from App - Server and freezes the fail-closed one-time approval projection into - `approval/1`. -- `voice/1` carries control only. Audio capture, permission, and transcription - stay on the Host microphone; audio and transcripts never cross this BLE - profile. - -## 0.2.0-experimental.1 — 2026-07-28 - -This developer-experience release keeps wire major 1 and the `approval/1` and -`status/1` profiles unchanged. - -### Added - -- A public XIAO nRF52840 Quickstart with exact wiring, flashing, expected - output, and an explicit public-App availability gate. -- A rerunnable Zephyr 4.3.0 / west 1.5.0 / SDK 0.17.4 bootstrap with board - aliases, dry-run output, isolated Python dependencies, and actionable errors. -- A macOS `nexting-device-host-smoke` executable that validates Device Info, - uses encrypted BLE, presents one synthetic approval, and emits a - machine-readable real-button `PASS`. -- Setup, toolchain, flashing, Bluetooth, Device Info, and public-App - troubleshooting. - -### Changed - -- Public docs no longer require unpublished product software. -- Claude Code and Codex docs separate published protocol surfaces from roadmap - profiles and from public App availability. -- Website SDK pages have stable shareable routes and include Kotlin/Android as - a first-class Host reference. +- A source-first ILX MultiPad USB CDC developer binding that reuses the portable + C99 state machine, preserves the upstream HID and `AA BB xx` commands, and + includes a host-side CMake contract test. +- A GitHub-ready MultiPad guide, conservative Device Info template, CDC smoke + check, and fail-closed STM32 serial-flash preparation script. Physical board + and bootloader evidence remain explicitly pending. ## 0.2.0-experimental.0 — 2026-07-27 diff --git a/devices/CONTRIBUTING.md b/devices/CONTRIBUTING.md index 75ea90f..a81a6f5 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 | -| Swift host SDK | Swift tests and build; consuming product integration tests when Host-facing behavior changes | -| 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 | +| Swift host SDK | Swift tests and build; consuming product integration tests when Host-facing behavior changes | +| 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 deleted file mode 100644 index b918caa..0000000 --- a/devices/QUICKSTART.md +++ /dev/null @@ -1,97 +0,0 @@ -# Start with Nexting - -Nexting turns nearby buttons, rotaries, LEDs, and displays into a physical -remote for an Agent. The device never talks directly to Claude Code, Codex, or -another Agent: - -```text -Nexting device ⇄ encrypted BLE ⇄ trusted Host ⇄ Agent integration -``` - -The trusted Host is the Nexting App or another compatible phone/computer Host. -It owns credentials, authorization, policy, and Agent-specific mapping. The -device owns physical input and minimum display state. - -## 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 | - -This separation lets one public device protocol support a button, wearable, -macropad, desk panel, or custom display without placing private Agent logic in -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 public Host or simulator before adapting hardware | A deterministic profile exchange or `PASS answer=...` local protocol proof | - -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. - -**Have a supported board?** -[Build the reference approval controller now](docs/reference-approval-controller.md) -and prove a real button without turning the general Quickstart into a board -assembly guide. - -Public third-party developer-device enrollment is a separate release gate. The -machine-readable status in [`docs/availability.json`](docs/availability.json) -currently records iOS and Android enrollment as unavailable. Do not use an -unpublished App build or weaken BLE authorization. The supported fallback is -the public Host smoke test. - -## First remote interaction - -Every supported interaction follows the same shape: - -1. The Agent produces a request or state change. -2. The Host verifies that the selected device is authorized and declared the - required profile. -3. The Host sends only bounded state over encrypted BLE. -4. The user presses, turns, navigates, or holds a physical control. -5. The Host validates identity, freshness, sequence, and policy before applying - the mapped action to the same Agent session. - -With a released first-party product and supported Agent integration, this is a -remote Agent interaction. With the public SDK and Host smoke test, it is a -local protocol proof: useful for validating the open contract, but not a claim -that the public Nexting App already enrolls DIY hardware. - -## Why the Host exists - -- Agent credentials and authoritative sessions stay on the trusted Host. -- Firmware implements stable physical intent rather than Claude Code or Codex - internals. -- Agent mappings and risk policy can change without reflashing the accessory. -- The Host rejects undeclared profiles, stale sequences, expired requests, - duplicate answers, and revoked devices. -- The same device contract works across richer custom hardware. - -## Security in one minute - -The local control link uses BLE LE Secure Connections, bonding, and encrypted -GATT access. The device receives minimum profile data, such as an opaque -request ID, bounded summary, fixed choices, relative lifetime, or volatile -display state. - -The Developer Reference uses BLE “Just Works.” That encrypts the bonded link -but does not authenticate against an active person-in-the-middle during -pairing. Production devices need authenticated application identity and signed -firmware. Read the complete [Security model](SECURITY.md) before making a -product claim. - -## Next steps - -- [Build the reference approval controller](docs/reference-approval-controller.md) -- [Troubleshoot setup, BLE, and Device Info](docs/troubleshooting.md) -- [Map a Codex App Server request in your own Host](docs/codex-app-server.md) -- [Integrate the C99 Device SDK](sdk/c/README.md) -- [Integrate the Swift Host SDK](sdk/swift/README.md) -- [Integrate the Kotlin Host SDK](sdk/kotlin/README.md) -- [Read every public interface](docs/interfaces.md) -- [Read the normative protocol](SPEC.md) diff --git a/devices/README.md b/devices/README.md index a566c3d..91bf258 100644 --- a/devices/README.md +++ b/devices/README.md @@ -2,16 +2,7 @@ Open interfaces for building physical control surfaces for AI agents. -Nexting Devices lets a button, wearable, desk panel, macropad, or custom -product control and display bounded AI Agent interactions through a trusted -Host/App. - -**New here?** -[Understand the connection and run your first interaction](QUICKSTART.md). -Hardware developers can then -[build the reference approval controller](docs/reference-approval-controller.md). -Public App availability is machine-readable in -[`docs/availability.json`](docs/availability.json). +Nexting Devices lets a button, wearable, desk panel, macropad, or custom product show one pending AI approval and return a real user's Allow or Deny choice through a trusted Host/App. **Nearby device, agent anywhere.** The device talks to the Host App over Bluetooth LE; the Host reaches the user's agent wherever it runs — the computer across the room or across the internet. Every device built on this contract is a remote control surface for its owner's agents, not a desk-bound accessory. @@ -27,35 +18,23 @@ 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) | -| Add a physical control surface to a Host/App | [Browse every public interface](docs/interfaces.md), then use the [Host integration track](docs/implementation-tracks.md#track-2-integrate-a-host-or-app) | -| Connect your own Host to Codex approvals | [Map the official Codex App Server safely](docs/codex-app-server.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 language SDK | Use the [language SDK track](docs/implementation-tracks.md#track-4-maintain-a-new-language-sdk) | -| 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 -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 | +| You want to… | Start here | +| -------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| 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) | +| Prepare an ILX MultiPad USB device | Read the [MultiPad USB guide](docs/multipad-usb.md); confirm the PCB before any flash write | +| Add a physical control surface to a Host/App | [Browse every public interface](docs/interfaces.md), then use the [Host integration track](docs/implementation-tracks.md#track-2-integrate-a-host-or-app) | +| 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 language SDK | Use the [language SDK track](docs/implementation-tracks.md#track-4-maintain-a-new-language-sdk) | +| 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 + +The first two profiles remain deliberately small: one active `approval/1` +request with Allow and Deny, plus bounded `status/1` Agent indicators over +Bluetooth LE. Version 0.2 adds extensible Device Info and Android SDK parity +without changing wire major 1. Public: @@ -76,18 +55,14 @@ Not public: The wire format may change before 1.0. Developer Reference devices are not production security certifications. -## Extensible physical controls - -Device Info describes identity, buttons, rotary controls, display, haptics, -standard battery support, inert vendor facts, and the exact versioned profiles -the device supports. The Host rejects traffic for undeclared profiles. +## The capability direction -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. +Experimental 0.2 is the first tile, not the ceiling. Device Info can already +describe identity, buttons, rotary controls, display, haptics, standard battery +support, and inert vendor facts so iOS and Android can render one extensible +information table. Interactive command keys, navigation, rotary events, text, +voice, and configuration remain separate future profiles. See [the capability +roadmap](docs/foundation-development.md#beyond-experimental-02-the-capability-roadmap). 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. @@ -102,20 +77,17 @@ All four share one Zephyr application and the portable C99 core. All four are Bu ## Documentation -- [Understand the device–Host–Agent connection](QUICKSTART.md) -- [Build the reference approval controller](docs/reference-approval-controller.md) -- [Troubleshoot setup, build, BLE, and Device Info](docs/troubleshooting.md) - [Documentation by task](docs/README.md) - [Browse the use cases](docs/use-cases.md) - [Build the public foundation](docs/foundation-development.md) - [Browse every public interface](docs/interfaces.md) -- [Map an official Codex App Server approval into a Host](docs/codex-app-server.md) - [Choose an implementation track](docs/implementation-tracks.md) - [Understand compatibility evidence](docs/conformance.md) - [Read the normative protocol](SPEC.md) - [Review the security model](SECURITY.md) - [Check current evidence and blockers](docs/project-status.md) - [Develop and test locally](docs/development.md) +- [Prepare an ILX MultiPad](docs/multipad-usb.md) - [Give a coding Agent the repository rules](AGENTS.md) ## Repository status diff --git a/devices/SECURITY.md b/devices/SECURITY.md index 0b82e47..a7077f9 100644 --- a/devices/SECURITY.md +++ b/devices/SECURITY.md @@ -11,8 +11,7 @@ The device receives only an opaque request ID, a bounded summary, fixed choices, ## Minimum transport controls - BLE LE Secure Connections and bonding; -- encrypted GATT permissions required for control writes and notification - subscription; +- encryption required for approval writes and notification subscription; - explicit user authorization and revocation in the App; - fail-closed parsing and bounded buffers; - relative expiry, single consumption, and duplicate suppression; @@ -20,44 +19,6 @@ The device receives only an opaque request ID, a bounded summary, fixed choices, - production firmware signing; - no name-only authorization in release builds. -## Protected local link - -The Zephyr Developer Reference enables BLE LE Secure Connections, bonding, -controller privacy, and bonding-required policy. It requests -`BT_SECURITY_L2` after connecting. Control writes require -`BT_GATT_PERM_WRITE_ENCRYPT`; notification subscription requires encrypted read -and write permissions. - -Device Info may remain readable for compatibility discovery. Reading identity -and declared capabilities never authorizes a device or a physical action. - -## Authorization and freshness - -The trusted Host validates explicit authorization, current request identity, -expiry, choice membership, sequence or revision, duplicate and replay state, -and single consumption before it applies physical intent. Phone and hardware -inputs compete for the same one-answer gate. Unsupported, stale, malformed, or -already-consumed input fails closed. - -## Minimum disclosure - -Firmware receives bounded profile data such as an opaque request ID, short -summary, fixed choices, relative lifetime, or volatile display state. Agent -credentials, account, session, terminal, prompt and cloud route identifiers, -and tokens remain on the trusted Host. - -`voice/1` controls the Host microphone lifecycle and never carries audio bytes or transcripts. -Audio permission, capture, encoding, transport, and transcription remain Host -responsibilities. - -## State and revocation - -State and partial-frame clearing on disconnect prevents a device from rendering -or acting on an abandoned exchange. Disconnect, reboot, a new bond, or physical -bond reset clears the applicable volatile requests, rendered state, -sequence/revision memory, and partial frames defined by the protocol. The Host -can revoke a previously authorized device independently of its BLE bond. - ## Developer Reference is not certification The macOS simulator and reference boards are development tools. They must be visibly identified as Developer Reference devices in names, logs, and App UI. diff --git a/devices/SHA256SUMS b/devices/SHA256SUMS index c94019e..45491ab 100644 --- a/devices/SHA256SUMS +++ b/devices/SHA256SUMS @@ -1,119 +1,106 @@ 5e79c3b2a9dc997d6faa762fa3d420da27243c27a60726a5b4d94dabfdfcfb17 AGENTS.md -c04377581c1276abd5167357fdf5cc2b5f43374ad175407b50b1ea4e9bd04b9f CHANGELOG.md -bca1e1281cdb9f6a6333b94732347e17ef1dfd71e37739e571931a78e9743da0 CONTRIBUTING.md -4e087afb9492832f8cfe8852b744b6a39609a99e71efe3e5f5547c92c8f66a4c docs/availability.json -fee2bc61257031a3a77f16d08317ffc900399da391e23c34c5a8eeee5e1242a4 docs/board-verification.md -1f6e6328514c91f6fd7598f22e97ace2437b954c50a4bf90796d6e280b2272ab docs/codex-app-server.md -b21af2b2e8f6909f6849c7b8c884a660cdba52d63e20f57b8a01558c01596a45 docs/conformance.md -3f8647d9f03b100d8687eb47d684fe973770374a0cab75297cd9cd03fef0eb06 docs/development.md -b684c430952a2502e7373cfe3b3c3354edbbbbf257bbfc8c2c2b8f18a9951cb4 docs/first-approval.md -06edae817e2a105c45191c1bed9972699ef8d2bc54b94bb5e438209ec3933159 docs/foundation-development.md -45a6f16c8f43443ee7035efa0dd5d0c87839e2835510f52a4760ab77bbe76569 docs/hardware-support.md -c0443f2162d284b7d4bdb5ef5b59679416ed0279909cf7fdd7f6bf19a5d50ff4 docs/implementation-tracks.md -efa03687616804ce67309bf4a53c2b07442f92fbaf8e090dc50768d53921cc8f docs/interfaces.md -56bdbeb867f3322e41fde1dfb69a3577944bd3cfbb8526f189e9d8c350c9298f docs/migration-0.1-to-0.2.md -b9dda6616039d28de4247731274a96b3b861fa5bd931a234ae4b05e3f2ca1978 docs/porting-guide.md -1e04597bbdbf582898af0209c2158a8a7a14e0a795798b6788862edbe10e1fc7 docs/project-status.md -ed3527cebbde0b08ad5b24d1ce6b06e64bccb8884dc265553e22db4a3ee39a91 docs/README.md -3a4450b4a09222ab4c85f2deb608f956b36558003db9041c9c32e730596411c5 docs/reference-approval-controller.md -317190076de89ff907b686a29df6303e271ca65bb5383deee7e80122425d1cd9 docs/troubleshooting.md -8bded68d2fb5341d6595bc44ecfee4e16319f0f5a5a9431a09d8b013a63ba0cc docs/use-cases.md -b7e876121a2ce1d8b008e80682f265d5538a2fc6bd7dfe6559ee63210d6fbf99 examples/macos-device-simulator/main.swift +b2e88419dcda506677dd44af32faca468de0f852305eb6c6b3892f1bd10b0f86 CHANGELOG.md +0905c822fd9d85cb05d05539ca0b41ce807109621f06e5d18792505a78fc270f CONTRIBUTING.md +f0e5a3b3fc8d8d664d76be452f1631a296ef89566acb58440354da31af2074bd docs/board-verification.md +bdfc3ae27b547003179322577ca28a03e8d06bb46815625ec1270ff3142b50da docs/conformance.md +92b35fee2f0ae4195fc31614a01b1aeba3b2bdc24b8f59c38e46a2c0c537dcc2 docs/development.md +b6b2a3bc464d167d920f143c71ffde52c51d264f5cc71c0e8928f95986fa0e2b docs/first-approval.md +e27b38c5b92c32d3f0028996516b027fb842183730dae8c7ba3c87e5be882329 docs/foundation-development.md +253e7aff8894704a280e9a588e0181da98a9bd825fd506625b9325e357f6e419 docs/hardware-support.md +8436fcbbfb8554f5c2549d06b329f6f3f7631ed3437ec51f1bb7454798491b00 docs/implementation-tracks.md +16815c4d5e0ae09b8502f0109a0a4e38b64b467a4b0a82b029905093b63310af docs/interfaces.md +026b3ce7d5970d2a34d64862de12882f8da9e014d2ef9ab4a9c3d5e99af31b1b docs/migration-0.1-to-0.2.md +18c71c1d3da6a2a9bb66912832916594c672d268fa1c014b66c4decdce9fe11a docs/multipad-usb.md +36f7151ddbf307f8ff4cf5487d094930882b78b7ee91d241adf2eff53753ac55 docs/porting-guide.md +8ba7a254ecd348f19215dedc11eeb561f22c74cf3417b47d414dfaab5675c4ed docs/project-status.md +dc9e38145477ae5c9f9818ad3567d7d57f9bd31f264279014b30ffec097b22e7 docs/README.md +5b04ce5083234a4137fad6eee987946b55bfe7a758220394eb127b804aa4f873 docs/use-cases.md +cddf6aec5d4750dc34c9a6f642c604c8786ba89018082b96db8a7b6fe716b7b5 examples/macos-device-simulator/main.swift 85a7b0f0976f7b43099629335e9b61be625a77ce548e438956dbf3a5e1de2e27 examples/macos-device-simulator/README.md +e93dad280dad39b252d5d0edaf549cf9db8a8833eb009bc2b089da8ebfb181d2 firmware/multipad/CMakeLists.txt +d40be2000fae5697e0d40abebfc833d31c8568679c66c3f1b1cbb85744dac7d4 firmware/multipad/nexting_multipad_adapter.c +f4bab39e2320ae1f171eca78005cdfd355848dda5690020f2201de4a96d37587 firmware/multipad/nexting_multipad_adapter.h +55e8c889cfe9b62dcc62c591880aba2f9826a6a1353d7a89213916efd27c0bce firmware/multipad/nexting-multipad-device-info.template.json +38103d0b2ed11139f1a1d3cfae91daccc6c57514086621a1ef49ca9e8d1d0270 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 c24d171b96e0541a3f5c8053d83e1630f7f16ef1d6f6d30b630e7063d122b554 firmware/zephyr/CMakeLists.txt 0be1017de0baf69292cb7eee26a9f6c08753a43ca6d583a9344397dd349d46b4 firmware/zephyr/debug-test-device.conf -363579f1d3b70d4e83de33a51587ee496c1f9d26105ace386251b6187ac0b321 firmware/zephyr/package.json +9d355da3bfffe4a950fdcf3bb7f1f0045a7da054f7e66f15824efc292546bf1d firmware/zephyr/package.json ca92428690e5d784eea852e5f31573d78c6bc42c9fb7229d7989ff89cbd3b543 firmware/zephyr/prj.conf -c700f02585e957eb666ce6b014a0cd9c30690c77deda5b19a86073c67716c1d7 firmware/zephyr/README.md -958e3a5e90984f2a0e2915c6c4047d98bbf4b46a9853e4ace2e2fe2143fc242d firmware/zephyr/src/main.c -2cf5855a3de0ebf320ad2df4b7c31cc22818d0dd149fef88b94e18eab5aba31c firmware/zephyr/tests/firmware-contract.test.mjs +d060ff9e9a7c6fb3cebc008b1db5b0127bbf077008e3553c95d7afdf37115c36 firmware/zephyr/README.md +91ad6d3fb2d182abd860c64a95e5297bb9e66f812317b89b8aa2daad7e977514 firmware/zephyr/src/main.c +386a921f3b12f805a676f5fad56b807f3c49b95f8cf3174d8bbd619476559377 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 +6470d2b9476248dad74f3882ca3d9afb4e85d8f1af8e84e96c32c0127ad9dfe1 package.json +af1ea5c4627d67b21afd238f48b7eb4931655894bd899450b16fc802d8cdcf68 protocol/vectors/approval-v1.json +eb2a6b125d123a5644a0773593c2eee730339f2809e51605f2f82eb518dde1d4 protocol/vectors/device-info-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 -6d13eeab77c9b5d79acc120b72cf84079fc6580cf5620ae73fa01df902112ac1 QUICKSTART.md -ebed69b2df3cc842ca37d0317c5f7957e5514041740f886c4c8a4f81d6471ff2 README.md -d34ae54889cc7d601bc57011a65f2377bf70ee08cbbfba2aec081bd5e9ec02c7 reference/js/package.json +e568a17b0b64eb50fd147981ad6d58127127022774075acdc8d07f93df14d895 README.md +83c03d7b5779aaf86471945890b5e699cba1171fe3064b58942a3784c4efa9d6 reference/js/package.json 18374cf8708c75abce5a22d6d445cf5ab48b6b62774ecf3af8f780693d1cf45e 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 -9bc0262c63eb03ea8adf4eea273d213dc82ffa3c69b2fc838aca6772f785ced2 scripts/bootstrap-zephyr.sh -edd88282a6903f32c585803a60c3c5bb076445d36fc76e511d9965ed57e20422 scripts/bootstrap-zephyr.test.mjs -327730ddb2cc1bc0265d6a15df0cd89bb94b154d1eb18d698cc57fa2be4cacfd scripts/check-naming.mjs -0ac9fbc4e0688525458164868e417919bc176fbae0c21181b3ea4b8711cc9a34 scripts/check-public-boundary.mjs +d1daccb45aebdf6a6ff524abe07ba34833a83266c433178596840c00a7fb3ee8 reference/js/src/device-info.mjs +e7ff09171e891a9e82bdc2a9b7b1d7f089a320e7c27c799337579002fca259b4 reference/js/src/framing.mjs +b4775aa13179f05e7417bc747fcc413678827475393f8a9e5405c46423908d41 reference/js/src/protocol.mjs +8eea06b2746cd9ed956cb769712ef5702b06717d97318397cc320c3733b183d9 reference/js/src/relay.mjs +f31e294911258aa828a937cd403966660fcd5e4faeaabe437fb8d70520cb3a7b reference/js/test/device-info.test.mjs +23bc2342bdf448d85738b316759802d36745d7b550c63f6b4b2ab2112de59666 reference/js/test/framing.test.mjs +bcd65a9c62ed1ad59e165fb4d636ed660cc06a2733e2fd7240e82780bbd479e6 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 +dd94f6782ad7f3f8ab102806ac2b32555a8f61ef6da04eca504250591f43d697 reference/js/test/vectors.test.mjs +00256ef1b0b8282988fd810528a3ca8619b178c0593d616729f47fce2e73b100 schemas/message.schema.json +707046c2762b923fcc0add6465f23a504a93f48502c55c24155c7367fcbfe4ae scripts/check-naming.mjs +e3f347b6e98e6a1ca3f9f842a6c2f1245f0f6953680a685ecbdd42d17dd1e1ed scripts/check-public-boundary.mjs 8d7aad1d02044e2762835d765a6bad5fc41c454e15d75111f957db45bc72348c scripts/check-public-boundary.test.mjs -7460c7107f0c0fe0f1393aa7b7f9661370301b4964e858a23e4d3c7e81f15f67 scripts/documentation-contract.test.mjs -7c287522776d2c13efd1b612911fe9acb0d93c50363db2057c94aeae6725f29c scripts/export-manifest.json -c9dbb012caf0fa12663dd833e532fa4c1c2c66e28d272b933f6c1c66971c2771 scripts/export-nexting-devices.mjs -d44b2068c761ca2a57c8cef1828cbd46435342c3a1e9d002b0ca3266ab2d7413 scripts/export-nexting-devices.test.mjs -acc23a2dc72940134997118b4cc09371fbe3cc98043ac7228e8b9c9f0d80d75f scripts/public-workflows/nexting-devices-ci.yml -61a898bbca6629ab9e990fb8fca9b22928cf245b6bfab91f85c9c70b96307e0d scripts/public-workflows/nexting-devices-firmware.yml -df6af303e569f464ac35e3445fe59d26dbba21ea47583157eb4813464e018246 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 +2ff5e27e3ce3c7d4e0623d1b66d5ff7fb24c2edf8308e35ba7b5fcdb59e76e4d scripts/documentation-contract.test.mjs +4d4f5bdc49028e5c05941cab8a387ee65a5723508755f4efbb6fe2f240b251b6 scripts/export-manifest.json +f461f2683632c7fb38a5c04b2fa03a0293fe791571b1b173982514530cc793d6 scripts/export-nexting-devices.mjs +85f681344b04754999e17a4ec472dfae1ed1c2daa49f065d77bacdb223ea57fe scripts/export-nexting-devices.test.mjs +a7120b95b63f5c7004f5099fcd9bf073fd34e167cef6e6382258d869f6faa2ac scripts/public-workflows/nexting-devices-ci.yml +26439e239d1cf2c5653887e1acc36158a37268739ce12e8d1b696961fedb1338 scripts/public-workflows/nexting-devices-firmware.yml +253def537572948c711e7efea1170e692d1dc7a8a24cf191462a5705c4391b60 scripts/simulator-contract.test.mjs +062dcfe496ac3b06800c0167b162bcb0009db2b400c4cbc19368f48f328bfd40 sdk/c/CMakeLists.txt +94af51de54183d084985a7644f6527602e3c7d0ad9560a70288ff32bbdf7da57 sdk/c/include/nexting_device.h +83faedf97e69f57fe68133106003e11255f27e078eb82b0daffd8e4aa1b83af8 sdk/c/README.md +96b38fc269a735ea14463c3faa7d0d7e069ac68716ab4c1dbbee5e462a1f3285 sdk/c/src/nexting_device.c +762bc8940658e05d8ed971f169e0b2a1f70996a177511d687952977ed53af456 sdk/c/tests/generate_vectors.mjs 25d1d7913e802c8bc97d946e6397a7ea2a9be9b62d41627b39e8fd675b6f1155 sdk/c/tests/test_codec.c b6b197c0a70209e0b366f2ff7fd8c39d239badff9dc531d5f93eb1cbbe514bc8 sdk/c/tests/test_device_info.c -d51158c657c8ae15d5a985f8af7be781f7fffa661f3fb91b34a78937decf1d2f sdk/c/tests/test_interactions.c 487dcc020f48b972d220943350b57e8a043c3072f96d67de530fa5cf9ba6e0da sdk/c/tests/test_relay.c d38b6768087760080a69478e51557a313d06ab5d5f97dd1605a33e04a430a5e6 sdk/c/tests/test_status.c 8ac606b50e5a4a5413fa9d570583caf1bab29779b0d930c5a32705a461ea08af sdk/c/tests/test_stream.c 8c52614a6393b6b7820cdde0027edb883ac1c2b204d35a088b623ccc4e6157cd sdk/kotlin/.gitignore -585b93531c28a3c5c1de7ebe7d47214cb780bbfa84055a7bd6f399d511dd3cc7 sdk/kotlin/build.gradle.kts -de1455eca60bce347b9102d7cacef6a6054453a9929969b0fc7de1dfe2e69f10 sdk/kotlin/gradlew -b1e21cfedd4cf2138ed836308ee3518064cbe18a8860155ffecc2d6d6493fd5a sdk/kotlin/README.md +01b50657531fba6d93a7d1594e5a9225820ad5c540850682bd4dffd876ef56c3 sdk/kotlin/build.gradle.kts +10aba029577bc5f16b48e3191af180771e6bdc65cb5818a9f1a0fd369e2b8d70 sdk/kotlin/README.md d7ecbec58086eba607d42430e3615fb6737b93c8d92745a9a9038b0a76b883d4 sdk/kotlin/settings.gradle.kts -3f9db12563b9b8b34357b5be45e4290a7c6b08e830a15d1ce80e5d1efc7cd694 sdk/kotlin/src/main/kotlin/ai/nexting/devices/DeviceInfo.kt -ba003398cbeec65fd1f4362a46938aac7cafabb438432e20352cb49f62f49dc0 sdk/kotlin/src/main/kotlin/ai/nexting/devices/Protocol.kt +d2f6ecf1360261b5a568c8272df55fc3edee1a60c7bbc8195e941693fef9c95c sdk/kotlin/src/main/kotlin/ai/nexting/devices/DeviceInfo.kt +8caeb5bf6412a75186783001121b9645bddced72e353027c2cb82c98c2255825 sdk/kotlin/src/main/kotlin/ai/nexting/devices/Protocol.kt 1487d8cd5412b13d3a4646b2d4a850b7a913f102b0e6415b089b49b4f684e370 sdk/kotlin/src/test/kotlin/ai/nexting/devices/DeviceInfoTest.kt -58556303e4b4956d4d406500c8dfd2bec630158d7c4268c9385d668fb78cb302 sdk/kotlin/src/test/kotlin/ai/nexting/devices/InteractionProfileTest.kt 7296bf9298d90177e93426c6453e88c3fcc8611bc381fb3e665c34382539ec32 sdk/kotlin/src/test/kotlin/ai/nexting/devices/ProtocolTest.kt -21dea7bd010b27b863f04ae2751332b87a709a423712852bac20f2dd30bfdf16 sdk/swift/Package.swift -850a20b362c7632a8862a1a64040aca345dd4c44acc2af7d57e330a2615f2f85 sdk/swift/README.md -8b6283b4bed44688dde19a457b8456f9b44f1a71652f660a9581aa5997720e4d sdk/swift/Sources/NextingDeviceHostSmoke/main.swift +e81763c107e15132f5e3bffb7e35c3aa48d8910005bd3c282511bcc24cadeb41 sdk/swift/Package.swift +65d28b940d8e2072502eade3933524791c6d80932e27d5b0785e11a00bbaab9b sdk/swift/README.md 05e25b0ccab972c4e258661fb944bd82ad2e27b18f2271e9355a325c1b1443e9 sdk/swift/Sources/NextingDeviceKit/Authorization.swift bad207de0578a2e6d83bd92c89f6d17181f794bff2b42c012f075f37f91436f9 sdk/swift/Sources/NextingDeviceKit/Battery.swift c331610cfd42dec4f51831a3e279e297591a7a58de1d776f080669e2b479871a sdk/swift/Sources/NextingDeviceKit/Central.swift f49ebf0ce48de0efd8d4d8a1ae323bfd09b6260f9541d6f30d2faec78525ab48 sdk/swift/Sources/NextingDeviceKit/Coordinator.swift -2892ddb1275e359fc5a9750d217e694268f6a845a44668435b5365a8e700652d sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift +35f2e01a5a51397a0ff4a337c9a861719a75cee00c3703db24be833fce24e2ab sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift 83d02fa9ee4e0fc66dcc2c414e1c8c0ceb01ffbfd21d0ceb2ccf6e655cc57aeb sdk/swift/Sources/NextingDeviceKit/Framing.swift -90338ec1dc328bb92f163e0f3c2b94e461263f324ce93591abc3483d6a6dc80c sdk/swift/Sources/NextingDeviceKit/HostSmoke.swift -5efac997fdb62272d6638eb7ceb4f2766e55b56540513fbb5f9db13c7b97ed6a sdk/swift/Sources/NextingDeviceKit/Protocol.swift +da5cd514c89109e673fcfc4f6a5e09442e111b214c3529e435be094c81ca827d sdk/swift/Sources/NextingDeviceKit/Protocol.swift 43fd49c269adb79fa36efb892d7eec327262e5cdfa50b1dc2ad905756a155126 sdk/swift/Sources/NextingDeviceKit/Relay.swift 4be04d4013f412e6770aaf462ea0c0357c668e4cb8005bf03eee368d373e8019 sdk/swift/Sources/NextingDeviceKit/State.swift -341850e1bf02c94b47645e15add75a7bac7f19f87043facf2c7e8e1ba5b6705c sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift -0f790ee6e59ba15d38c3a06b6e4ca38e1e11b991068906fda7c94e05676bff0c sdk/swift/Tests/NextingDeviceKitTests/HostSmokeTests.swift +04ed84f7d1b05c4e4d21cbbe2c30eb6e5bdc261432509d0b54cc5f341b57d78e sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift a505f114c02ad031aa1c80461a7a9006f108bbeaf12a549fdacbbb73412b11ce sdk/swift/Tests/NextingDeviceKitTests/IntegrationTests.swift -e756d07cd1e47b36d43c29f3bd85e144355e60da62d7ff4a7205cee93e269ec4 sdk/swift/Tests/NextingDeviceKitTests/InteractionProfileTests.swift baefe1d85ee7317797d03c5b12851ee4ed73eadbda8a0cdbd9215e60ddbe0e44 sdk/swift/Tests/NextingDeviceKitTests/RelayTests.swift 13f09fd666e7871d2298fcf2669bfda2f7adb4b6edb815c637b64c688f30d71c sdk/swift/Tests/NextingDeviceKitTests/StatusTests.swift aae4345e72b23ee5f64ab863406f88bd4b5af3544f1e23e7da0eaaac37fbff3a sdk/swift/Tests/NextingDeviceKitTests/VectorTests.swift -5237d7e35cf872b9348db413bed8a500c7868f368be5fe9e5ccc8e27faffb961 SECURITY.md -e9ac568e20666af8e92aed24192b5501391239000222fa4f4992b82d77084c50 SPEC.md +08e9f7804e570679cc89fca2d13d210f122ae5c05135777ccb1ae51f5751c5e8 SECURITY.md +773d0fbfc63173bdbdfb9d0d4d4682348c49f0e0286e1a59468bf87a890b5576 SPEC.md 4196491d589dbc14ad631e03d48d6ee1d736e1a119960a0b0240f6e35c173f56 west.yml diff --git a/devices/SPEC.md b/devices/SPEC.md index e3a12c6..2ca99d6 100644 --- a/devices/SPEC.md +++ b/devices/SPEC.md @@ -1,8 +1,6 @@ # Nexting Device Protocol — Experimental 0.2 -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`. +This document is normative for wire major `1`, profile `approval/1`, and profile `status/1`. ## Product contract @@ -19,12 +17,12 @@ Profile `approval/1` represents exactly two one-time actions: Allow maps to sour ## 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 | 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 +33,27 @@ 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.2.0-experimental.0", + "wire": [1], + "profiles": ["approval/1", "status/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 +} ``` The required fields remain `protocol`, `spec`, `wire`, `profiles`, `model`, @@ -88,7 +106,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 @@ -134,7 +157,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 +178,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 +186,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 +201,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 +221,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. @@ -205,148 +239,6 @@ Status is volatile: disconnect, reboot, or a new bond clears all rendered slots Fail closed: unknown `state`, duplicate `slot`, out-of-range `slot`, more than 8 `agents` entries, an oversized or control-character `label`, or any other malformed field discards the entire frame and keeps the previously rendered state. -### Navigation - -Profile `navigation/1` presents 2–8 bounded choices without exposing the -source Agent prompt. It shares exclusive interactive focus with `approval/1`. - -```json -{"v":1,"t":"nav_present","id":"q7","items":["Fix it","Explain"],"cursor":0,"ttl":30000} -{"v":1,"t":"nav_move","id":"q7","dir":"next","seq":12} -{"v":1,"t":"nav_select","id":"q7","index":1,"seq":13} -{"v":1,"t":"nav_resolved","id":"q7","r":"selected"} -``` - -- `id`: the common 1–64 byte request identifier. -- `items`: 2–8 unique strings, each 1–64 UTF-8 bytes with no control - characters. -- `cursor`: integer `0...items.count - 1`. -- `ttl`: 1–300000 milliseconds. -- `dir`: `prev`, `next`, `up`, `down`, `left`, or `right`. -- `index`: integer 0–7. The Host additionally checks it against the current - presentation. -- `seq`: unsigned 32-bit sequence number. -- terminal `r`: `selected`, `cancelled`, `expired`, or `replaced`. - -Moves are advisory. The Host remains authoritative and replaces -`nav_present` when cursor state changes. A selection is consumed at most once. - -### Keys - -Profile `keys/1` separates a physical slot from its private Host action: - -```json -{"v":1,"t":"keymap","rev":4,"keys":[{"slot":0,"label":"Approve","enabled":true,"light":"solid","rgb":[0,200,90]}]} -{"v":1,"t":"key_event","slot":0,"event":"press","seq":41} -``` - -`keymap` is a full volatile replacement with 0–64 unique slots in 0–63. -`label` is 1–32 UTF-8 bytes with no control characters. `light` is `off`, -`dim`, `solid`, or `pulse`. Optional `rgb` is exactly three integers in 0–255. -`event` is `press`, `release`, `hold`, or `double`. - -A device emits no event for a disabled or undeclared slot. The Host maps a -slot to an authorized action; labels are presentation only. Repeating an -identical `rev` is idempotent. Conflicting content at the same revision is -rejected. - -### Rotary - -Profile `rotary/1` reports bounded physical rotation and press gestures: - -```json -{"v":1,"t":"rotary_map","rev":8,"controls":[{"slot":0,"label":"Model","value":2,"min":0,"max":3,"wrap":true}]} -{"v":1,"t":"rotary_event","slot":0,"delta":1,"seq":52} -{"v":1,"t":"rotary_press","slot":0,"event":"press","seq":53} -``` - -`rotary_map` is a full volatile replacement with 0–16 unique slots in 0–15. -`label` is 1–32 UTF-8 bytes with no control characters. `min`, `max`, and -`value` are integers in -1000000–1000000 with -`min <= value <= max`. `wrap` is boolean. `delta` is a non-zero integer in --127–127. Rotary press uses the key gesture enum. The Host sends a replacement -map after applying a delta; the device does not infer the authoritative value. - -### Voice control - -Profile `voice/1` controls a Host-owned microphone lifecycle: - -```json -{"v":1,"t":"voice_event","event":"start","seq":61} -{"v":1,"t":"voice_state","state":"listening","label":"Release to send"} -``` - -`voice_event.event` is `start`, `stop`, or `cancel`. `voice_state.state` is -`idle`, `listening`, `transcribing`, `submitted`, or `error`. Optional `label` -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. - -### Text - -Profile `text/1` replaces one plain-text display channel: - -```json -{"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 -characters. `content` is 0–1024 UTF-8 bytes; line feed and tab are permitted -and all other control characters are rejected. Empty content clears the -channel. Content is never executable markup, a link, or an action. The Host -privacy-filters it before transport. - -### Usage - -Profile `usage/1` publishes volatile model/token counters: - -```json -{"v":1,"t":"usage","model":"GPT-5.6","input_tokens":1234,"output_tokens":567,"cached_tokens":89,"context_used":1800,"context_limit":128000} -{"v":1,"t":"usage_clear"} -``` - -`model` is 1–64 UTF-8 bytes with no control characters. Counters are -non-negative canonical integers at most 9007199254740991. -`cached_tokens` is optional. `context_used` and `context_limit` are optional -only as a pair and require `context_used <= context_limit`. Billing, price, -plan, account, and organization data are not part of this profile. - -### Configuration - -Profile `config/1` proposes a complete atomic device-owned configuration: - -```json -{"v":1,"t":"config","rev":7,"entries":[{"key":"key.0.mode","value":"momentary"},{"key":"display.brightness","value":70}]} -{"v":1,"t":"config_result","rev":7,"status":"applied"} -``` - -`entries` contains 0–32 unique keys. A key is 1–48 ASCII characters matching -`[A-Za-z0-9][A-Za-z0-9._-]*`. A value is a boolean, integer in --1000000–1000000, or string of 0–128 UTF-8 bytes with no control characters. -Objects, arrays, floats, and null are rejected. - -The SDK validates the complete proposal before exposing it to device code. -Device code validates every supported key and commits all entries or none. -`config_result.status` is `applied` or `rejected`. An applied result has no -`code`; a rejected result requires `unknown_key`, `invalid_value`, -`storage_error`, or `unsupported`. Rejection preserves the previous revision -and configuration. - -### Shared revision and sequence behavior - -`rev` and `seq` are canonical unsigned 32-bit integers. A receiver remembers -the latest event sequence per physical source and connection. Repeating a -sequence is idempotent; a lower sequence is stale and ignored. Wraparound -starts a new epoch only after reconnect. - -Full-replacement messages accept a newer revision. Repeating an identical -revision and content is idempotent; different content at that revision fails -closed. Disconnect clears navigation, key/rotary presentation, voice state, -text, usage, and sequence/revision memory. Only a successfully applied -`config/1` payload may persist. - ## State and races The device state is Idle, Pending, or Waiting Resolution. @@ -375,6 +267,7 @@ After reconnect, the device never restores an approval from persistent storage. Experimental releases may make breaking changes with a spec version, vector, and changelog update. After 1.0, wire major `1` only gains optional fields or new negotiated profiles. Implementations reject unsupported wire majors and profiles without guessing. -The canonical examples and rejection cases are in the versioned files under -`protocol/vectors/`. Every official SDK must produce identical behavior for -all vector files. +The canonical examples and rejection cases are in +`protocol/vectors/approval-v1.json`, `protocol/vectors/status-v1.json`, and +`protocol/vectors/device-info-v1.json` and must produce identical behavior in +every official SDK. diff --git a/devices/docs/README.md b/devices/docs/README.md index 5a1c586..19b36c0 100644 --- a/devices/docs/README.md +++ b/devices/docs/README.md @@ -2,41 +2,31 @@ Choose the task you are trying to complete. -Start with the root [Quickstart](../QUICKSTART.md). It explains the -device–Host–Agent architecture, separates first-party product onboarding from -SDK development, and leads to a remote interaction or honest local protocol -proof. If a checkpoint fails, use [Troubleshooting](troubleshooting.md). - ## Understand the product and foundation - [Project overview](../README.md): product promise, public/private boundary, reference boards, and repository status. - [Migrate from Experimental 0.1 to 0.2](migration-0.1-to-0.2.md): compatibility, metadata, and Host integration changes. -- [Use cases](use-cases.md): what makers can build with the nine current profiles and their limits. -- [Foundation development blueprint](foundation-development.md): lifecycle, modules, dependency direction, exact files, change map, and the current capability set. +- [Use cases](use-cases.md): what makers build today and on the roadmap, mapped to real profiles and limits. +- [Foundation development blueprint](foundation-development.md): lifecycle, modules, dependency direction, exact files, change map, and the capability roadmap. - [Public interface catalog](interfaces.md): BLE, wire messages, Swift Host API, and portable C API. -- [Codex App Server Host guide](codex-app-server.md): choose the official rich-client surface and project only one-time approvals into `approval/1`. - [Protocol specification](../SPEC.md): normative wire, BLE, state, limits, and versioning. - [Security model](../SECURITY.md): trust boundary, minimum controls, threats, and non-claims. ## Run a working example -- [Public Quickstart](../QUICKSTART.md): understand, connect, and choose the supported first result. -- [Reference approval controller](reference-approval-controller.md): wire, build, flash, and prove the XIAO Developer Reference. -- [Public availability](availability.json): machine-readable SDK version and third-party App enrollment gate. -- [Troubleshooting](troubleshooting.md): repair setup, toolchain, flash, BLE, and Device Info failures. - [First hardware approval](first-approval.md): build, flash, enroll, and answer one request. - [macOS BLE simulator](../examples/macos-device-simulator/README.md): exercise a real iPhone without a board. ## Implement or extend - [Implementation tracks](implementation-tracks.md): reference board, Host/App, MCU/RTOS, and new-language routes. -- [Codex App Server Host guide](codex-app-server.md): exact request mapping, fail-closed policy, and authoritative settlement. - [Local development workflow](development.md): prerequisites, commands, TDD order, and CI. - [Swift Host SDK](../sdk/swift/README.md): codec, authorization, relay, coordinator, and CoreBluetooth central. - [Kotlin Host SDK](../sdk/kotlin/README.md): bounded Device Info and protocol APIs for Android hosts. - [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. - [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 deleted file mode 100644 index 930217a..0000000 --- a/devices/docs/availability.json +++ /dev/null @@ -1,17 +0,0 @@ -{ - "schemaVersion": 1, - "sdkVersion": "0.2.0-experimental.2", - "wireMajor": 1, - "checkedAt": "2026-07-31", - "developerEnrollment": { - "ios": { - "available": false, - "verifiedThrough": "App Store 2.4" - }, - "android": { - "available": false, - "verifiedThrough": "No public version verified" - } - }, - "fallback": "host-smoke" -} 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/codex-app-server.md b/devices/docs/codex-app-server.md deleted file mode 100644 index 90cbf86..0000000 --- a/devices/docs/codex-app-server.md +++ /dev/null @@ -1,122 +0,0 @@ -# Connect a Host to the official Codex App Server - -This guide explains how a Host can project a small, safe subset of official -Codex approvals onto a Nexting `approval/1` device. It is an integration -pattern, not a new wire profile. [`SPEC.md`](../SPEC.md) remains the normative -device contract. - -## Choose the correct official surface - -OpenAI publishes two related integration layers: - -- [Codex SDK](https://learn.chatgpt.com/docs/codex-sdk) is the higher-level - TypeScript and Python interface for server-side automation that starts, - resumes, and runs Codex threads. -- [Codex App Server](https://learn.chatgpt.com/docs/app-server) is the - bidirectional JSON-RPC interface for rich clients that need authentication, - conversation history, approvals, and streamed Agent events. - -A physical approval surface needs the second layer. It must observe the -server-initiated request, preserve its request identity inside the trusted -Host, return the exact official decision, and wait for -`serverRequest/resolved`. - -Keep App Server local. Use its default stdio transport, a local Unix socket, or -an authenticated localhost/loopback connection. Do not expose an unauthenticated -non-loopback listener. The device still talks only to the authorized Host over -encrypted BLE; it never connects to App Server, Codex, or a Nexting cloud -endpoint. - -## Exact projection - -| Official Codex request | Hardware eligibility | Allow result | Deny result | -| --- | --- | --- | --- | -| `item/commandExecution/requestApproval` | Only when `availableDecisions` is explicitly and exactly `["accept","decline"]`, `command` is meaningful, and no elevated field below is present | `{ "decision": "accept" }` | `{ "decision": "decline" }` | -| `item/fileChange/requestApproval` | Only when `reason` is meaningful, `grantRoot` is absent, and the Host offers exactly one-time accept/decline | `{ "decision": "accept" }` | `{ "decision": "decline" }` | -| `item/permissions/requestApproval` | Never in `approval/1` | Phone/desktop only | Phone/desktop only | -| `item/tool/requestUserInput` | Never in `approval/1` | Phone/desktop only | Phone/desktop only | -| `mcpServer/elicitation/request` | Never in `approval/1` | Phone/desktop only | Phone/desktop only | - -Reject a command request from hardware when any of these fields is present: - -- `networkApprovalContext`: this is a managed network prompt and needs - host/protocol-specific UI; -- `additionalPermissions`: the user must review the requested permission set; -- `proposedExecpolicyAmendment`: accepting can change future command policy. - -Reject a file-change request when `grantRoot` is present. Never convert -`acceptForSession`, `cancel`, an exec-policy amendment, a permissions subset, -or form content into the green Allow key. - -## Host state machine - -The Host, not the device, owns the official App Server request. The safe flow -is: - -1. Receive the official JSON-RPC request and retain its exact request ID, - method, thread, turn, and params in trusted memory. -2. Apply the eligibility rules above. A missing field, unknown version, - malformed decision list, or unhelpful summary means phone/desktop only. -3. Call `NextingDeviceRelayCoordinator.present(context:summary:ttlMs:)` with - the retained request as private `context`. Send only the opaque public - request ID, bounded summary, fixed choices, and relative TTL over BLE. -4. When the coordinator invokes `answerPrompt`, atomically claim the same - pending request against phone UI. Map Allow to - `{ "decision": "accept" }` and Deny to - `{ "decision": "decline" }`. -5. Send one JSON-RPC response using the retained App Server request ID. -6. Call `answerSucceeded(requestId:)` only after the owning App Server path - accepts or authoritatively settles the response. Call - `answerFailed(requestId:)` after a retryable delivery failure. -7. When App Server emits `serverRequest/resolved` first, cancel the matching - device request. Never clear a newer request by ID mismatch. - -Pseudocode for the policy boundary: - -```text -onCodexRequest(request): - if request.method == commandApproval - and request.availableDecisions == ["accept", "decline"] - and meaningful(request.command) - and absent(request.networkApprovalContext) - and absent(request.additionalPermissions) - and absent(request.proposedExecpolicyAmendment): - present(context=request, summary=request.command, ttlMs=30000) - - else if request.method == fileChangeApproval - and meaningful(request.reason) - and absent(request.grantRoot): - present(context=request, summary=request.reason, ttlMs=30000) - - else: - renderOnTrustedScreenOnly(request) -``` - -## What this repository provides - -The public package provides BLE, codecs, authorization primitives, the -`NextingDeviceRelayCoordinator`, answer-claim building blocks, protocol -vectors, and reference firmware. It deliberately does not publish a Codex Agent adapter, -accounts, cloud routing, or Nexting's private Bridge. - -A third-party Host supplies its own official App Server client, prompt -rendering, eligibility/risk policy, enrollment UI, and authoritative answer -path. The public SDK helps that Host talk safely to a nearby device; it does -not grant access to the user's Codex session. - -## Verify the boundary - -At minimum, test these negative cases before shipping: - -- missing or reordered `availableDecisions`; -- `acceptForSession` or `cancel`; -- `networkApprovalContext`, `additionalPermissions`, or - `proposedExecpolicyAmendment`; -- file approval with `grantRoot` or no meaningful `reason`; -- unknown request version or method; -- stale, expired, replaced, duplicate, and mismatched request IDs; -- phone and hardware answers arriving in the same event-loop turn; -- App Server settling with `serverRequest/resolved` before the BLE answer. - -Then run the shared device verification from -[Public interfaces](interfaces.md#verify-the-interfaces). diff --git a/devices/docs/conformance.md b/devices/docs/conformance.md index 211c498..cc22568 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. @@ -67,11 +67,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 2df3b0c..6add5f9 100644 --- a/devices/docs/development.md +++ b/devices/docs/development.md @@ -68,12 +68,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/first-approval.md b/devices/docs/first-approval.md index a0d41a1..7770ae0 100644 --- a/devices/docs/first-approval.md +++ b/devices/docs/first-approval.md @@ -1,19 +1,20 @@ # Run the first hardware approval -This guide explains the full approval lifecycle after the public -[Quickstart](../QUICKSTART.md) has proved the board and BLE path. +This guide gets a reference board to the point where an iPhone can present one real approval and receive an Allow or Deny button press. ## Before you start -You need one supported board, two buttons, and the public Host smoke test from -the Quickstart. The Host discovers the board, prints bounded Device Info, and -requires an encrypted notification subscription before presenting anything. +You need: -As of 2026-07-27, App Store 2.4 does not include Experimental 0.2 -developer-device enrollment. The public completion point is therefore a real -Allow or Deny returned to `nexting-device-host-smoke`. Agent end-to-end testing -begins only when the SDK page names the first supported public iOS and Android -versions. +- a real iPhone; the iOS Simulator cannot provide this Bluetooth path; +- a Debug build of the current private Nexting App; +- one supported board, two buttons, and the wiring from the firmware README; +- a Zephyr or nRF Connect SDK workspace. + +The current Nexting App exposes explicit accessory discovery, enrollment, and +revocation on iOS and Android. Scan for a nearby device, inspect its verified +Device Info table, and confirm the device before it is remembered. The App may +remember multiple accessories but holds only one active transport lease. For firmware development, `debug-test-device.conf` still provides the reference advertised name. That name helps discovery only; it is never the @@ -23,10 +24,12 @@ from read-only hardware identity. ## Build nRF52840 -From `nexting/devices`, let the public bootstrap prepare the pinned workspace: +From a configured west workspace: ```sh -./scripts/bootstrap-zephyr.sh --board xiao-nrf52840-sense --build +west build -p always -b xiao_ble/nrf52840/sense \ + /path/to/nexting-devices/firmware/zephyr \ + -- -DEXTRA_CONF_FILE=debug-test-device.conf ``` For the nRF52840 DK, replace the board target with `nrf52840dk/nrf52840`. @@ -48,12 +51,12 @@ For XIAO ESP32-S3 use `xiao_esp32s3/esp32s3/procpu`. The public CI is the reprod ## Run the approval -1. Run `swift run --package-path sdk/swift nexting-device-host-smoke`. -2. Inspect the printed Device Info and allow the encrypted BLE connection. -3. Confirm the Pending LED turns on after the Host sends `present`. -4. Press and release Allow or Deny once. -5. Confirm `PASS answer=allow` or `PASS answer=deny`, followed by LED clear - after `resolved`. +1. Launch the App on the iPhone, open **My Devices & Nearby**, and choose Scan. +2. Power the board, inspect its Device Info table, and explicitly confirm enrollment. The App should pair when it subscribes to the encrypted answer characteristic. +3. Cause Claude Code or Codex to show an eligible ordinary two-choice permission prompt. +4. Confirm the pending LED turns on and only the bounded summary is visible on the board log. +5. Press and release Allow or Deny once. +6. Confirm the originating Agent continues with that option and the LED turns off after `resolved`. Claude Code and Codex keep independent adapters and action sinks. Then complete every case in `board-verification.md`. A successful happy path alone is not enough for the `Board verified` label. @@ -70,7 +73,8 @@ This is a local developer-reference recovery path, not product enrollment or acc ## If discovery does not happen -- Confirm macOS Bluetooth is enabled and the terminal has Bluetooth permission. +- Confirm the phone is real hardware and Bluetooth is enabled. +- Confirm the App has Bluetooth permission and the device has been explicitly enrolled. - Confirm Device Info reports wire `1`, profile `approval/1`, and at least 512 message bytes. - Hold both board buttons for three seconds, then forget the old accessory on iOS if security configuration changed. - Check that the board advertises the service UUID, not only the local name. diff --git a/devices/docs/foundation-development.md b/devices/docs/foundation-development.md index 4e08b61..ce67372 100644 --- a/devices/docs/foundation-development.md +++ b/devices/docs/foundation-development.md @@ -54,41 +54,41 @@ 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. ## Host SDK files -| File | Owns | -| --- | --- | -| `sdk/swift/Sources/NextingDeviceKit/Protocol.swift` | Public messages and strict encoding/decoding | -| `Framing.swift` | Bounded newline assembly and oversize discard | -| `DeviceInfo.swift` | Peripheral capability parsing and negotiation | -| `Authorization.swift` | Stable peripheral identity and deny-by-default authorization | -| `Relay.swift` | TTL, replacement, duplicate handling, phone/device races, and two-phase completion | -| `Coordinator.swift` | Thin mapping between product prompt context and the public relay/transport | -| `State.swift` | Pure answer-claim and discovery-recovery helpers | -| `Central.swift` | CoreBluetooth discovery, encrypted setup, bounded writes, and notification input | +| File | Owns | +| --------------------------------------------------- | ---------------------------------------------------------------------------------- | +| `sdk/swift/Sources/NextingDeviceKit/Protocol.swift` | Public messages and strict encoding/decoding | +| `Framing.swift` | Bounded newline assembly and oversize discard | +| `DeviceInfo.swift` | Peripheral capability parsing and negotiation | +| `Authorization.swift` | Stable peripheral identity and deny-by-default authorization | +| `Relay.swift` | TTL, replacement, duplicate handling, phone/device races, and two-phase completion | +| `Coordinator.swift` | Thin mapping between product prompt context and the public relay/transport | +| `State.swift` | Pure answer-claim and discovery-recovery helpers | +| `Central.swift` | CoreBluetooth discovery, encrypted setup, bounded writes, and notification input | The Host product supplies enrollment UI, risk policy, internal prompt context, and the final Agent answer call. It calls `present` with a summary and TTL, then reports `answerSucceeded` or `answerFailed` only after its authoritative Agent operation returns. ## 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 | -| `examples/macos-device-simulator/main.swift` | Real BLE peripheral reference without a physical board | +| 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 | +| `examples/macos-device-simulator/main.swift` | Real BLE peripheral reference without a physical board | 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. @@ -100,44 +100,40 @@ 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 | -| Host prompt behavior | `Relay.swift` / `Coordinator.swift` | JS reference when semantics change | Swift tests + consuming Host integration tests | -| CoreBluetooth transport | `Central.swift` | simulator when peripheral behavior changes | Swift + simulator compile | -| 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 | +| Host prompt behavior | `Relay.swift` / `Coordinator.swift` | JS reference when semantics change | Swift tests + consuming Host integration tests | +| CoreBluetooth transport | `Central.swift` | simulator when peripheral behavior changes | Swift + simulator compile | +| 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 +## Beyond Experimental 0.2: the capability roadmap -Release `0.2.0-experimental.2` ships the original `approval/1` and `status/1` -profiles plus seven independent interaction profiles. Every data form has its -own versioned profile, vectors, and evidence rather than silently reusing the -`approval/1` claim. +Experimental 0.2 ships two capabilities: profile `approval/1` and profile `status/1`. The platform direction is a full physical control surface — every data form a device maker needs, each as its own versioned profile with its own vectors and evidence, never silently reusing the `approval/1` claim. The **capability declaration** is the extensibility mechanism: on connect, a device can report typed identity, buttons, rotary controls, display, haptics, standard battery support, and bounded inert vendor facts. The Host shows only -fields the device actually declares. Interactive behavior is also negotiated -explicitly through `navigation/1`, `keys/1`, `rotary/1`, `voice/1`, `text/1`, -`usage/1`, and `config/1`; it is never inferred from static metadata. - -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 | +fields the device actually declares. Interactive key events, navigation, +microphone, lighting, and configuration still need their own profiles rather +than being inferred from static metadata. + +The roadmap, modeled on the complete feature set of 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 | +| Command keys | device → Host | Physical key events (approve, decline, fork, mic, send, fast); the Host owns what each key means | +| Navigation input | device → Host | Stick direction events for radial menus and workflow selection | +| Rotary input | both | Dial events up; level lists and current level down | +| Text content | Host → device | Summaries and conversation content for screened devices | +| Voice | device → Host | Push-to-talk control, with device-microphone audio or Host-microphone capture | +| Configuration | Host → device | Key maps and lighting, so behavior changes without reflashing | +| 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..1c88379 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,15 @@ 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. + ## Hardware contract An Experimental 0.2 port needs: diff --git a/devices/docs/implementation-tracks.md b/devices/docs/implementation-tracks.md index 7fbf615..3a6eb6d 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 @@ -108,7 +108,29 @@ Your adapter owns BLE, GPIO, timers, bond lifecycle, and transport buffers. It d Run the desktop C sanitizer suite, compile the exact target, then complete the real-board checklist. Add the board to [hardware support](hardware-support.md) only at the evidence level it earned. -## Track 4: Maintain a new language SDK +## Track 4: Integrate the ILX MultiPad over USB CDC + +Choose this track when the hardware is the open-source ILX MultiPad. It is a +USB CDC binding for the same public JSON frames, not a second approval protocol +and not automatic Nexting App USB enrollment. + +1. Read the [MultiPad USB guide](multipad-usb.md) and identify the PCB variant. +2. Build `firmware/multipad` and run `npm run test:multipad` before touching the + board. +3. Add `nexting_multipad_adapter.c/.h` and `sdk/c/src/nexting_device.c` to the + upstream STM32 project. Preserve HID and the legacy `AA BB xx` commands. +4. Verify the original flash backup and boot path. Module boards can use the + serial bootloader; FPC boards require SWD/J-Link. +5. Record CDC echo, `present → answer → resolved`, expiry, and disconnect + evidence before claiming a physical integration. + +The Host still owns USB authorization, Agent routing, and final action sinks. +The device sees only bounded public frames and never receives credentials. + +## Track 5: Maintain a new language SDK + +This is the language SDK route formerly listed as **Track 4: Maintain a new language SDK**; +the number moved only to make the MultiPad hardware path visible. Choose this track for Kotlin, Rust, TypeScript, Python, or another Host/device implementation. diff --git a/devices/docs/interfaces.md b/devices/docs/interfaces.md index b4deeb8..917f77c 100644 --- a/devices/docs/interfaces.md +++ b/devices/docs/interfaces.md @@ -1,13 +1,11 @@ # Public interfaces -Nexting Devices `0.2.0-experimental.2` 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. +Nexting Devices Experimental 0.2 exposes one small 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; +- four newline-delimited message types for profile `approval/1` and one downlink message type for profile `status/1`; - a Swift Host SDK; - a Kotlin/JVM Host SDK for Android; - a portable fixed-buffer C99 device SDK; @@ -15,72 +13,47 @@ developers discover the interfaces. [`SPEC.md`](../SPEC.md) remains normative. 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. -Agent adapters remain outside this public package. A Host developer who uses -the official Codex rich-client surface can follow the -[Codex App Server mapping guide](codex-app-server.md) to project only an exact, -one-time approval into `approval/1`; the App Server connection and request -identity stay inside that Host. - ## BLE transport 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 | Host: `Central.swift`; 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 | Host: `Central.swift`; device: `main.c` + C stream | -| Uplink | `6EADC0DE-0003-4A21-9C5E-1B7F3D9E42A0` | device → Host | Report physical answers or errors | Encrypted notification subscription | Device: `main.c`; Host: `Central.swift` + Swift line decoder | -| 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`; Host: `DeviceInfo.swift` / `Central.swift` | +| Surface | UUID | Direction | Product purpose | Required behavior | Implemented by | +| --------------- | -------------------------------------- | ------------- | ----------------------------------------------------------------------------------------------------- | --------------------------------------------- | ------------------------------------------------------------ | +| Primary Service | `6EADC0DE-0001-4A21-9C5E-1B7F3D9E42A0` | — | Discover a Nexting device | Advertise the service UUID | Host: `Central.swift`; 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 | Host: `Central.swift`; device: `main.c` + C stream | +| Uplink | `6EADC0DE-0003-4A21-9C5E-1B7F3D9E42A0` | device → Host | Report physical answers or errors | Encrypted notification subscription | Device: `main.c`; Host: `Central.swift` + Swift line decoder | +| 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`; Host: `DeviceInfo.swift` / `Central.swift` | 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. -The Device Info characteristic carries the -[capability declaration](foundation-development.md#experimental-02-the-capability-set). -The Host sends or accepts a message only when the device declared that -message's exact profile. +The Device Info characteristic is also where the [capability declaration](foundation-development.md#beyond-experimental-02-the-capability-roadmap) grows: future profiles add capability entries there before any new traffic flows. ## 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`; `Protocol.swift`; `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; relay/state tests; Swift and C state | -| `resolved` | Host → device | End the current request as answered, expired, cancelled, or replaced | A nonmatching ID does not clear another request | vectors; `relay.mjs`; `Relay.swift`; C state | -| `error` | either direction | Report a bounded protocol failure when useful | An error never carries authority to approve | vectors and all three strict codecs | -| `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; all three strict codecs | -| `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`; all four codecs | -| `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`; all four codecs | -| `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`; all four codecs | -| `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`; all four codecs | -| `voice_event` / `voice_state` (`voice/1`) | both | Start, stop, cancel, and acknowledge push-to-talk control | No audio or transcript; capture and transcription remain on the Host microphone | `voice-v1.json`; all four codecs | -| `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`; all four codecs | -| `usage` / `usage_clear` (`usage/1`) | Host → device | Render a model label and bounded counters | Counters are display data, not billing authority | `usage-v1.json`; all four codecs | -| `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`; all four codecs | - -Exact fields, enums, byte limits, canonical JSON rules, nesting limits, and -state transitions are in [the specification](../SPEC.md). Executable examples -live in `protocol/vectors/`, including -[approval-v1.json](../protocol/vectors/approval-v1.json), -[status-v1.json](../protocol/vectors/status-v1.json), and -[navigation-v1.json](../protocol/vectors/navigation-v1.json). A Host sends -`status` only to a device whose Device Info declares `statusSlots` of at least -1; volatile display and interaction state clears on disconnect or reboot. +| 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`; `Protocol.swift`; `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; relay/state tests; Swift and C state | +| `resolved` | Host → device | End the current request as answered, expired, cancelled, or replaced | A nonmatching ID does not clear another request | vectors; `relay.mjs`; `Relay.swift`; C state | +| `error` | either direction | Report a bounded protocol failure when useful | An error never carries authority to approve | vectors and all three strict codecs | +| `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; all three strict codecs | + +Exact fields, enums, byte limits, canonical JSON rules, nesting limits, and state transitions are in [the specification](../SPEC.md). Executable examples live in the shared vectors: [approval-v1.json](../protocol/vectors/approval-v1.json) and [status-v1.json](../protocol/vectors/status-v1.json). A Host sends `status` only to a device whose Device Info declares `statusSlots` of at least 1; status rendering is volatile and clears on disconnect, reboot, or a new bond. ## Swift Host SDK -| Public type | Use it for | Implementation | -| --- | --- | --- | -| `NextingDeviceMessage` / `NextingDeviceCodec` | Strict wire values and newline-terminated encoding/decoding | `sdk/swift/Sources/NextingDeviceKit/Protocol.swift` | -| `requiredProfile` / `interactionSequence` | Gate messages by negotiated profile and reject replayed physical events | `Protocol.swift` | -| `NextingDeviceAgentStatus` / `NextingDeviceAgentState` | One agent-status slot value for profile `status/1` | `Protocol.swift` | -| `NextingDeviceLineDecoder` | Bounded fragmented input | `Framing.swift` | -| `NextingDeviceInfo` | Capability negotiation, including the optional `statusSlots` declaration | `DeviceInfo.swift` | -| `NextingDeviceAuthorizationStore` / `NextingDeviceAuthorizationPolicy` | Explicit enrollment and deny-by-default authorization | `Authorization.swift` | -| `NextingDevicePromptRelay` | One-current-prompt state, TTL, races, retries, and two-phase completion | `Relay.swift` | -| `NextingDeviceRelayTransport` | Transport interface consumed by the coordinator | `Coordinator.swift` | -| `NextingDeviceRelayCoordinator` | Map product prompt context into public request state | `Coordinator.swift` | -| `NextingDeviceCentral` | CoreBluetooth discovery, setup, bounded writes, and notifications | `sdk/swift/Sources/NextingDeviceKit/Central.swift` | -| `NextingDeviceAnswerClaimGate` | Share single-consumption ownership between phone and hardware | `State.swift` | +| Public type | Use it for | Implementation | +| ---------------------------------------------------------------------- | ------------------------------------------------------------------------ | --------------------------------------------------- | +| `NextingDeviceMessage` / `NextingDeviceCodec` | Strict wire values and newline-terminated encoding/decoding | `sdk/swift/Sources/NextingDeviceKit/Protocol.swift` | +| `NextingDeviceAgentStatus` / `NextingDeviceAgentState` | One agent-status slot value for profile `status/1` | `Protocol.swift` | +| `NextingDeviceLineDecoder` | Bounded fragmented input | `Framing.swift` | +| `NextingDeviceInfo` | Capability negotiation, including the optional `statusSlots` declaration | `DeviceInfo.swift` | +| `NextingDeviceAuthorizationStore` / `NextingDeviceAuthorizationPolicy` | Explicit enrollment and deny-by-default authorization | `Authorization.swift` | +| `NextingDevicePromptRelay` | One-current-prompt state, TTL, races, retries, and two-phase completion | `Relay.swift` | +| `NextingDeviceRelayTransport` | Transport interface consumed by the coordinator | `Coordinator.swift` | +| `NextingDeviceRelayCoordinator` | Map product prompt context into public request state | `Coordinator.swift` | +| `NextingDeviceCentral` | CoreBluetooth discovery, setup, bounded writes, and notifications | `sdk/swift/Sources/NextingDeviceKit/Central.swift` | +| `NextingDeviceAnswerClaimGate` | Share single-consumption ownership between phone and hardware | `State.swift` | The Host calls `present(context:summary:ttlMs:)`. A routed hardware answer invokes the Host-provided `answerPrompt` closure. The Host must call `answerSucceeded(requestId:)` only after its Agent action succeeds, or `answerFailed(requestId:)` after an authoritative failure. Phone-first answers call `phoneAnswerStarted()` before the same completion path. Cancellation calls `cancel()`. Agent-state indicators go through `publishStatus(_:)`, which sends a full-replacement status frame only when the connected device declared `statusSlots` and the link is authorized. @@ -91,26 +64,24 @@ Production Hosts must supply enrollment and revocation. Peripheral-name matching The Kotlin/JVM module mirrors the bounded wire and Device Info models needed by Android. `DeviceInfoCodec` decodes typed identity, buttons, rotary controls, display, haptics, standard Battery Service support, and inert vendor facts. -`ProtocolCodec` implements the same canonical newline-delimited nine-profile -Wire and limits. `DeviceMessage.requiredProfile` gates negotiated features and -`interactionSequence` exposes sequence sources for replay rejection. Android owns Bluetooth permission, +`ProtocolCodec` implements the same canonical newline-delimited +`approval/1`/`status/1` values and limits. Android owns Bluetooth permission, GATT lifecycle, encrypted authorization storage, account metadata, and UI. ## 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 | +| 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` | 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. @@ -129,29 +100,29 @@ The JavaScript reference verifies readable wire and relay behavior. Swift verifi ## 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.2 | +| ---------------- | -------------------------------------------------------------------- | +| Wire version | `1` | +| Profiles | `approval/1` and `status/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 -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 -identity remain outside this release. Nothing may silently reuse an -Experimental 0.2 compatibility claim. +Typed Device Info counts describe hardware only. Command keys, navigation, +rotary input, text content, voice, and configuration still require their own +versioned profiles, vectors, and evidence before devices may claim them. Wi-Fi, +HTTP, MQTT, USB, multiple prompts, and production identity also require +explicit future contracts. Nothing may silently reuse the Experimental 0.2 +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 4663702..b01de92 100644 --- a/devices/docs/migration-0.1-to-0.2.md +++ b/devices/docs/migration-0.1-to-0.2.md @@ -7,7 +7,7 @@ new Host SDK surfaces; it does not grant new approval authority to a device. ## Device firmware -1. Change the Device Info `spec` string to `0.2.0-experimental.2`. +1. Change the Device Info `spec` string to `0.2.0-experimental.0`. 2. Keep every required 0.1 field and its existing bound. 3. Add only capabilities the physical device actually implements: `device_id`, manufacturer descriptors, button and rotary counts, display, @@ -25,7 +25,7 @@ record and revoke it explicitly. ## Apple hosts -- Update `NextingDeviceKit` to `0.2.0-experimental.2`. +- Update `NextingDeviceKit` to `0.2.0-experimental.0`. - Render `NextingDeviceInfo` as an ordered key/value table. Omit absent capabilities rather than filling the screen with empty placeholders. - Read live battery from the standard Battery Service only when declared. @@ -57,12 +57,12 @@ support, and vendor facts remain read-only values reported by the device. ## Compatibility -| Pair | Expected behavior | -| --- | --- | -| 0.2 Host + valid 0.1 device | Approval/status continue; new metadata rows are absent | -| 0.1 Host + 0.2 device | Required fields continue; unknown bounded optional fields are ignored | -| 0.2 Host + malformed optional vendor block | Core Device Info remains; vendor block is dropped | -| Unsupported wire/profile | Connection fails closed as before | +| Pair | Expected behavior | +| ------------------------------------------ | --------------------------------------------------------------------- | +| 0.2 Host + valid 0.1 device | Approval/status continue; new metadata rows are absent | +| 0.1 Host + 0.2 device | Required fields continue; unknown bounded optional fields are ignored | +| 0.2 Host + 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..8c3c021 --- /dev/null +++ b/devices/docs/multipad-usb.md @@ -0,0 +1,110 @@ +# MultiPad USB CDC 开发指南 + +这是一条给已经买到 ILX MultiPad 的开发者路径。它和 BLE 参考板是两种 +不同的传输:MultiPad 先作为普通 USB HID 键盘工作,再通过 USB CDC ACM +通道接收配置和 Nexting JSON。它不直接连接 Claude Code、Codex 或 Nexting +云;Host/App 仍然负责授权、会话和最终动作。 + +## 先知道你手上的硬件是哪一版 + +先看[上游仓库](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 自救 | + +拆机前不要猜显示屏、电池、序列号或开关位置。请先拔掉 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 4c250fd..e6dec39 100644 --- a/devices/docs/project-status.md +++ b/devices/docs/project-status.md @@ -1,38 +1,34 @@ # Project status -Snapshot: 2026-07-29. Release `0.2.0-experimental.2` is implemented as the open -Devices SDK inside the existing `Nexting-ai/nexting` repository under -`devices/`. +Snapshot: 2026-07-27. Experimental 0.2 is implemented as the open Devices SDK +inside the existing `Nexting-ai/nexting` repository under `devices/`. + +The ILX MultiPad work is a separate USB CDC developer track. The adapter and +host-side contract test are prepared; no physical MultiPad flash or App USB +enrollment claim has been made yet. The purchased board must be opened to +identify its module/FPC boot path before any write. ## 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, simulator, and export tests | Passing | -| Swift Host SDK | Nine-profile codec, Device Info/profile negotiation, sequence sources, authorization, relay, coordinator, CoreBluetooth, and SwiftPM tests | Passing | -| Kotlin Host SDK | Matching nine-profile codec, Device Info negotiation, and sequence sources used directly by Android | Passing | -| Portable C99 SDK | Fixed-buffer nine-profile protocol, approval/status state, Device Info, ASan/UBSan suites | Passing | -| iOS App integration | Explicit enrollment/revocation, secure remembered devices, one active lease, battery, continuous information table, metadata sync, independent Claude Code/Codex adapters | iOS Simulator build and integration contracts pass | -| Android App integration | Public Kotlin SDK, BLE enrollment, encrypted authorization storage, battery, continuous information table, metadata sync, independent Claude Code/Codex adapters | Debug APK, unit tests, and lint pass | -| 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, root SwiftPM package, 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 | `approval/1`, `status/1`, bounded Device Info 0.2, JSON Schema, valid and hostile vectors | Passing; wire major remains `1` | +| JavaScript reference | Protocol, framing, relay, Device Info, documentation, simulator, and export tests | Passing | +| Swift Host SDK | Codec, Device Info, authorization, relay, coordinator, CoreBluetooth, and SwiftPM tests | Passing | +| Kotlin Host SDK | Bounded Device Info and protocol codecs used directly by Android | Passing | +| Portable C99 SDK | Fixed-buffer protocol, state, Device Info, ASan/UBSan suites | Passing | +| iOS App integration | Explicit enrollment/revocation, secure remembered devices, one active lease, battery, continuous information table, metadata sync, independent Claude Code/Codex adapters | iOS Simulator build and integration contracts pass | +| Android App integration | Public Kotlin SDK, BLE enrollment, encrypted authorization storage, battery, continuous information table, metadata sync, independent Claude Code/Codex adapters | Debug APK, unit tests, and lint pass | +| 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, root SwiftPM package, 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 | +| ILX MultiPad | STM32F103VET6 USB HID + CDC upstream source; portable C99 adapter and contract test | Adapter test passing; PCB and boot-path evidence pending | ## What 0.2 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 in iOS and Android integration - so undeclared, replayed, or out-of-order physical input is discarded. -- Host-only microphone capture for push-to-talk: `voice/1` carries control - events and acknowledgement, never audio or transcripts. -- Atomic remote configuration. One invalid setting rejects the complete update - without changing the active configuration. - Optional typed identity and hardware capabilities: buttons, approval/custom buttons, rotary controls, rotary presses, display, haptics, standard Battery Service support, and inert bounded vendor facts. @@ -51,7 +47,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-07-27 verification environment. Therefore: - no real-radio, pairing, reconnect, battery, button, or cross-platform @@ -79,9 +75,8 @@ the public `devices/` subtree. - Passing source tests does not prove a radio works. - A successful firmware build does not prove pairing, notification delivery, race handling, battery reporting, or bond revocation. -- Device Info capabilities describe hardware; versioned profile declarations - separately authorize behavior. Static button or dial counts never imply - `keys/1` or `rotary/1`. +- Device Info capabilities describe hardware; they do not create unversioned + command, rotary-event, voice, text, or configuration profiles. - User metadata and `device_id` are not authorization credentials. - A Developer Reference device is not a production security certification. diff --git a/devices/docs/reference-approval-controller.md b/devices/docs/reference-approval-controller.md deleted file mode 100644 index 552c97f..0000000 --- a/devices/docs/reference-approval-controller.md +++ /dev/null @@ -1,96 +0,0 @@ -# Build the reference approval controller - -This Developer Reference uses two buttons to demonstrate `approval/1`. It is -not the only or default shape of a Nexting device. A successful smoke test -proves a bounded physical approval path, not production identity, signed -firmware, live Agent mapping, or physical security. - -## 1. Gather and wire - -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 | - -The reference firmware enables internal pull-ups. Do not connect either input -to 3.3 V. - -## 2. Get the exact release - -```sh -git clone --branch devices-v0.2.0-experimental.2 --depth 1 \ - https://github.com/Nexting-ai/nexting.git -cd nexting/devices -``` - -## 3. Build - -On macOS or Linux with Python 3.11+: - -```sh -./scripts/bootstrap-zephyr.sh \ - --board xiao-nrf52840-sense \ - --install-sdk \ - --build -``` - -The script creates an isolated Python environment beside `devices/`, -initializes the pinned Zephyr 4.3.0 workspace, installs west 1.5.0, optionally -installs Zephyr SDK 0.17.4, and builds: - -```text -devices/build/xiao-nrf52840-sense/zephyr/zephyr.uf2 -``` - -Rerunning is safe. Use `--dry-run` to inspect every resolved version and path -without changing the workspace. - -## 4. Flash - -1. Connect the XIAO over USB-C. -2. Double-press Reset. A volume named `XIAO BLE` appears. -3. Copy `zephyr.uf2` to that volume. It ejects automatically. -4. The Pending LED remains off until a Host presents an approval. - -## 5. Prove the public BLE path - -From `nexting/devices` on macOS: - -```sh -swift run --package-path sdk/swift nexting-device-host-smoke \ - --summary "Allow the Nexting hardware smoke test?" -``` - -Allow Bluetooth access. The Host prints the discovered device, Device Info, and -connection state. Press Allow or Deny once. Success is explicit: - -```text -PASS answer=allow -``` - -or: - -```text -PASS answer=deny -``` - -This proves discovery, encrypted subscription, bounded framing, the -`approval/1` state machine, and a real button without requiring Agent -credentials. - -## 6. Understand the evidence boundary - -The result is a local protocol proof. It is not production enrollment or a live -Agent connection, and it does not make this Developer Reference a certified -approval device. Before a production claim, add authenticated application -identity, signed firmware, explicit enrollment and revocation, and the dated -real-board evidence required by -[Conformance](conformance.md) and -[Board verification](board-verification.md). - -Public third-party enrollment is tracked in -[`availability.json`](availability.json). Do not use an unpublished App build -or weaken BLE authorization while that gate is closed. diff --git a/devices/docs/troubleshooting.md b/devices/docs/troubleshooting.md deleted file mode 100644 index d725d46..0000000 --- a/devices/docs/troubleshooting.md +++ /dev/null @@ -1,56 +0,0 @@ -# Troubleshooting - -Start with the first failed checkpoint. Keep BLE encryption enabled; a security -failure is not repaired by making a characteristic public. - -## `west: unknown command "build"` - -You ran a globally installed `west` outside an initialized workspace. From -`nexting/devices`, use the repository bootstrap: - -```sh -./scripts/bootstrap-zephyr.sh --board xiao-nrf52840-sense --build -``` - -It creates `.west` beside `devices/` and uses its own west 1.5.0 virtual -environment. If the next error names a missing ARM toolchain, rerun once with -`--install-sdk`. - -## `XIAO BLE` never appears - -- Use a data-capable USB-C cable. -- Double-press Reset quickly. -- Try a direct USB port instead of a charge-only hub. -- Confirm you built the `xiao_ble/nrf52840/sense` target and copied - `zephyr.uf2`, not an ELF file. - -## The Host finds no Bluetooth device - -- Turn Bluetooth on and grant the terminal Bluetooth permission in System - Settings → Privacy & Security → Bluetooth. -- Confirm the board advertises the service UUID `6EADC0DE-0001-4A21-9C5E-1B7F3D9E42A0`. -- Hold Allow and Deny for three seconds, forget the old macOS bond, and retry. -- Do not authorize by advertised name alone. - -## The Host rejects Device Info - -The smoke test requires wire major `1`, `approval/1`, at least two declared -buttons, and a bounded message size of at least 512 bytes. Reflash the tagged -reference firmware if Device Info is missing or malformed. A user name, serial, -battery value, or vendor fact is display metadata—not authorization. - -## It connects but no answer arrives - -- Verify Allow is D0-to-GND and Deny is D1-to-GND. -- Press and release one button; holding both starts the bond-reset gesture. -- Confirm the Host subscribed to the encrypted answer characteristic before it - sent `present`. -- Run the Host again and keep the terminal visible for the exact timeout or - protocol error. - -## Agent connection is unavailable - -App Store 2.4 does not yet enroll Experimental 0.2 developer devices. Passing -the public Host smoke test is the current external-developer completion point. -Agent connection becomes public only when the SDK page names a verified iOS and -Android release. diff --git a/devices/docs/use-cases.md b/devices/docs/use-cases.md index c399218..2e81e50 100644 --- a/devices/docs/use-cases.md +++ b/devices/docs/use-cases.md @@ -4,7 +4,7 @@ What people build on Nexting Devices, and which profiles each product consumes. Each scenario lists the profiles it uses today and the evidence level it can realistically claim. Claim wording is governed by [the conformance guide](conformance.md); build routes are in [the implementation tracks](implementation-tracks.md). -## Available profiles +## Today: profiles `approval/1` and `status/1` ### 1. Two-button approval pad @@ -37,26 +37,22 @@ A clip-on badge: one RGB LED for the current slot-0 agent state, one button that - Profiles: `approval/1` + `status/1` with `statusSlots: 1`. - Hardware: smallest XIAO-class board, one LED, one button; battery-friendly because both profiles are idle-quiet — traffic only flows on state changes. -## Full control-surface scenarios +## Roadmap scenarios -These profiles ship in `0.2.0-experimental.2` with normative SPEC sections, -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. +These need profiles that are specified but not yet shipped. They are listed so makers can plan hardware; do not claim them until each profile lands with its own vectors and evidence. See [the capability roadmap](foundation-development.md#beyond-experimental-02-the-capability-roadmap). -| 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 | +| Scenario | Needs | Product shape | +| --------------------- | ------------------------ | ------------------------------------------------------------------------------------------------ | +| Full command macropad | Command keys profile | 8–13 keys mapped to agent commands (approve, decline, fork, send, fast) with status backlighting | +| Menu navigator | Navigation input profile | Stick or wheel for radial menus and workflow selection | +| Rotary controller | Rotary profile | Dial scrubbing through option levels, with the current level pushed down to the device | +| Reader device | Text content profile | Larger conversation content for screened devices | +| Talk-to-agent remote | Voice profile | Push-to-talk key with device-microphone audio or Host-side capture | +| Reconfigurable pad | Configuration profile | Key maps and lighting downloaded from the Host, no reflash | ## Rules every scenario inherits - Deny by default: a device does nothing until the user explicitly authorizes it in the Host App; revocation stops all traffic. - Fail closed: malformed, stale, oversized, or unauthorized input never approves anything and never corrupts the display. - Volatile state: approvals and status live in RAM and clear on disconnect, reboot, or a new bond. -- Honest capabilities: declare only what the hardware implements. A device with one LED declares `statusSlots: 1`, not 8; a device without a dial omits `rotary/1` and receives no rotary map. +- Honest capabilities: declare only what the hardware renders. A device with one LED declares `statusSlots: 1`, not 8; a device with no indicator omits the field and receives no status traffic. diff --git a/devices/examples/macos-device-simulator/main.swift b/devices/examples/macos-device-simulator/main.swift index 07f5dfd..e664835 100644 --- a/devices/examples/macos-device-simulator/main.swift +++ b/devices/examples/macos-device-simulator/main.swift @@ -9,7 +9,7 @@ private let deviceInfoUUID = CBUUID(string: "6EADC0DE-0004-4A21-9C5E-1B7F3D9E42A private let maximumMessageBytes = 4096 private let answerRetryInterval = 1.0 private let deviceInfoData = Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.2\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"macos-device-simulator\",\"fw\":\"0.2.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240,\"display_name\":\"Mac Device Simulator\",\"button_count\":2,\"approval_button_count\":2,\"custom_button_count\":0}" + "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.0\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"macos-device-simulator\",\"fw\":\"0.2.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240,\"display_name\":\"Mac Device Simulator\",\"button_count\":2,\"approval_button_count\":2,\"custom_button_count\":0}" .utf8 ) 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..93a44f3 --- /dev/null +++ b/devices/firmware/multipad/README.md @@ -0,0 +1,101 @@ +# 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. + +## 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. + +## 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/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/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/package.json b/devices/firmware/zephyr/package.json index 7c92bda..cc5b801 100644 --- a/devices/firmware/zephyr/package.json +++ b/devices/firmware/zephyr/package.json @@ -1,6 +1,6 @@ { "name": "@nexting-ai/device-zephyr-contract", - "version": "0.2.0-experimental.2", + "version": "0.2.0-experimental.0", "private": true, "type": "module", "scripts": { diff --git a/devices/firmware/zephyr/src/main.c b/devices/firmware/zephyr/src/main.c index d1aa5d7..f555666 100644 --- a/devices/firmware/zephyr/src/main.c +++ b/devices/firmware/zephyr/src/main.c @@ -32,7 +32,7 @@ BT_UUID_128_ENCODE(0x6eadc0de, 0x0004, 0x4a21, 0x9c5e, 0x1b7f3d9e42a0) #define DEVICE_INFO_JSON \ - "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.2\"," \ + "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.0\"," \ "\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"" CONFIG_BOARD \ "\",\"fw\":\"0.2.0\",\"max_message_bytes\":4096," \ "\"max_summary_bytes\":240,\"display_name\":\"Nexting Reference\"," \ diff --git a/devices/firmware/zephyr/tests/firmware-contract.test.mjs b/devices/firmware/zephyr/tests/firmware-contract.test.mjs index 4a7931e..5f23ca5 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,10 +33,13 @@ 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/); + assert.match(main, /0\.2\.0-experimental\.0/); assert.match(main, /button_count/); assert.match(main, /approval_button_count/); assert.match(main, /bt_conn_set_security\([\s\S]*BT_SECURITY_L2/); @@ -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..93b8478 100644 --- a/devices/package.json +++ b/devices/package.json @@ -1,14 +1,15 @@ { "name": "@nexting-ai/devices", - "version": "0.2.0-experimental.2", + "version": "0.2.0-experimental.0", "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:scripts && npm run test:firmware && npm run test:multipad", "test:reference": "node --test reference/js/test/*.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", "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/config-v1.json b/devices/protocol/vectors/config-v1.json deleted file mode 100644 index 2a20733..0000000 --- a/devices/protocol/vectors/config-v1.json +++ /dev/null @@ -1,36 +0,0 @@ -{ - "spec": "0.2.0-experimental.2", - "wire": 1, - "profile": "config/1", - "valid": [ - { - "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}]} - }, - { - "name": "clear configuration", - "wire": "{\"v\":1,\"t\":\"config\",\"rev\":8,\"entries\":[]}\n", - "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"} - }, - { - "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"} - } - ], - "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"} - ] -} diff --git a/devices/protocol/vectors/device-info-v1.json b/devices/protocol/vectors/device-info-v1.json index 0196748..cfa2ac2 100644 --- a/devices/protocol/vectors/device-info-v1.json +++ b/devices/protocol/vectors/device-info-v1.json @@ -1,10 +1,10 @@ { - "spec": "0.2.0-experimental.2", + "spec": "0.2.0-experimental.0", "wire": 1, "valid": [ { "name": "required core only", - "wire": "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.2\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-nrf52840-ref\",\"fw\":\"0.2.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240}", + "wire": "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.0\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-nrf52840-ref\",\"fw\":\"0.2.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240}", "decoded": { "model": "xiao-nrf52840-ref", "statusSlots": 0, @@ -16,7 +16,7 @@ }, { "name": "complete extensible macropad", - "wire": "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.2\",\"wire\":[1],\"profiles\":[\"approval/1\",\"status/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,\"display\":{\"type\":\"oled\",\"width\":128,\"height\":64},\"haptics\":[\"click\",\"success\"],\"vendor\":{\"namespace\":\"com.ilx.multipad\",\"facts\":[{\"key\":\"layers\",\"label\":\"Key layers\",\"value\":\"4\"},{\"key\":\"socket\",\"label\":\"Socket\",\"value\":\"Kailh hot-swap\"}]}}", + "wire": "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.0\",\"wire\":[1],\"profiles\":[\"approval/1\",\"status/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,\"display\":{\"type\":\"oled\",\"width\":128,\"height\":64},\"haptics\":[\"click\",\"success\"],\"vendor\":{\"namespace\":\"com.ilx.multipad\",\"facts\":[{\"key\":\"layers\",\"label\":\"Key layers\",\"value\":\"4\"},{\"key\":\"socket\",\"label\":\"Socket\",\"value\":\"Kailh hot-swap\"}]}}", "decoded": { "model": "Multi Pad", "statusSlots": 3, @@ -28,7 +28,7 @@ }, { "name": "unknown bounded optional field is ignored", - "wire": "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.2\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"DIY Key\",\"fw\":\"7\",\"max_message_bytes\":1024,\"max_summary_bytes\":120,\"future_capability\":{\"level\":2}}", + "wire": "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.0\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"DIY Key\",\"fw\":\"7\",\"max_message_bytes\":1024,\"max_summary_bytes\":120,\"future_capability\":{\"level\":2}}", "decoded": { "model": "DIY Key", "statusSlots": 0, @@ -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 deleted file mode 100644 index 5b0070d..0000000 --- a/devices/protocol/vectors/keys-v1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "spec": "0.2.0-experimental.2", - "wire": 1, - "profile": "keys/1", - "valid": [ - { - "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]}]} - }, - { - "name": "clear key presentation", - "wire": "{\"v\":1,\"t\":\"keymap\",\"rev\":5,\"keys\":[]}\n", - "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} - } - ], - "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"} - ] -} diff --git a/devices/protocol/vectors/navigation-v1.json b/devices/protocol/vectors/navigation-v1.json deleted file mode 100644 index 4587bae..0000000 --- a/devices/protocol/vectors/navigation-v1.json +++ /dev/null @@ -1,37 +0,0 @@ -{ - "spec": "0.2.0-experimental.2", - "wire": 1, - "profile": "navigation/1", - "valid": [ - { - "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} - }, - { - "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} - }, - { - "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} - }, - { - "name": "resolve selected", - "wire": "{\"v\":1,\"t\":\"nav_resolved\",\"id\":\"q7\",\"r\":\"selected\"}\n", - "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"} - ] -} diff --git a/devices/protocol/vectors/rotary-v1.json b/devices/protocol/vectors/rotary-v1.json deleted file mode 100644 index 6d83589..0000000 --- a/devices/protocol/vectors/rotary-v1.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "spec": "0.2.0-experimental.2", - "wire": 1, - "profile": "rotary/1", - "valid": [ - { - "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}]} - }, - { - "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} - }, - { - "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} - } - ], - "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"} - ] -} diff --git a/devices/protocol/vectors/text-v1.json b/devices/protocol/vectors/text-v1.json deleted file mode 100644 index 6533413..0000000 --- a/devices/protocol/vectors/text-v1.json +++ /dev/null @@ -1,28 +0,0 @@ -{ - "spec": "0.2.0-experimental.2", - "wire": 1, - "profile": "text/1", - "valid": [ - { - "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"} - }, - { - "name": "clear channel", - "wire": "{\"v\":1,\"t\":\"text\",\"channel\":7,\"content\":\"\"}\n", - "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"} - } - ], - "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"} - ] -} diff --git a/devices/protocol/vectors/usage-v1.json b/devices/protocol/vectors/usage-v1.json deleted file mode 100644 index 0fa735e..0000000 --- a/devices/protocol/vectors/usage-v1.json +++ /dev/null @@ -1,31 +0,0 @@ -{ - "spec": "0.2.0-experimental.2", - "wire": 1, - "profile": "usage/1", - "valid": [ - { - "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} - }, - { - "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} - }, - { - "name": "clear usage", - "wire": "{\"v\":1,\"t\":\"usage_clear\"}\n", - "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"} - ] -} diff --git a/devices/protocol/vectors/voice-v1.json b/devices/protocol/vectors/voice-v1.json deleted file mode 100644 index 68ddc8a..0000000 --- a/devices/protocol/vectors/voice-v1.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "spec": "0.2.0-experimental.2", - "wire": 1, - "profile": "voice/1", - "valid": [ - { - "name": "start host microphone", - "wire": "{\"v\":1,\"t\":\"voice_event\",\"event\":\"start\",\"seq\":61}\n", - "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} - }, - { - "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"} - }, - { - "name": "idle without label", - "wire": "{\"v\":1,\"t\":\"voice_state\",\"state\":\"idle\"}\n", - "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"} - ] -} diff --git a/devices/reference/js/package.json b/devices/reference/js/package.json index d0f953e..5240a9a 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.2.0-experimental.0", "private": true, "description": "Executable JavaScript reference for the Nexting Device Protocol.", "type": "module", diff --git a/devices/reference/js/src/device-info.mjs b/devices/reference/js/src/device-info.mjs index 1eddd90..f958d85 100644 --- a/devices/reference/js/src/device-info.mjs +++ b/devices/reference/js/src/device-info.mjs @@ -8,20 +8,11 @@ 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; -export function supportsProfile(deviceInfo, profile) { - return ( - deviceInfo !== null && - typeof deviceInfo === "object" && - Array.isArray(deviceInfo.profiles) && - typeof profile === "string" && - deviceInfo.profiles.includes(profile) - ); -} - const knownFields = new Set([ "protocol", "spec", @@ -243,7 +234,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; @@ -335,7 +327,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; } } 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..a7b9696 100644 --- a/devices/reference/js/src/protocol.mjs +++ b/devices/reference/js/src/protocol.mjs @@ -4,28 +4,11 @@ export { MAX_VENDOR_BYTES, MAX_VENDOR_FACTS, decodeDeviceInfo, - supportsProfile, } from "./device-info.mjs"; export const WIRE_VERSION = 1; export const PROFILE = "approval/1"; export const STATUS_PROFILE = "status/1"; -export const NAVIGATION_PROFILE = "navigation/1"; -export const KEYS_PROFILE = "keys/1"; -export const ROTARY_PROFILE = "rotary/1"; -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 INTERACTION_PROFILES = [ - NAVIGATION_PROFILE, - KEYS_PROFILE, - ROTARY_PROFILE, - VOICE_PROFILE, - TEXT_PROFILE, - USAGE_PROFILE, - CONFIG_PROFILE, -]; export const CHOICES = ["allow", "deny"]; export const STATUS_STATES = [ "idle", @@ -57,22 +40,6 @@ export const MAX_TTL_MS = 300_000; export const MAX_MESSAGE_BYTES = 4_096; export const MAX_STATUS_AGENTS = 8; export const MAX_STATUS_LABEL_BYTES = 64; -export const MAX_SEQUENCE = 4_294_967_295; -export const MAX_SAFE_COUNTER = Number.MAX_SAFE_INTEGER; - -const NAV_DIRECTIONS = ["prev", "next", "up", "down", "left", "right"]; -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 CONFIG_STATUSES = ["applied", "rejected"]; -const CONFIG_ERROR_CODES = [ - "unknown_key", - "invalid_value", - "storage_error", - "unsupported", -]; const idPattern = /^[A-Za-z0-9._:-]+$/; const encoder = new TextEncoder(); @@ -88,45 +55,6 @@ const knownWireFields = new Set([ "r", "code", "agents", - "items", - "cursor", - "dir", - "seq", - "index", - "rev", - "keys", - "slot", - "event", - "controls", - "delta", - "state", - "label", - "channel", - "title", - "content", - "model", - "input_tokens", - "output_tokens", - "cached_tokens", - "context_used", - "context_limit", - "entries", - "status", -]); -const canonicalUnsignedFields = new Set([ - "v", - "ttl", - "seq", - "rev", - "cursor", - "index", - "slot", - "channel", - "input_tokens", - "output_tokens", - "cached_tokens", - "context_used", - "context_limit", ]); // eslint-disable-next-line no-control-regex -- the protocol bans control characters on purpose @@ -179,121 +107,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; - } - return true; -} - -function isObject(value) { - return value !== null && typeof value === "object" && !Array.isArray(value); -} - -function hasOnlyFields(value, fields) { - return isObject(value) && Object.keys(value).every((field) => fields.includes(field)); -} - -function validBoundedText(value, minimum, maximum, allowNewlineAndTab = false) { - if ( - typeof value !== "string" || - byteLength(value) < minimum || - byteLength(value) > maximum - ) return false; - const pattern = allowNewlineAndTab - ? /[\u0000-\u0008\u000b-\u001f\u007f]/ - : controlCharacterPattern; - return !pattern.test(value); -} - -function validU32(value) { - return Number.isInteger(value) && value >= 0 && value <= MAX_SEQUENCE; -} - -function validSafeCounter(value) { - return Number.isSafeInteger(value) && value >= 0; -} - -function validUniqueSlots(value, maximumItems, maximumSlot, validator) { - if (!Array.isArray(value) || value.length > maximumItems) return false; - const slots = new Set(); - for (const item of value) { - if ( - !isObject(item) || - !Number.isInteger(item.slot) || - item.slot < 0 || - item.slot > maximumSlot || - slots.has(item.slot) || - !validator(item) - ) return false; - slots.add(item.slot); - } - return true; -} - -function validNavigationItems(value) { - return ( - Array.isArray(value) && - value.length >= 2 && - value.length <= 8 && - value.every((item) => validBoundedText(item, 1, 64)) && - new Set(value).size === value.length - ); -} - -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))), - ); -} - -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", - ); -} - -const configKeyPattern = /^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$/; - -function validConfigValue(value) { - return ( - typeof value === "boolean" || - (Number.isInteger(value) && value >= -1_000_000 && value <= 1_000_000) || - validBoundedText(value, 0, 128) - ); -} - -function validConfigEntries(value) { - if (!Array.isArray(value) || value.length > 32) return false; - const keys = new Set(); - for (const entry of value) { - if ( - !hasOnlyFields(entry, ["key", "value"]) || - typeof entry.key !== "string" || - !configKeyPattern.test(entry.key) || - keys.has(entry.key) || - !validConfigValue(entry.value) - ) return false; - keys.add(entry.key); + if (agent.label !== undefined && !validStatusLabel(agent.label)) + return false; } return true; } @@ -314,17 +137,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 +218,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); @@ -410,9 +235,10 @@ function rawObjectIsSafe(text) { seenKnownFields.add(key); } if ( - canonicalUnsignedFields.has(key) && + (key === "v" || key === "ttl") && !hasCanonicalUnsignedIntegerValue(text, end) - ) return false; + ) + return false; expectingTopLevelKey = false; } index = end; @@ -464,7 +290,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 +314,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" }; @@ -509,240 +340,6 @@ export function encode(message) { }; break; } - case "navPresent": - if ( - !validId(message.requestId) || - !validNavigationItems(message.items) || - !Number.isInteger(message.cursor) || - message.cursor < 0 || - message.cursor >= message.items.length || - !validTTL(message.ttlMs) - ) return null; - wire = { - v: WIRE_VERSION, - t: "nav_present", - id: message.requestId, - items: message.items, - cursor: message.cursor, - ttl: message.ttlMs, - }; - break; - case "navMove": - if ( - !validId(message.requestId) || - !NAV_DIRECTIONS.includes(message.direction) || - !validU32(message.sequence) - ) return null; - wire = { - v: WIRE_VERSION, - t: "nav_move", - id: message.requestId, - dir: message.direction, - seq: message.sequence, - }; - break; - case "navSelect": - if ( - !validId(message.requestId) || - !Number.isInteger(message.index) || - message.index < 0 || - message.index > 7 || - !validU32(message.sequence) - ) return null; - wire = { - v: WIRE_VERSION, - t: "nav_select", - id: message.requestId, - index: message.index, - seq: message.sequence, - }; - break; - case "navResolved": - if ( - !validId(message.requestId) || - !NAV_RESOLUTION_REASONS.includes(message.reason) - ) return null; - wire = { - v: WIRE_VERSION, - t: "nav_resolved", - id: message.requestId, - r: message.reason, - }; - break; - case "keymap": - if (!validU32(message.revision) || !validKeyPresentations(message.keys)) { - return null; - } - wire = { - v: WIRE_VERSION, - t: "keymap", - rev: message.revision, - keys: message.keys, - }; - break; - case "keyEvent": - if ( - !Number.isInteger(message.slot) || - message.slot < 0 || - message.slot > 63 || - !GESTURES.includes(message.event) || - !validU32(message.sequence) - ) return null; - wire = { - v: WIRE_VERSION, - t: "key_event", - slot: message.slot, - event: message.event, - seq: message.sequence, - }; - break; - case "rotaryMap": - if (!validU32(message.revision) || !Array.isArray(message.controls)) return null; - { - const controls = message.controls.map((item) => ({ - slot: item.slot, - label: item.label, - value: item.value, - min: item.minimum, - max: item.maximum, - wrap: item.wrap, - })); - if (!validRotaryControls(controls)) return null; - wire = { - v: WIRE_VERSION, - t: "rotary_map", - rev: message.revision, - controls, - }; - } - break; - case "rotaryEvent": - if ( - !Number.isInteger(message.slot) || - message.slot < 0 || - message.slot > 15 || - !Number.isInteger(message.delta) || - message.delta === 0 || - message.delta < -127 || - message.delta > 127 || - !validU32(message.sequence) - ) return null; - wire = { - v: WIRE_VERSION, - t: "rotary_event", - slot: message.slot, - delta: message.delta, - seq: message.sequence, - }; - break; - case "rotaryPress": - if ( - !Number.isInteger(message.slot) || - message.slot < 0 || - message.slot > 15 || - !GESTURES.includes(message.event) || - !validU32(message.sequence) - ) return null; - wire = { - v: WIRE_VERSION, - t: "rotary_press", - slot: message.slot, - event: message.event, - seq: message.sequence, - }; - break; - case "voiceEvent": - if (!VOICE_EVENTS.includes(message.event) || !validU32(message.sequence)) { - return null; - } - wire = { - v: WIRE_VERSION, - t: "voice_event", - event: message.event, - seq: message.sequence, - }; - break; - case "voiceState": - if ( - !VOICE_STATES.includes(message.state) || - (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; - case "text": - if ( - !Number.isInteger(message.channel) || - message.channel < 0 || - message.channel > 7 || - (message.title !== undefined && - !validBoundedText(message.title, 1, 64)) || - !validBoundedText(message.content, 0, 1024, true) - ) return null; - wire = { v: WIRE_VERSION, t: "text", channel: message.channel }; - if (message.title !== undefined) wire.title = message.title; - wire.content = message.content; - break; - case "usage": - if ( - !validBoundedText(message.model, 1, 64) || - !validSafeCounter(message.inputTokens) || - !validSafeCounter(message.outputTokens) || - (message.cachedTokens !== undefined && - !validSafeCounter(message.cachedTokens)) || - ((message.contextUsed === undefined) !== - (message.contextLimit === undefined)) || - (message.contextUsed !== undefined && - (!validSafeCounter(message.contextUsed) || - !validSafeCounter(message.contextLimit) || - message.contextUsed > message.contextLimit)) - ) return null; - wire = { - v: WIRE_VERSION, - t: "usage", - model: message.model, - input_tokens: message.inputTokens, - output_tokens: message.outputTokens, - }; - if (message.cachedTokens !== undefined) { - wire.cached_tokens = message.cachedTokens; - } - if (message.contextUsed !== undefined) { - wire.context_used = message.contextUsed; - wire.context_limit = message.contextLimit; - } - break; - case "usageClear": - wire = { v: WIRE_VERSION, t: "usage_clear" }; - break; - case "config": - if (!validU32(message.revision) || !validConfigEntries(message.entries)) { - return null; - } - wire = { - v: WIRE_VERSION, - t: "config", - rev: message.revision, - entries: message.entries, - }; - break; - case "configResult": - if ( - !validU32(message.revision) || - !CONFIG_STATUSES.includes(message.status) || - (message.status === "applied" && message.code !== undefined) || - (message.status === "rejected" && - !CONFIG_ERROR_CODES.includes(message.code)) - ) return null; - wire = { - v: WIRE_VERSION, - t: "config_result", - rev: message.revision, - status: message.status, - }; - if (message.code !== undefined) wire.code = message.code; - break; default: return null; } @@ -784,11 +381,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, @@ -806,241 +405,6 @@ export function decode(raw) { }), }; } - case "nav_present": - if ( - !hasOnlyFields(wire, ["v", "t", "id", "items", "cursor", "ttl"]) || - !validId(wire.id) || - !validNavigationItems(wire.items) || - !Number.isInteger(wire.cursor) || - wire.cursor < 0 || - wire.cursor >= wire.items.length || - !validTTL(wire.ttl) - ) return null; - return { - type: "navPresent", - requestId: wire.id, - items: [...wire.items], - cursor: wire.cursor, - ttlMs: wire.ttl, - }; - case "nav_move": - if ( - !hasOnlyFields(wire, ["v", "t", "id", "dir", "seq"]) || - !validId(wire.id) || - !NAV_DIRECTIONS.includes(wire.dir) || - !validU32(wire.seq) - ) return null; - return { - type: "navMove", - requestId: wire.id, - direction: wire.dir, - sequence: wire.seq, - }; - case "nav_select": - if ( - !hasOnlyFields(wire, ["v", "t", "id", "index", "seq"]) || - !validId(wire.id) || - !Number.isInteger(wire.index) || - wire.index < 0 || - wire.index > 7 || - !validU32(wire.seq) - ) return null; - return { - type: "navSelect", - requestId: wire.id, - index: wire.index, - sequence: wire.seq, - }; - case "nav_resolved": - if ( - !hasOnlyFields(wire, ["v", "t", "id", "r"]) || - !validId(wire.id) || - !NAV_RESOLUTION_REASONS.includes(wire.r) - ) return null; - return { - type: "navResolved", - requestId: wire.id, - reason: wire.r, - }; - case "keymap": - if ( - !hasOnlyFields(wire, ["v", "t", "rev", "keys"]) || - !validU32(wire.rev) || - !validKeyPresentations(wire.keys) - ) return null; - return { - type: "keymap", - revision: wire.rev, - keys: wire.keys.map((key) => ({ ...key, ...(key.rgb ? { rgb: [...key.rgb] } : {}) })), - }; - case "key_event": - if ( - !hasOnlyFields(wire, ["v", "t", "slot", "event", "seq"]) || - !Number.isInteger(wire.slot) || - wire.slot < 0 || - wire.slot > 63 || - !GESTURES.includes(wire.event) || - !validU32(wire.seq) - ) return null; - return { - type: "keyEvent", - slot: wire.slot, - event: wire.event, - sequence: wire.seq, - }; - case "rotary_map": - if ( - !hasOnlyFields(wire, ["v", "t", "rev", "controls"]) || - !validU32(wire.rev) || - !validRotaryControls(wire.controls) - ) return null; - return { - type: "rotaryMap", - revision: wire.rev, - controls: wire.controls.map((control) => ({ - slot: control.slot, - label: control.label, - value: control.value, - minimum: control.min, - maximum: control.max, - wrap: control.wrap, - })), - }; - case "rotary_event": - if ( - !hasOnlyFields(wire, ["v", "t", "slot", "delta", "seq"]) || - !Number.isInteger(wire.slot) || - wire.slot < 0 || - wire.slot > 15 || - !Number.isInteger(wire.delta) || - wire.delta === 0 || - wire.delta < -127 || - wire.delta > 127 || - !validU32(wire.seq) - ) return null; - return { - type: "rotaryEvent", - slot: wire.slot, - delta: wire.delta, - sequence: wire.seq, - }; - case "rotary_press": - if ( - !hasOnlyFields(wire, ["v", "t", "slot", "event", "seq"]) || - !Number.isInteger(wire.slot) || - wire.slot < 0 || - wire.slot > 15 || - !GESTURES.includes(wire.event) || - !validU32(wire.seq) - ) return null; - return { - type: "rotaryPress", - slot: wire.slot, - event: wire.event, - sequence: wire.seq, - }; - case "voice_event": - if ( - !hasOnlyFields(wire, ["v", "t", "event", "seq"]) || - !VOICE_EVENTS.includes(wire.event) || - !validU32(wire.seq) - ) return null; - return { - type: "voiceEvent", - event: wire.event, - sequence: wire.seq, - }; - case "voice_state": { - if ( - !hasOnlyFields(wire, ["v", "t", "state", "label"]) || - !VOICE_STATES.includes(wire.state) || - (wire.label !== undefined && !validBoundedText(wire.label, 1, 64)) - ) return null; - const decoded = { type: "voiceState", state: wire.state }; - if (wire.label !== undefined) decoded.label = wire.label; - return decoded; - } - case "text": { - if ( - !hasOnlyFields(wire, ["v", "t", "channel", "title", "content"]) || - !Number.isInteger(wire.channel) || - wire.channel < 0 || - wire.channel > 7 || - (wire.title !== undefined && !validBoundedText(wire.title, 1, 64)) || - !validBoundedText(wire.content, 0, 1024, true) - ) return null; - const decoded = { type: "text", channel: wire.channel }; - if (wire.title !== undefined) decoded.title = wire.title; - decoded.content = wire.content; - return decoded; - } - case "usage": { - if ( - !hasOnlyFields(wire, [ - "v", - "t", - "model", - "input_tokens", - "output_tokens", - "cached_tokens", - "context_used", - "context_limit", - ]) || - !validBoundedText(wire.model, 1, 64) || - !validSafeCounter(wire.input_tokens) || - !validSafeCounter(wire.output_tokens) || - (wire.cached_tokens !== undefined && - !validSafeCounter(wire.cached_tokens)) || - ((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; - 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.context_used !== undefined) { - decoded.contextUsed = wire.context_used; - decoded.contextLimit = wire.context_limit; - } - return decoded; - } - case "usage_clear": - if (!hasOnlyFields(wire, ["v", "t"])) return null; - return { type: "usageClear" }; - case "config": - if ( - !hasOnlyFields(wire, ["v", "t", "rev", "entries"]) || - !validU32(wire.rev) || - !validConfigEntries(wire.entries) - ) return null; - return { - type: "config", - revision: wire.rev, - entries: wire.entries.map((entry) => ({ ...entry })), - }; - case "config_result": { - if ( - !hasOnlyFields(wire, ["v", "t", "rev", "status", "code"]) || - !validU32(wire.rev) || - !CONFIG_STATUSES.includes(wire.status) || - (wire.status === "applied" && wire.code !== undefined) || - (wire.status === "rejected" && !CONFIG_ERROR_CODES.includes(wire.code)) - ) return null; - const decoded = { - type: "configResult", - revision: wire.rev, - status: wire.status, - }; - if (wire.code !== undefined) decoded.code = wire.code; - 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/test/device-info.test.mjs b/devices/reference/js/test/device-info.test.mjs index 4a47e05..70e5cb7 100644 --- a/devices/reference/js/test/device-info.test.mjs +++ b/devices/reference/js/test/device-info.test.mjs @@ -2,11 +2,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { - MAX_DEVICE_INFO_BYTES, - decodeDeviceInfo, - supportsProfile, -} from "../src/protocol.mjs"; +import { MAX_DEVICE_INFO_BYTES, decodeDeviceInfo } from "../src/protocol.mjs"; const vectorFile = JSON.parse( await readFile( @@ -24,15 +20,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,41 +87,24 @@ 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%" }, ]); assert.equal(override.capabilities.batteryService, false); }); - -test("Device Info negotiates interaction profiles explicitly", () => { - const info = decodeDeviceInfo( - JSON.stringify({ - ...JSON.parse(vectorFile.valid[0].wire), - profiles: [ - "approval/1", - "navigation/1", - "keys/1", - "rotary/1", - "voice/1", - "text/1", - "usage/1", - ], - }), - ); - assert.ok(info); - assert.equal(supportsProfile(info, "navigation/1"), true); - assert.equal(supportsProfile(info, "keys/1"), true); - assert.equal(supportsProfile(info, "config/1"), false); -}); 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..e3924d9 100644 --- a/devices/reference/js/test/package.test.mjs +++ b/devices/reference/js/test/package.test.mjs @@ -38,8 +38,8 @@ test("reference package stays private and exposes explicit entry points", async }); 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"); - assert.match(changelog, /## 0\.2\.0-experimental\.1/); + assert.equal(releasePackageJSON.version, "0.2.0-experimental.0"); + assert.equal(packageJSON.version, "0.2.0-experimental.0"); + assert.match(changelog, /## 0\.2\.0-experimental\.0/); assert.match(readme, /## Experimental 0\.2/); }); 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/vectors.test.mjs b/devices/reference/js/test/vectors.test.mjs index 89ff6f7..677a182 100644 --- a/devices/reference/js/test/vectors.test.mjs +++ b/devices/reference/js/test/vectors.test.mjs @@ -4,7 +4,6 @@ import test from "node:test"; import { CHOICES, - decode, ERROR_CODES, MAX_REQUEST_ID_BYTES, MAX_SUMMARY_BYTES, @@ -12,34 +11,6 @@ import { RESOLUTION_REASONS, } from "../src/protocol.mjs"; -const interactionProfiles = [ - "navigation", - "keys", - "rotary", - "voice", - "text", - "usage", - "config", -]; - -test("interaction profile vectors share the strict reference decoder", async () => { - for (const profile of interactionProfiles) { - const raw = await readFile( - new URL(`../../../protocol/vectors/${profile}-v1.json`, import.meta.url), - "utf8", - ); - const vectors = JSON.parse(raw); - assert.equal(vectors.wire, 1); - assert.equal(vectors.profile, `${profile}/1`); - for (const vector of vectors.valid) { - assert.deepEqual(decode(vector.wire), vector.decoded, vector.name); - } - for (const vector of vectors.invalid) { - assert.equal(decode(vector.wire), null, vector.name); - } - } -}); - test("approval/1 vectors are versioned and cover every message type", async () => { const raw = await readFile( new URL("../../../protocol/vectors/approval-v1.json", import.meta.url), @@ -55,15 +26,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..5a64b3b 100644 --- a/devices/schemas/message.schema.json +++ b/devices/schemas/message.schema.json @@ -7,23 +7,7 @@ { "$ref": "#/$defs/answer" }, { "$ref": "#/$defs/resolved" }, { "$ref": "#/$defs/error" }, - { "$ref": "#/$defs/status" }, - { "$ref": "#/$defs/navPresent" }, - { "$ref": "#/$defs/navMove" }, - { "$ref": "#/$defs/navSelect" }, - { "$ref": "#/$defs/navResolved" }, - { "$ref": "#/$defs/keymap" }, - { "$ref": "#/$defs/keyEvent" }, - { "$ref": "#/$defs/rotaryMap" }, - { "$ref": "#/$defs/rotaryEvent" }, - { "$ref": "#/$defs/rotaryPress" }, - { "$ref": "#/$defs/voiceEvent" }, - { "$ref": "#/$defs/voiceState" }, - { "$ref": "#/$defs/text" }, - { "$ref": "#/$defs/usage" }, - { "$ref": "#/$defs/usageClear" }, - { "$ref": "#/$defs/config" }, - { "$ref": "#/$defs/configResult" } + { "$ref": "#/$defs/status" } ], "$defs": { "deviceInfo": { @@ -273,282 +257,6 @@ } } } - }, - "u32": { - "type": "integer", - "minimum": 0, - "maximum": 4294967295 - }, - "safeCounter": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - }, - "gesture": { - "enum": ["press", "release", "hold", "double"] - }, - "navPresent": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "id", "items", "cursor", "ttl"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "nav_present" }, - "id": { "$ref": "#/$defs/id" }, - "items": { - "type": "array", - "minItems": 2, - "maxItems": 8, - "uniqueItems": true, - "items": { - "type": "string", - "minLength": 1, - "x-maxUtf8Bytes": 64 - } - }, - "cursor": { "type": "integer", "minimum": 0, "maximum": 7 }, - "ttl": { "type": "integer", "minimum": 1, "maximum": 300000 } - } - }, - "navMove": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "id", "dir", "seq"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "nav_move" }, - "id": { "$ref": "#/$defs/id" }, - "dir": { "enum": ["prev", "next", "up", "down", "left", "right"] }, - "seq": { "$ref": "#/$defs/u32" } - } - }, - "navSelect": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "id", "index", "seq"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "nav_select" }, - "id": { "$ref": "#/$defs/id" }, - "index": { "type": "integer", "minimum": 0, "maximum": 7 }, - "seq": { "$ref": "#/$defs/u32" } - } - }, - "navResolved": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "id", "r"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "nav_resolved" }, - "id": { "$ref": "#/$defs/id" }, - "r": { "enum": ["selected", "cancelled", "expired", "replaced"] } - } - }, - "keyPresentation": { - "type": "object", - "additionalProperties": false, - "required": ["slot", "label", "enabled", "light"], - "properties": { - "slot": { "type": "integer", "minimum": 0, "maximum": 63 }, - "label": { "type": "string", "minLength": 1, "x-maxUtf8Bytes": 32 }, - "enabled": { "type": "boolean" }, - "light": { "enum": ["off", "dim", "solid", "pulse"] }, - "rgb": { - "type": "array", - "minItems": 3, - "maxItems": 3, - "items": { "type": "integer", "minimum": 0, "maximum": 255 } - } - } - }, - "keymap": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "rev", "keys"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "keymap" }, - "rev": { "$ref": "#/$defs/u32" }, - "keys": { - "type": "array", - "maxItems": 64, - "items": { "$ref": "#/$defs/keyPresentation" } - } - } - }, - "keyEvent": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "slot", "event", "seq"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "key_event" }, - "slot": { "type": "integer", "minimum": 0, "maximum": 63 }, - "event": { "$ref": "#/$defs/gesture" }, - "seq": { "$ref": "#/$defs/u32" } - } - }, - "rotaryControl": { - "type": "object", - "additionalProperties": false, - "required": ["slot", "label", "value", "min", "max", "wrap"], - "properties": { - "slot": { "type": "integer", "minimum": 0, "maximum": 15 }, - "label": { "type": "string", "minLength": 1, "x-maxUtf8Bytes": 32 }, - "value": { "type": "integer", "minimum": -1000000, "maximum": 1000000 }, - "min": { "type": "integer", "minimum": -1000000, "maximum": 1000000 }, - "max": { "type": "integer", "minimum": -1000000, "maximum": 1000000 }, - "wrap": { "type": "boolean" } - } - }, - "rotaryMap": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "rev", "controls"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "rotary_map" }, - "rev": { "$ref": "#/$defs/u32" }, - "controls": { - "type": "array", - "maxItems": 16, - "items": { "$ref": "#/$defs/rotaryControl" } - } - } - }, - "rotaryEvent": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "slot", "delta", "seq"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "rotary_event" }, - "slot": { "type": "integer", "minimum": 0, "maximum": 15 }, - "delta": { "type": "integer", "minimum": -127, "maximum": 127, "not": { "const": 0 } }, - "seq": { "$ref": "#/$defs/u32" } - } - }, - "rotaryPress": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "slot", "event", "seq"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "rotary_press" }, - "slot": { "type": "integer", "minimum": 0, "maximum": 15 }, - "event": { "$ref": "#/$defs/gesture" }, - "seq": { "$ref": "#/$defs/u32" } - } - }, - "voiceEvent": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "event", "seq"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "voice_event" }, - "event": { "enum": ["start", "stop", "cancel"] }, - "seq": { "$ref": "#/$defs/u32" } - } - }, - "voiceState": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "state"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "voice_state" }, - "state": { "enum": ["idle", "listening", "transcribing", "submitted", "error"] }, - "label": { "type": "string", "minLength": 1, "x-maxUtf8Bytes": 64 } - } - }, - "text": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "channel", "content"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "text" }, - "channel": { "type": "integer", "minimum": 0, "maximum": 7 }, - "title": { "type": "string", "minLength": 1, "x-maxUtf8Bytes": 64 }, - "content": { "type": "string", "x-maxUtf8Bytes": 1024 } - } - }, - "usage": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "model", "input_tokens", "output_tokens"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "usage" }, - "model": { "type": "string", "minLength": 1, "x-maxUtf8Bytes": 64 }, - "input_tokens": { "$ref": "#/$defs/safeCounter" }, - "output_tokens": { "$ref": "#/$defs/safeCounter" }, - "cached_tokens": { "$ref": "#/$defs/safeCounter" }, - "context_used": { "$ref": "#/$defs/safeCounter" }, - "context_limit": { "$ref": "#/$defs/safeCounter" } - }, - "dependentRequired": { - "context_used": ["context_limit"], - "context_limit": ["context_used"] - } - }, - "usageClear": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "usage_clear" } - } - }, - "configEntry": { - "type": "object", - "additionalProperties": false, - "required": ["key", "value"], - "properties": { - "key": { - "type": "string", - "minLength": 1, - "maxLength": 48, - "pattern": "^[A-Za-z0-9][A-Za-z0-9._-]*$" - }, - "value": { - "oneOf": [ - { "type": "boolean" }, - { "type": "integer", "minimum": -1000000, "maximum": 1000000 }, - { "type": "string", "x-maxUtf8Bytes": 128 } - ] - } - } - }, - "config": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "rev", "entries"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "config" }, - "rev": { "$ref": "#/$defs/u32" }, - "entries": { - "type": "array", - "maxItems": 32, - "items": { "$ref": "#/$defs/configEntry" } - } - } - }, - "configResult": { - "type": "object", - "additionalProperties": false, - "required": ["v", "t", "rev", "status"], - "properties": { - "v": { "const": 1 }, - "t": { "const": "config_result" }, - "rev": { "$ref": "#/$defs/u32" }, - "status": { "enum": ["applied", "rejected"] }, - "code": { "enum": ["unknown_key", "invalid_value", "storage_error", "unsupported"] } - } } } } diff --git a/devices/scripts/bootstrap-zephyr.sh b/devices/scripts/bootstrap-zephyr.sh deleted file mode 100755 index 5a7e0de..0000000 --- a/devices/scripts/bootstrap-zephyr.sh +++ /dev/null @@ -1,154 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -devices_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -workspace_dir="$(dirname "$devices_dir")" -board_alias="xiao-nrf52840-sense" -should_build=0 -dry_run=0 -install_sdk=0 - -usage() { - sed -n '2,31p' "$0" | sed -n 's/^# \{0,1\}//p' -} - -# bootstrap-zephyr.sh [options] -# -# Prepare the pinned Nexting Devices Zephyr workspace and optionally build. -# -# Options: -# --board ALIAS xiao-nrf52840-sense (default), nrf52840-dk, -# xiao-esp32c3, or xiao-esp32s3 -# --build Build the selected reference firmware after bootstrap -# --install-sdk Install Zephyr SDK 0.17.4 for the selected architecture -# --dry-run Print the resolved setup and build without changing files -# -h, --help Show this help -# -# Run this script from any directory. It creates .west and a Python virtual -# environment beside devices/, leaving the checked-out source tree unchanged. - -die() { - printf 'ERROR: %s\n' "$*" >&2 - exit 64 -} - -while (($#)); do - case "$1" in - --board) - (($# >= 2)) || die "--board requires an alias" - board_alias="$2" - shift 2 - ;; - --build) - should_build=1 - shift - ;; - --install-sdk) - install_sdk=1 - shift - ;; - --dry-run) - dry_run=1 - shift - ;; - -h|--help) - usage - exit 0 - ;; - *) - die "unknown option: $1" - ;; - esac -done - -case "$board_alias" in - xiao-nrf52840-sense) - board="xiao_ble/nrf52840/sense" - toolchain="arm-zephyr-eabi" - image="zephyr.uf2" - ;; - nrf52840-dk) - board="nrf52840dk/nrf52840" - toolchain="arm-zephyr-eabi" - image="zephyr.hex" - ;; - xiao-esp32c3) - board="xiao_esp32c3/esp32c3" - toolchain="riscv64-zephyr-elf" - image="zephyr.bin" - ;; - xiao-esp32s3) - board="xiao_esp32s3/esp32s3/procpu" - toolchain="xtensa-espressif_esp32s3_zephyr-elf" - image="zephyr.bin" - ;; - *) - die "unsupported board '$board_alias'. Use xiao-nrf52840-sense, nrf52840-dk, xiao-esp32c3, or xiao-esp32s3." - ;; -esac - -build_dir="$devices_dir/build/$board_alias" -venv_dir="$workspace_dir/.nexting-zephyr-venv" -west_bin="$venv_dir/bin/west" - -printf 'Nexting Devices Zephyr bootstrap\n' -printf 'West version: 1.5.0\n' -printf 'Zephyr revision: v4.3.0\n' -printf 'Zephyr SDK: 0.17.4\n' -printf 'Board: %s\n' "$board" -printf 'Workspace: %s\n' "$workspace_dir" -printf 'Build directory: %s\n' "$build_dir" -printf 'Build option: -DEXTRA_CONF_FILE=debug-test-device.conf\n' -printf 'Expected image: %s/zephyr/%s\n' "$build_dir" "$image" - -if ((dry_run)); then - printf 'DRY RUN: no files changed.\n' - exit 0 -fi - -command -v python3 >/dev/null 2>&1 || - die "python3 is required. Install Python 3.11+ and rerun this command." - -if [[ ! -x "$west_bin" ]]; then - printf '\n[1/5] Creating isolated Python environment...\n' - python3 -m venv "$venv_dir" - "$venv_dir/bin/python" -m pip install --upgrade pip - "$venv_dir/bin/python" -m pip install "west==1.5.0" -fi - -cd "$workspace_dir" - -if [[ ! -d "$workspace_dir/.west" ]]; then - printf '\n[2/5] Initializing the local manifest workspace...\n' - "$west_bin" init -l "$devices_dir" -else - printf '\n[2/5] Reusing %s/.west\n' "$workspace_dir" -fi - -printf '\n[3/5] Fetching the pinned Zephyr modules...\n' -"$west_bin" update -"$west_bin" zephyr-export -"$venv_dir/bin/python" -m pip install -r "$workspace_dir/zephyr/scripts/requirements.txt" - -if ((install_sdk)); then - printf '\n[4/5] Installing Zephyr SDK 0.17.4 (%s)...\n' "$toolchain" - "$west_bin" sdk install --version 0.17.4 --toolchains "$toolchain" -else - printf '\n[4/5] Keeping the installed Zephyr SDK.\n' - printf ' If the build reports a missing toolchain, rerun with --install-sdk.\n' -fi - -if ((should_build)); then - printf '\n[5/5] Building %s...\n' "$board" - extra_args=(-DEXTRA_CONF_FILE=debug-test-device.conf) - if [[ "$board_alias" == xiao-esp32* ]]; then - "$west_bin" blobs fetch hal_espressif - fi - "$west_bin" build -p always -b "$board" "$devices_dir/firmware/zephyr" \ - -d "$build_dir" -- "${extra_args[@]}" - [[ -f "$build_dir/zephyr/$image" ]] || - die "build completed without expected image $build_dir/zephyr/$image" - printf '\nPASS firmware=%s/zephyr/%s\n' "$build_dir" "$image" -else - printf '\n[5/5] Bootstrap complete. Add --build to compile firmware.\n' -fi diff --git a/devices/scripts/bootstrap-zephyr.test.mjs b/devices/scripts/bootstrap-zephyr.test.mjs deleted file mode 100644 index 1ea0e86..0000000 --- a/devices/scripts/bootstrap-zephyr.test.mjs +++ /dev/null @@ -1,49 +0,0 @@ -import assert from "node:assert/strict"; -import { execFile } from "node:child_process"; -import { readFile } from "node:fs/promises"; -import { promisify } from "node:util"; -import { fileURLToPath } from "node:url"; -import test from "node:test"; - -const execFileAsync = promisify(execFile); -const script = fileURLToPath(new URL("./bootstrap-zephyr.sh", import.meta.url)); - -test("real setup enters the workspace before initializing or updating west", async () => { - const source = await readFile(script, "utf8"); - const enterWorkspace = source.indexOf('cd "$workspace_dir"'); - const initialize = source.indexOf('"$west_bin" init'); - const update = source.indexOf('"$west_bin" update'); - assert.ok(enterWorkspace > 0); - assert.ok(initialize > enterWorkspace); - assert.ok(update > enterWorkspace); -}); - -test("dry run resolves the golden XIAO build without changing the workspace", async () => { - const { stdout } = await execFileAsync(script, [ - "--board", - "xiao-nrf52840-sense", - "--build", - "--dry-run", - ]); - - for (const marker of [ - "West version: 1.5.0", - "Zephyr revision: v4.3.0", - "Board: xiao_ble/nrf52840/sense", - "EXTRA_CONF_FILE=debug-test-device.conf", - "zephyr.uf2", - ]) { - assert.ok(stdout.includes(marker), `dry run missing ${marker}`); - } -}); - -test("unknown boards fail with the supported aliases", async () => { - await assert.rejects( - execFileAsync(script, ["--board", "mystery-board", "--dry-run"]), - (error) => { - assert.equal(error.code, 64); - assert.match(error.stderr, /xiao-nrf52840-sense/); - return true; - }, - ); -}); 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 19ff0e6..7a43cd4 100644 --- a/devices/scripts/documentation-contract.test.mjs +++ b/devices/scripts/documentation-contract.test.mjs @@ -1,5 +1,4 @@ import assert from "node:assert/strict"; -import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import test from "node:test"; @@ -20,7 +19,7 @@ test("foundation guide maps product behavior to real source files", async () => "Fail closed", "What to change", "capability declaration", - "capability set", + "capability roadmap", "agent anywhere", "needs-input/error states for LEDs or screens, full replacement, volatile", ]) { @@ -47,18 +46,6 @@ test("interface catalog exposes only the supported public surfaces", async () => "publishStatus", "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", "NextingDeviceRelayCoordinator", "NextingDeviceCentral", "nexting_device_decode", @@ -74,45 +61,10 @@ test("interface catalog exposes only the supported public surfaces", async () => } assert.match(interfaces, /does not expose a Nexting cloud API/); - assert.match(interfaces, /no public TCP, UDP, HTTP, MQTT, or WebSocket endpoint/); - assert.doesNotMatch(interfaces, /still require their own\s+versioned profiles/i); -}); - -test("Codex Host guide maps the official App Server subset without publishing private adapters", async () => { - const [guide, overview, index, interfaces, manifest] = await Promise.all([ - read("docs/codex-app-server.md"), - read("README.md"), - read("docs/README.md"), - read("docs/interfaces.md"), - read("scripts/export-manifest.json"), - ]); - - for (const marker of [ - "https://learn.chatgpt.com/docs/codex-sdk", - "https://learn.chatgpt.com/docs/app-server", - "item/commandExecution/requestApproval", - "item/fileChange/requestApproval", - "serverRequest/resolved", - '"decision": "accept"', - '"decision": "decline"', - "networkApprovalContext", - "additionalPermissions", - "proposedExecpolicyAmendment", - "grantRoot", - "NextingDeviceRelayCoordinator", - "localhost", - ]) { - assert.ok(guide.includes(marker), `Codex Host guide missing ${marker}`); - } - assert.match(guide, /Codex SDK.*App Server/s); - assert.match(guide, /private.*Bridge|Bridge.*private/i); - assert.match(guide, /does not.*publish.*Agent adapter/i); - assert.doesNotMatch(guide, /api\.nexting\.ai|NEXTING_.*TOKEN|Bearer /); - - for (const source of [overview, index, interfaces]) { - assert.match(source, /codex-app-server\.md/); - } - assert.match(manifest, /docs\/codex-app-server\.md/); + assert.match( + interfaces, + /no public TCP, UDP, HTTP, MQTT, or WebSocket endpoint/, + ); }); test("implementation tracks cover every supported developer route", async () => { @@ -132,7 +84,10 @@ test("implementation tracks cover every supported developer route", async () => "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, + ); } }); @@ -150,7 +105,10 @@ test("conformance guide keeps evidence levels literal", async () => { "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.match(conformance, /Build verified is not Board verified/); @@ -167,7 +125,6 @@ test("reader entry points link the public documentation system", async () => { for (const link of [ "docs/foundation-development.md", "docs/interfaces.md", - "docs/codex-app-server.md", "docs/implementation-tracks.md", "docs/conformance.md", "docs/use-cases.md", @@ -178,7 +135,6 @@ test("reader entry points link the public documentation system", async () => { for (const link of [ "foundation-development.md", "interfaces.md", - "codex-app-server.md", "implementation-tracks.md", "conformance.md", "use-cases.md", @@ -187,7 +143,10 @@ test("reader entry points link the public documentation system", async () => { } assert.match(development, /All four reference targets are Build verified/); - assert.doesNotMatch(development, /Espressif targets still require an upstream/); + assert.doesNotMatch( + development, + /Espressif targets still require an upstream/, + ); assert.doesNotMatch(development, /ESP32 builds.*pending/i); }); @@ -198,60 +157,25 @@ test("use-case guide maps scenarios to real profiles and limits", async () => { "approval/1", "status/1", "statusSlots", - "navigation/1", - "keys/1", - "rotary/1", - "voice/1", - "text/1", - "usage/1", - "config/1", "conformance.md", "implementation-tracks.md", ]) { assert.ok(useCases.includes(marker), "use cases missing marker: " + marker); } - assert.doesNotMatch(useCases, /profiles that are specified but not yet shipped/i); -}); - -test("0.2 experimental.2 documents every frozen interaction profile", async () => { - const [overview, changelog, projectStatus, c, swift, kotlin] = - await Promise.all([ - read("README.md"), - read("CHANGELOG.md"), - read("docs/project-status.md"), - read("sdk/c/README.md"), - read("sdk/swift/README.md"), - read("sdk/kotlin/README.md"), - ]); - - for (const source of [overview, changelog, projectStatus, c, swift, kotlin]) { - assert.match(source, /0\.2\.0-experimental\.2/); - } - for (const profile of [ - "navigation/1", - "keys/1", - "rotary/1", - "voice/1", - "text/1", - "usage/1", - "config/1", - ]) { - assert.match(overview, new RegExp(profile.replace("/", "\\/"))); - assert.match(projectStatus, new RegExp(profile.replace("/", "\\/"))); - } - assert.match(overview, /Host microphone/i); - assert.match(changelog, /frozen interaction profiles/i); + assert.match(useCases, /roadmap/i); }); test("Agent and maintainer guides use the same sources and claims", async () => { - const [agents, contributing, swift, c, firmware, porting] = await Promise.all([ - read("AGENTS.md"), - read("CONTRIBUTING.md"), - read("sdk/swift/README.md"), - read("sdk/c/README.md"), - read("firmware/zephyr/README.md"), - read("docs/porting-guide.md"), - ]); + const [agents, contributing, swift, c, firmware, porting] = await Promise.all( + [ + read("AGENTS.md"), + read("CONTRIBUTING.md"), + read("sdk/swift/README.md"), + read("sdk/c/README.md"), + read("firmware/zephyr/README.md"), + read("docs/porting-guide.md"), + ], + ); for (const marker of [ "docs/foundation-development.md", @@ -268,198 +192,3 @@ test("Agent and maintainer guides use the same sources and claims", async () => assert.match(component, /interfaces\.md|implementation-tracks\.md/); } }); - -test("public Quickstart explains the product before optional reference hardware", async () => { - const referenceControllerUrl = new URL( - "docs/reference-approval-controller.md", - root, - ); - const availabilityUrl = new URL("docs/availability.json", root); - assert.ok( - existsSync(referenceControllerUrl), - "reference approval controller guide must exist", - ); - assert.ok(existsSync(availabilityUrl), "availability source must exist"); - - 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 protocol 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/, - ); - assert.doesNotMatch(quickstart, /private Nexting App|Debug build/); - - for (const marker of [ - "Build the reference approval controller", - "Developer Reference", - "approval/1", - "XIAO nRF52840", - "D0", - "D1", - "bootstrap-zephyr.sh", - "nexting-device-host-smoke", - "PASS answer=", - ]) { - assert.ok( - referenceController.includes(marker), - "reference controller missing marker: " + marker, - ); - } - assert.match( - referenceController, - /not the only or default shape of a Nexting device/i, - ); - assert.match(overview, /docs\/reference-approval-controller\.md/); - assert.match(index, /reference-approval-controller\.md/); - - assert.equal(availability.sdkVersion, packageJson.version); - assert.equal(availability.wireMajor, 1); - assert.equal(availability.developerEnrollment.ios.available, false); - assert.equal(availability.developerEnrollment.android.available, false); - assert.equal(availability.fallback, "host-smoke"); - - 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( - security, - /fully secure|device-to-Agent end-to-end encryption|no data leaves your device/i, - ); - - assert.doesNotMatch(firstApproval, /current private Nexting App|Debug build/); - assert.match(troubleshooting, /west: unknown command "build"/); - assert.match(troubleshooting, /Bluetooth/); - assert.match(troubleshooting, /Device Info/); -}); - -test("Swift package exposes the documented public Host smoke executable", async () => { - const [manifest, source, readme] = await Promise.all([ - read("sdk/swift/Package.swift"), - read("sdk/swift/Sources/NextingDeviceHostSmoke/main.swift"), - read("sdk/swift/README.md"), - ]); - - for (const marker of ["nexting-device-host-smoke", "NextingDeviceHostSmoke"]) { - assert.ok(manifest.includes(marker), `Swift manifest missing ${marker}`); - } - for (const marker of [ - "onDiscovered", - "connectedDeviceInfo", - "Device Info", - "PASS answer=", - "Bluetooth", - ]) { - assert.ok(source.includes(marker), `Host smoke source missing ${marker}`); - } - assert.match(readme, /nexting-device-host-smoke/); -}); - -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}`); - } -}); - -test("public workflows use current Node 24 action majors", async () => { - const [ci, firmware] = await Promise.all([ - 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/setup-java@v5", - "gradle/actions/setup-gradle@v6", - "actions/upload-artifact@v7", - "actions/download-artifact@v8", - ]) { - assert.ok(workflows.includes(marker), `public workflows missing ${marker}`); - } - assert.doesNotMatch( - workflows, - /actions\/(?:checkout|setup-node|setup-python|setup-java)@v4/, - ); -}); - -test("Kotlin has a checksum-pinned one-command launcher", async () => { - const [launcher, readme, workflow] = await Promise.all([ - read("sdk/kotlin/gradlew"), - read("sdk/kotlin/README.md"), - read("scripts/public-workflows/nexting-devices-ci.yml"), - ]); - for (const marker of [ - 'gradle_version="9.0.0"', - "8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b", - "services.gradle.org/distributions", - "checksum mismatch", - ]) { - assert.ok(launcher.includes(marker), `Kotlin launcher missing ${marker}`); - } - assert.match(readme, /\.\/gradlew test/); - assert.match(workflow, /\.\/gradlew test/); -}); diff --git a/devices/scripts/export-manifest.json b/devices/scripts/export-manifest.json index 7e9eabe..3ee78fc 100644 --- a/devices/scripts/export-manifest.json +++ b/devices/scripts/export-manifest.json @@ -8,14 +8,11 @@ "LICENSE", "LICENSES/CC-BY-4.0.txt", "NOTICE", - "QUICKSTART.md", "README.md", "SECURITY.md", "SPEC.md", "docs/README.md", - "docs/availability.json", "docs/board-verification.md", - "docs/codex-app-server.md", "docs/conformance.md", "docs/development.md", "docs/first-approval.md", @@ -24,10 +21,9 @@ "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", - "docs/troubleshooting.md", "docs/use-cases.md", "examples/macos-device-simulator/README.md", "examples/macos-device-simulator/main.swift", @@ -41,17 +37,18 @@ "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/nexting-multipad-device-info.template.json", + "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", "package.json", "protocol/vectors/approval-v1.json", "protocol/vectors/device-info-v1.json", - "protocol/vectors/navigation-v1.json", - "protocol/vectors/keys-v1.json", - "protocol/vectors/rotary-v1.json", "protocol/vectors/status-v1.json", - "protocol/vectors/voice-v1.json", - "protocol/vectors/text-v1.json", - "protocol/vectors/usage-v1.json", - "protocol/vectors/config-v1.json", "reference/js/README.md", "reference/js/package.json", "reference/js/src/device-info.mjs", @@ -69,8 +66,6 @@ "scripts/check-naming.mjs", "scripts/check-public-boundary.mjs", "scripts/check-public-boundary.test.mjs", - "scripts/bootstrap-zephyr.sh", - "scripts/bootstrap-zephyr.test.mjs", "scripts/documentation-contract.test.mjs", "scripts/export-manifest.json", "scripts/export-nexting-devices.mjs", @@ -83,22 +78,18 @@ "sdk/c/include/nexting_device.h", "sdk/c/src/nexting_device.c", "sdk/c/tests/generate_vectors.mjs", - "sdk/c/tests/generate_interaction_vectors.mjs", "sdk/c/tests/test_codec.c", "sdk/c/tests/test_device_info.c", "sdk/c/tests/test_relay.c", "sdk/c/tests/test_status.c", "sdk/c/tests/test_stream.c", - "sdk/c/tests/test_interactions.c", "sdk/kotlin/.gitignore", "sdk/kotlin/README.md", "sdk/kotlin/build.gradle.kts", - "sdk/kotlin/gradlew", "sdk/kotlin/settings.gradle.kts", "sdk/kotlin/src/main/kotlin/ai/nexting/devices/DeviceInfo.kt", "sdk/kotlin/src/main/kotlin/ai/nexting/devices/Protocol.kt", "sdk/kotlin/src/test/kotlin/ai/nexting/devices/DeviceInfoTest.kt", - "sdk/kotlin/src/test/kotlin/ai/nexting/devices/InteractionProfileTest.kt", "sdk/kotlin/src/test/kotlin/ai/nexting/devices/ProtocolTest.kt", "sdk/swift/Package.swift", "sdk/swift/README.md", @@ -108,15 +99,11 @@ "sdk/swift/Sources/NextingDeviceKit/Coordinator.swift", "sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift", "sdk/swift/Sources/NextingDeviceKit/Framing.swift", - "sdk/swift/Sources/NextingDeviceKit/HostSmoke.swift", "sdk/swift/Sources/NextingDeviceKit/Protocol.swift", "sdk/swift/Sources/NextingDeviceKit/Relay.swift", "sdk/swift/Sources/NextingDeviceKit/State.swift", - "sdk/swift/Sources/NextingDeviceHostSmoke/main.swift", "sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift", "sdk/swift/Tests/NextingDeviceKitTests/IntegrationTests.swift", - "sdk/swift/Tests/NextingDeviceKitTests/HostSmokeTests.swift", - "sdk/swift/Tests/NextingDeviceKitTests/InteractionProfileTests.swift", "sdk/swift/Tests/NextingDeviceKitTests/RelayTests.swift", "sdk/swift/Tests/NextingDeviceKitTests/StatusTests.swift", "sdk/swift/Tests/NextingDeviceKitTests/VectorTests.swift", @@ -126,9 +113,7 @@ ".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 f908555..10def8c 100644 --- a/devices/scripts/export-nexting-devices.mjs +++ b/devices/scripts/export-nexting-devices.mjs @@ -11,7 +11,6 @@ import { stat, writeFile, } from "node:fs/promises"; -import { realpathSync } from "node:fs"; import { createHash } from "node:crypto"; import { tmpdir } from "node:os"; import { basename, dirname, join, relative, resolve, sep } from "node:path"; @@ -21,12 +20,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 +50,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 +92,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 +108,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 +154,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)}`; } @@ -194,9 +203,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 }); @@ -219,7 +229,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; } } @@ -227,7 +240,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}`); } @@ -248,10 +262,18 @@ async function desiredRootFiles(publicCheckout, output, manifest, source) { } async function checkGeneratedRoot(publicCheckout, desired, manifest) { - for (const path of ["README.md", "Package.swift", ...Object.values(manifest.workflowMappings)]) { + for (const path of [ + "README.md", + "Package.swift", + ...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; } @@ -267,7 +289,11 @@ 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", "Package.swift", ...Object.values(manifest.workflowMappings)]) { + for (const path of [ + "README.md", + "Package.swift", + ...Object.values(manifest.workflowMappings), + ]) { const from = join(built, path); const to = join(publicCheckout, path); await mkdir(dirname(to), { recursive: true }); @@ -291,11 +317,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); @@ -304,10 +334,7 @@ export async function exportNextingDevices(options) { } } -if ( - process.argv[1] - && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url)) -) { +if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { try { await exportNextingDevices(parseArguments(process.argv.slice(2))); console.log("Nexting Devices public export passed"); diff --git a/devices/scripts/export-nexting-devices.test.mjs b/devices/scripts/export-nexting-devices.test.mjs index 62cd2cc..52c3b47 100644 --- a/devices/scripts/export-nexting-devices.test.mjs +++ b/devices/scripts/export-nexting-devices.test.mjs @@ -1,23 +1,32 @@ 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"; import { fileURLToPath } from "node:url"; -import { promisify } from "node:util"; import { exportNextingDevices } from "./export-nexting-devices.mjs"; const source = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const execFileAsync = promisify(execFile); async function fixture() { const root = await mkdtemp(join(tmpdir(), "nexting-export-test-")); 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 +36,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,27 +48,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/); - assert.match(await readFile(join(publicCheckout, "Package.swift"), "utf8"), /NextingDeviceKit/); - assert.match(await readFile(join(publicCheckout, "devices", "SHA256SUMS"), "utf8"), /SPEC\.md/); assert.match( - await readFile( - join(publicCheckout, "devices", "docs", "availability.json"), - "utf8", - ), - /"fallback": "host-smoke"/, + await readFile(join(publicCheckout, "README.md"), "utf8"), + /Build hardware for Nexting/, ); assert.match( - await readFile( - join( - publicCheckout, - "devices", - "docs", - "reference-approval-controller.md", - ), - "utf8", - ), - /Developer Reference/, + await readFile(join(publicCheckout, "Package.swift"), "utf8"), + /NextingDeviceKit/, + ); + assert.match( + await readFile(join(publicCheckout, "devices", "SHA256SUMS"), "utf8"), + /SPEC\.md/, ); assert.match( await readFile( @@ -65,32 +67,15 @@ test("exports a deterministic devices subtree without touching unrelated public ), /NextingDeviceKit\/Protocol\.swift/, ); - 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 }); - } -}); - -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 linkedEntrypoint = join(root, "export-nexting-devices.mjs"); - try { - await symlink(entrypoint, linkedEntrypoint); - const { stdout } = await execFileAsync(process.execPath, [ - linkedEntrypoint, - "--source", - source, - "--public-checkout", - publicCheckout, - ]); - assert.match(stdout, /public export passed/); - assert.match( - await readFile(join(publicCheckout, "devices", "QUICKSTART.md"), "utf8"), - /Nexting device ⇄ encrypted BLE ⇄ trusted Host ⇄ Agent integration/, + 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 }); } @@ -106,7 +91,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 +115,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 +140,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 +174,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/public-workflows/nexting-devices-ci.yml b/devices/scripts/public-workflows/nexting-devices-ci.yml index 69f2055..d403989 100644 --- a/devices/scripts/public-workflows/nexting-devices-ci.yml +++ b/devices/scripts/public-workflows/nexting-devices-ci.yml @@ -19,8 +19,8 @@ jobs: javascript-and-boundary: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 22 - run: npm run check @@ -29,7 +29,7 @@ jobs: swift: runs-on: macos-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - run: swift test - run: >- swiftc -warnings-as-errors -o /tmp/nexting-device-sim @@ -39,18 +39,18 @@ jobs: kotlin: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v5 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - - uses: gradle/actions/setup-gradle@v6 - - run: ./gradlew test + - uses: gradle/actions/setup-gradle@v4 + - run: gradle test working-directory: devices/sdk/kotlin c99: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - run: cmake -S devices/sdk/c -B build -DNEXTING_DEVICE_SANITIZE=ON - run: cmake --build build && ctest --test-dir build --output-on-failure diff --git a/devices/scripts/public-workflows/nexting-devices-firmware.yml b/devices/scripts/public-workflows/nexting-devices-firmware.yml index cab3880..1b10b3e 100644 --- a/devices/scripts/public-workflows/nexting-devices-firmware.yml +++ b/devices/scripts/public-workflows/nexting-devices-firmware.yml @@ -8,8 +8,6 @@ on: - "devices/west.yml" - ".github/workflows/nexting-devices-firmware.yml" push: - tags: - - "devices-v*" paths: - "devices/firmware/**" - "devices/sdk/c/**" @@ -32,13 +30,11 @@ jobs: include: - board: nrf52840dk/nrf52840 artifact: nrf52840dk - flash: west flash with the onboard SWD debugger - board: xiao_ble/nrf52840/sense artifact: xiao-nrf52840-sense - flash: copy zephyr.uf2 to the XIAO BLE mass-storage bootloader steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.12" - uses: zephyrproject-rtos/action-zephyr-setup@v1 @@ -48,30 +44,14 @@ jobs: sdk-version: 0.17.4 west-version: 1.5.0 - run: west build -p always -b ${{ matrix.board }} devices/firmware/zephyr - - name: Package self-describing firmware asset - env: - BOARD: ${{ matrix.board }} - ARTIFACT: ${{ matrix.artifact }} - FLASH: ${{ matrix.flash }} - run: | - set -eu - bundle="nexting-device-${ARTIFACT}-${GITHUB_SHA}" - mkdir -p "${bundle}" - for image in build/zephyr/zephyr.elf build/zephyr/zephyr.bin build/zephyr/zephyr.hex build/zephyr/zephyr.uf2; do - if [ -f "${image}" ]; then cp "${image}" "${bundle}/"; fi - done - test -n "$(find "${bundle}" -type f -print -quit)" - ( - cd "${bundle}" - sha256sum zephyr.* > SHA256SUMS - printf '{\n "schemaVersion": 1,\n "sourceCommit": "%s",\n "board": "%s",\n "artifact": "%s",\n "flash": "%s",\n "zephyr": "4.3.0",\n "zephyrSdk": "0.17.4",\n "west": "1.5.0",\n "evidence": "Build verified",\n "boardVerified": false\n}\n' \ - "${GITHUB_SHA}" "${BOARD}" "${ARTIFACT}" "${FLASH}" > artifact-manifest.json - ) - tar -czf "${bundle}.tar.gz" "${bundle}" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@v4 with: name: nexting-device-${{ matrix.artifact }}-${{ github.sha }} - path: nexting-device-${{ matrix.artifact }}-${{ github.sha }}.tar.gz + path: | + build/zephyr/zephyr.elf + build/zephyr/zephyr.bin + build/zephyr/zephyr.hex + build/zephyr/zephyr.uf2 if-no-files-found: error espressif: @@ -82,13 +62,11 @@ jobs: include: - board: xiao_esp32c3/esp32c3 artifact: xiao-esp32c3 - flash: west flash through the Espressif serial bootloader - board: xiao_esp32s3/esp32s3/procpu artifact: xiao-esp32s3 - flash: west flash through the Espressif serial bootloader steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.12" - uses: zephyrproject-rtos/action-zephyr-setup@v1 @@ -99,52 +77,11 @@ jobs: west-version: 1.5.0 - run: west blobs fetch hal_espressif - run: west build -p always -b ${{ matrix.board }} devices/firmware/zephyr - - name: Package self-describing firmware asset - env: - BOARD: ${{ matrix.board }} - ARTIFACT: ${{ matrix.artifact }} - FLASH: ${{ matrix.flash }} - run: | - set -eu - bundle="nexting-device-${ARTIFACT}-${GITHUB_SHA}" - mkdir -p "${bundle}" - for image in build/zephyr/zephyr.elf build/zephyr/zephyr.bin build/zephyr/zephyr.hex; do - if [ -f "${image}" ]; then cp "${image}" "${bundle}/"; fi - done - test -n "$(find "${bundle}" -type f -print -quit)" - ( - cd "${bundle}" - sha256sum zephyr.* > SHA256SUMS - printf '{\n "schemaVersion": 1,\n "sourceCommit": "%s",\n "board": "%s",\n "artifact": "%s",\n "flash": "%s",\n "zephyr": "4.3.0",\n "zephyrSdk": "0.17.4",\n "west": "1.5.0",\n "evidence": "Build verified",\n "boardVerified": false\n}\n' \ - "${GITHUB_SHA}" "${BOARD}" "${ARTIFACT}" "${FLASH}" > artifact-manifest.json - ) - tar -czf "${bundle}.tar.gz" "${bundle}" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@v4 with: name: nexting-device-${{ matrix.artifact }}-${{ github.sha }} - path: nexting-device-${{ matrix.artifact }}-${{ github.sha }}.tar.gz + path: | + build/zephyr/zephyr.elf + build/zephyr/zephyr.bin + build/zephyr/zephyr.hex if-no-files-found: error - - release: - if: startsWith(github.ref, 'refs/tags/devices-v') - needs: - - nordic - - espressif - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/download-artifact@v8 - with: - pattern: nexting-device-*-${{ github.sha }} - path: release-assets - merge-multiple: true - - name: Publish immutable Experimental release - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create "${GITHUB_REF_NAME}" release-assets/*.tar.gz \ - --repo "${GITHUB_REPOSITORY}" \ - --prerelease \ - --title "Nexting Devices SDK ${GITHUB_REF_NAME#devices-v}" \ - --notes "Public Experimental release. Firmware assets are Build verified with pinned Zephyr 4.3.0, Zephyr SDK 0.17.4, and west 1.5.0. They are not Board verified; inspect each artifact-manifest.json and the repository conformance guide." diff --git a/devices/scripts/simulator-contract.test.mjs b/devices/scripts/simulator-contract.test.mjs index 7c3fd49..e8b1d94 100644 --- a/devices/scripts/simulator-contract.test.mjs +++ b/devices/scripts/simulator-contract.test.mjs @@ -31,7 +31,7 @@ test("public simulator keeps the protocol and BLE transport contracts", async () } assert.match(simulator, /NextingDeviceCodec\.decode/); assert.match(simulator, /NextingDeviceCodec\.encode/); - assert.match(simulator, /0\.2\.0-experimental\.2/); + assert.match(simulator, /0\.2\.0-experimental\.0/); assert.match(simulator, /button_count/); assert.match(simulator, /approval_button_count/); assert.match(simulator, /systemUptime/); @@ -39,7 +39,10 @@ test("public simulator keeps the protocol and BLE transport contracts", async () assert.ok(!simulator.includes("JSONSerialization")); assert.match(simulator, /notifyEncryptionRequired/); assert.match(simulator, /writeEncryptionRequired/); - assert.match(simulator, /subscribedCentral\.identifier == request\.central\.identifier/); + assert.match( + simulator, + /subscribedCentral\.identifier == request\.central\.identifier/, + ); assert.match(simulator, /insufficientAuthorization/); assert.match(simulator, /maximumUpdateValueLength/); assert.match(simulator, /outboundFrameOffset/); diff --git a/devices/sdk/c/CMakeLists.txt b/devices/sdk/c/CMakeLists.txt index c63a491..ad72789 100644 --- a/devices/sdk/c/CMakeLists.txt +++ b/devices/sdk/c/CMakeLists.txt @@ -53,19 +53,6 @@ if(NOT NEXTING_DEVICE_INFO_VECTOR_RESULT EQUAL 0) message(FATAL_ERROR "Could not generate C Device Info vectors") endif() -set(NEXTING_DEVICE_INTERACTION_VECTOR_HEADER - "${CMAKE_CURRENT_BINARY_DIR}/generated_interaction_vectors.h") -execute_process( - COMMAND "${NEXTING_DEVICE_NODE_EXECUTABLE}" - "${CMAKE_CURRENT_SOURCE_DIR}/tests/generate_interaction_vectors.mjs" - "${CMAKE_CURRENT_SOURCE_DIR}/../../protocol/vectors" - "${NEXTING_DEVICE_INTERACTION_VECTOR_HEADER}" - RESULT_VARIABLE NEXTING_DEVICE_INTERACTION_VECTOR_RESULT -) -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) target_include_directories(nexting_device PUBLIC include) target_compile_options(nexting_device PRIVATE -Wall -Wextra -Wpedantic -Werror) @@ -76,7 +63,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) 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..6744681 100644 --- a/devices/sdk/c/README.md +++ b/devices/sdk/c/README.md @@ -2,9 +2,7 @@ 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 - `approval/1`, `status/1`, `navigation/1`, `keys/1`, `rotary/1`, `voice/1`, - `text/1`, `usage/1`, and `config/1`; +- deterministic Experimental 0.2 message encoding and decoding; - 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; @@ -38,28 +36,3 @@ ctest --test-dir build-sanitize --output-on-failure Give `nexting_device_stream_t` a caller-owned receive buffer and feed it every downlink fragment. Forward complete `present` and `resolved` messages to `nexting_device_state_t`. Encode the returned `answer` into a caller-owned transmit buffer and notify the host. Call `nexting_device_state_disconnect` and `nexting_device_stream_reset` whenever the BLE connection ends. The state is intentionally volatile. A device must never restore an approval after reboot or reconnect. - -## Add an interaction control - -Declare only the profile the hardware implements in Device Info, decode the -Host map/display messages, and encode generic physical events. For example, a -press on custom key slot 3: - -```c -nexting_device_message_t event = {0}; -event.type = NEXTING_DEVICE_MESSAGE_KEY_EVENT; -event.interaction.slot = 3; -event.interaction.gesture = NEXTING_DEVICE_GESTURE_PRESS; -event.interaction.sequence = ++key_sequence; - -char wire[NEXTING_DEVICE_DEFAULT_MAX_MESSAGE_BYTES]; -size_t wire_length = 0; -if (nexting_device_encode(&event, wire, sizeof wire, &wire_length) == - NEXTING_DEVICE_OK) { - ble_notify((const uint8_t *)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. diff --git a/devices/sdk/c/include/nexting_device.h b/devices/sdk/c/include/nexting_device.h index 3e3f507..0d773cd 100644 --- a/devices/sdk/c/include/nexting_device.h +++ b/devices/sdk/c/include/nexting_device.h @@ -28,18 +28,6 @@ extern "C" { #define NEXTING_DEVICE_INFO_VENDOR_KEY_CAPACITY 33 #define NEXTING_DEVICE_INFO_VENDOR_LABEL_CAPACITY 65 #define NEXTING_DEVICE_INFO_VENDOR_VALUE_CAPACITY 129 -#define NEXTING_DEVICE_NAV_MAX_ITEMS 8U -#define NEXTING_DEVICE_NAV_ITEM_CAPACITY 65 -#define NEXTING_DEVICE_KEYS_MAX 64U -#define NEXTING_DEVICE_KEY_LABEL_CAPACITY 33 -#define NEXTING_DEVICE_ROTARY_MAX 16U -#define NEXTING_DEVICE_ROTARY_LABEL_CAPACITY 33 -#define NEXTING_DEVICE_VOICE_LABEL_CAPACITY 65 -#define NEXTING_DEVICE_TEXT_TITLE_CAPACITY 65 -#define NEXTING_DEVICE_TEXT_CONTENT_CAPACITY 1025 -#define NEXTING_DEVICE_CONFIG_MAX_ENTRIES 32U -#define NEXTING_DEVICE_CONFIG_KEY_CAPACITY 49 -#define NEXTING_DEVICE_CONFIG_STRING_CAPACITY 129 typedef enum { NEXTING_DEVICE_OK = 0, @@ -54,23 +42,7 @@ typedef enum { NEXTING_DEVICE_MESSAGE_ANSWER, NEXTING_DEVICE_MESSAGE_RESOLVED, NEXTING_DEVICE_MESSAGE_ERROR, - NEXTING_DEVICE_MESSAGE_STATUS, - NEXTING_DEVICE_MESSAGE_NAV_PRESENT, - NEXTING_DEVICE_MESSAGE_NAV_MOVE, - NEXTING_DEVICE_MESSAGE_NAV_SELECT, - NEXTING_DEVICE_MESSAGE_NAV_RESOLVED, - NEXTING_DEVICE_MESSAGE_KEYMAP, - NEXTING_DEVICE_MESSAGE_KEY_EVENT, - NEXTING_DEVICE_MESSAGE_ROTARY_MAP, - NEXTING_DEVICE_MESSAGE_ROTARY_EVENT, - NEXTING_DEVICE_MESSAGE_ROTARY_PRESS, - NEXTING_DEVICE_MESSAGE_VOICE_EVENT, - NEXTING_DEVICE_MESSAGE_VOICE_STATE, - NEXTING_DEVICE_MESSAGE_TEXT, - NEXTING_DEVICE_MESSAGE_USAGE, - NEXTING_DEVICE_MESSAGE_USAGE_CLEAR, - NEXTING_DEVICE_MESSAGE_CONFIG, - NEXTING_DEVICE_MESSAGE_CONFIG_RESULT + NEXTING_DEVICE_MESSAGE_STATUS } nexting_device_message_type_t; typedef enum { @@ -115,165 +87,6 @@ typedef struct { char label[NEXTING_DEVICE_STATUS_LABEL_CAPACITY]; } nexting_device_agent_status_t; -typedef enum { - NEXTING_DEVICE_DIRECTION_NONE = 0, - NEXTING_DEVICE_DIRECTION_PREV, - NEXTING_DEVICE_DIRECTION_NEXT, - NEXTING_DEVICE_DIRECTION_UP, - NEXTING_DEVICE_DIRECTION_DOWN, - NEXTING_DEVICE_DIRECTION_LEFT, - NEXTING_DEVICE_DIRECTION_RIGHT -} nexting_device_direction_t; - -typedef enum { - NEXTING_DEVICE_NAV_RESOLUTION_NONE = 0, - NEXTING_DEVICE_NAV_RESOLUTION_SELECTED, - NEXTING_DEVICE_NAV_RESOLUTION_CANCELLED, - NEXTING_DEVICE_NAV_RESOLUTION_EXPIRED, - NEXTING_DEVICE_NAV_RESOLUTION_REPLACED -} nexting_device_nav_resolution_t; - -typedef enum { - NEXTING_DEVICE_GESTURE_NONE = 0, - NEXTING_DEVICE_GESTURE_PRESS, - NEXTING_DEVICE_GESTURE_RELEASE, - NEXTING_DEVICE_GESTURE_HOLD, - NEXTING_DEVICE_GESTURE_DOUBLE -} nexting_device_gesture_t; - -typedef enum { - NEXTING_DEVICE_LIGHT_NONE = 0, - NEXTING_DEVICE_LIGHT_OFF, - NEXTING_DEVICE_LIGHT_DIM, - NEXTING_DEVICE_LIGHT_SOLID, - NEXTING_DEVICE_LIGHT_PULSE -} nexting_device_light_t; - -typedef enum { - NEXTING_DEVICE_VOICE_EVENT_NONE = 0, - NEXTING_DEVICE_VOICE_EVENT_START, - NEXTING_DEVICE_VOICE_EVENT_STOP, - NEXTING_DEVICE_VOICE_EVENT_CANCEL -} nexting_device_voice_event_t; - -typedef enum { - NEXTING_DEVICE_VOICE_NONE = 0, - NEXTING_DEVICE_VOICE_IDLE, - NEXTING_DEVICE_VOICE_LISTENING, - NEXTING_DEVICE_VOICE_TRANSCRIBING, - NEXTING_DEVICE_VOICE_SUBMITTED, - NEXTING_DEVICE_VOICE_ERROR -} nexting_device_voice_state_t; - -typedef enum { - NEXTING_DEVICE_CONFIG_VALUE_NONE = 0, - NEXTING_DEVICE_CONFIG_BOOLEAN, - NEXTING_DEVICE_CONFIG_INTEGER, - NEXTING_DEVICE_CONFIG_STRING -} nexting_device_config_value_type_t; - -typedef enum { - NEXTING_DEVICE_CONFIG_STATUS_NONE = 0, - NEXTING_DEVICE_CONFIG_APPLIED, - NEXTING_DEVICE_CONFIG_REJECTED -} nexting_device_config_status_t; - -typedef enum { - NEXTING_DEVICE_CONFIG_ERROR_NONE = 0, - NEXTING_DEVICE_CONFIG_UNKNOWN_KEY, - NEXTING_DEVICE_CONFIG_INVALID_VALUE, - NEXTING_DEVICE_CONFIG_STORAGE_ERROR, - NEXTING_DEVICE_CONFIG_UNSUPPORTED -} nexting_device_config_error_t; - -typedef struct { - size_t item_count; - char items[NEXTING_DEVICE_NAV_MAX_ITEMS][NEXTING_DEVICE_NAV_ITEM_CAPACITY]; - uint8_t cursor; - uint8_t index; - nexting_device_direction_t direction; - nexting_device_nav_resolution_t resolution; -} nexting_device_navigation_payload_t; - -typedef struct { - uint8_t slot; - char label[NEXTING_DEVICE_KEY_LABEL_CAPACITY]; - bool enabled; - nexting_device_light_t light; - bool has_rgb; - uint8_t rgb[3]; -} nexting_device_key_presentation_t; - -typedef struct { - size_t key_count; - nexting_device_key_presentation_t keys[NEXTING_DEVICE_KEYS_MAX]; -} nexting_device_keymap_payload_t; - -typedef struct { - uint8_t slot; - char label[NEXTING_DEVICE_ROTARY_LABEL_CAPACITY]; - int32_t value; - int32_t minimum; - int32_t maximum; - bool wrap; -} nexting_device_rotary_control_t; - -typedef struct { - size_t control_count; - nexting_device_rotary_control_t controls[NEXTING_DEVICE_ROTARY_MAX]; -} nexting_device_rotary_map_payload_t; - -typedef struct { - bool has_title; - char title[NEXTING_DEVICE_TEXT_TITLE_CAPACITY]; - char content[NEXTING_DEVICE_TEXT_CONTENT_CAPACITY]; -} nexting_device_text_payload_t; - -typedef struct { - char model[NEXTING_DEVICE_STATUS_LABEL_CAPACITY]; - uint64_t input_tokens; - uint64_t output_tokens; - bool has_cached_tokens; - uint64_t cached_tokens; - bool has_context; - uint64_t context_used; - uint64_t context_limit; -} nexting_device_usage_payload_t; - -typedef struct { - char key[NEXTING_DEVICE_CONFIG_KEY_CAPACITY]; - nexting_device_config_value_type_t type; - bool boolean_value; - int32_t integer_value; - char string_value[NEXTING_DEVICE_CONFIG_STRING_CAPACITY]; -} nexting_device_config_entry_t; - -typedef struct { - size_t entry_count; - nexting_device_config_entry_t entries[NEXTING_DEVICE_CONFIG_MAX_ENTRIES]; -} nexting_device_config_payload_t; - -typedef struct { - nexting_device_navigation_payload_t navigation; - uint32_t sequence; - uint32_t revision; - uint8_t slot; - nexting_device_gesture_t gesture; - nexting_device_keymap_payload_t keymap; - nexting_device_rotary_map_payload_t rotary_map; - int16_t delta; - nexting_device_voice_event_t voice_event; - nexting_device_voice_state_t voice_state; - bool has_label; - char label[NEXTING_DEVICE_VOICE_LABEL_CAPACITY]; - uint8_t channel; - nexting_device_text_payload_t text; - nexting_device_usage_payload_t usage; - nexting_device_config_payload_t config; - nexting_device_config_status_t config_status; - nexting_device_config_error_t config_error; -} nexting_device_interaction_payload_t; - typedef struct { nexting_device_message_type_t type; bool has_request_id; @@ -285,7 +98,6 @@ typedef struct { nexting_device_error_code_t error_code; size_t agent_count; nexting_device_agent_status_t agents[NEXTING_DEVICE_STATUS_MAX_AGENTS]; - nexting_device_interaction_payload_t interaction; } nexting_device_message_t; nexting_device_result_t nexting_device_decode(const char *wire, @@ -343,13 +155,6 @@ typedef struct { vendor_facts[NEXTING_DEVICE_INFO_MAX_VENDOR_FACTS]; bool supports_approval_v1; bool supports_status_v1; - bool supports_navigation_v1; - bool supports_keys_v1; - bool supports_rotary_v1; - bool supports_voice_v1; - bool supports_text_v1; - bool supports_usage_v1; - bool supports_config_v1; } nexting_device_info_t; nexting_device_result_t @@ -416,16 +221,6 @@ nexting_device_status_on_message(nexting_device_status_state_t *state, const nexting_device_message_t *message); void nexting_device_status_disconnect(nexting_device_status_state_t *state); -typedef struct { - bool has_value; - uint32_t latest; -} nexting_device_sequence_state_t; - -void nexting_device_sequence_init(nexting_device_sequence_state_t *state); -bool nexting_device_sequence_accept(nexting_device_sequence_state_t *state, - uint32_t sequence); -void nexting_device_sequence_disconnect(nexting_device_sequence_state_t *state); - #ifdef __cplusplus } #endif diff --git a/devices/sdk/c/src/nexting_device.c b/devices/sdk/c/src/nexting_device.c index 699280d..8664c0f 100644 --- a/devices/sdk/c/src/nexting_device.c +++ b/devices/sdk/c/src/nexting_device.c @@ -18,41 +18,18 @@ typedef struct { bool overflow; } writer_t; -#define FIELD_V (UINT64_C(1) << 0) -#define FIELD_T (UINT64_C(1) << 1) -#define FIELD_ID (UINT64_C(1) << 2) -#define FIELD_SUM (UINT64_C(1) << 3) -#define FIELD_OPT (UINT64_C(1) << 4) -#define FIELD_TTL (UINT64_C(1) << 5) -#define FIELD_CH (UINT64_C(1) << 6) -#define FIELD_R (UINT64_C(1) << 7) -#define FIELD_CODE (UINT64_C(1) << 8) -#define FIELD_AGENTS (UINT64_C(1) << 9) -#define FIELD_ITEMS (UINT64_C(1) << 10) -#define FIELD_CURSOR (UINT64_C(1) << 11) -#define FIELD_DIR (UINT64_C(1) << 12) -#define FIELD_SEQ (UINT64_C(1) << 13) -#define FIELD_INDEX (UINT64_C(1) << 14) -#define FIELD_REV (UINT64_C(1) << 15) -#define FIELD_KEYS (UINT64_C(1) << 16) -#define FIELD_SLOT (UINT64_C(1) << 17) -#define FIELD_EVENT (UINT64_C(1) << 18) -#define FIELD_CONTROLS (UINT64_C(1) << 19) -#define FIELD_DELTA (UINT64_C(1) << 20) -#define FIELD_STATE (UINT64_C(1) << 21) -#define FIELD_LABEL (UINT64_C(1) << 22) -#define FIELD_CHANNEL (UINT64_C(1) << 23) -#define FIELD_TITLE (UINT64_C(1) << 24) -#define FIELD_CONTENT (UINT64_C(1) << 25) -#define FIELD_MODEL (UINT64_C(1) << 26) -#define FIELD_INPUT_TOKENS (UINT64_C(1) << 27) -#define FIELD_OUTPUT_TOKENS (UINT64_C(1) << 28) -#define FIELD_CACHED_TOKENS (UINT64_C(1) << 29) -#define FIELD_CONTEXT_USED (UINT64_C(1) << 30) -#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) +enum { + FIELD_V = 1U << 0, + FIELD_T = 1U << 1, + FIELD_ID = 1U << 2, + FIELD_SUM = 1U << 3, + FIELD_OPT = 1U << 4, + FIELD_TTL = 1U << 5, + FIELD_CH = 1U << 6, + FIELD_R = 1U << 7, + FIELD_CODE = 1U << 8, + FIELD_AGENTS = 1U << 9 +}; static bool is_space(char value) { return value == ' ' || value == '\t' || value == '\n' || value == '\r'; @@ -306,27 +283,6 @@ static bool parse_uint(parser_t *parser, uint64_t *value) { return parser->position > start; } -static bool parse_int32(parser_t *parser, int32_t *value) { - bool negative = false; - uint64_t magnitude = 0; - skip_space(parser); - if (parser->position < parser->length && - parser->bytes[parser->position] == '-') { - negative = true; - parser->position += 1U; - } - if (!parse_uint(parser, &magnitude)) - return false; - if ((!negative && magnitude > INT32_MAX) || - (negative && magnitude > (uint64_t)INT32_MAX + 1U)) - return false; - *value = negative ? (magnitude == (uint64_t)INT32_MAX + 1U - ? INT32_MIN - : -(int32_t)magnitude) - : (int32_t)magnitude; - return true; -} - static bool skip_number(parser_t *parser) { size_t start; skip_space(parser); @@ -654,422 +610,7 @@ static bool parse_agents(parser_t *parser, nexting_device_message_t *message) { } } -static bool parse_boolean(parser_t *parser, bool *value); - -static bool valid_profile_text(const char *value, size_t capacity, - bool allow_layout, bool allow_empty) { - size_t length = 0; - if (!bounded_length(value, capacity, &length) || - (!allow_empty && length == 0U) || !valid_utf8_cstring(value, capacity)) - return false; - for (size_t index = 0; index < length; ++index) { - const uint8_t byte = (uint8_t)value[index]; - if (byte == 0x7FU || (byte < 0x20U && - !(allow_layout && (byte == '\n' || byte == '\t')))) - return false; - } - return true; -} - -static nexting_device_direction_t parse_direction(const char *value) { - if (strcmp(value, "prev") == 0) - return NEXTING_DEVICE_DIRECTION_PREV; - if (strcmp(value, "next") == 0) - return NEXTING_DEVICE_DIRECTION_NEXT; - if (strcmp(value, "up") == 0) - return NEXTING_DEVICE_DIRECTION_UP; - if (strcmp(value, "down") == 0) - return NEXTING_DEVICE_DIRECTION_DOWN; - if (strcmp(value, "left") == 0) - return NEXTING_DEVICE_DIRECTION_LEFT; - if (strcmp(value, "right") == 0) - return NEXTING_DEVICE_DIRECTION_RIGHT; - return NEXTING_DEVICE_DIRECTION_NONE; -} - -static nexting_device_nav_resolution_t -parse_nav_resolution(const char *value) { - if (strcmp(value, "selected") == 0) - return NEXTING_DEVICE_NAV_RESOLUTION_SELECTED; - if (strcmp(value, "cancelled") == 0) - return NEXTING_DEVICE_NAV_RESOLUTION_CANCELLED; - if (strcmp(value, "expired") == 0) - return NEXTING_DEVICE_NAV_RESOLUTION_EXPIRED; - if (strcmp(value, "replaced") == 0) - return NEXTING_DEVICE_NAV_RESOLUTION_REPLACED; - return NEXTING_DEVICE_NAV_RESOLUTION_NONE; -} - -static nexting_device_gesture_t parse_gesture(const char *value) { - if (strcmp(value, "press") == 0) - return NEXTING_DEVICE_GESTURE_PRESS; - if (strcmp(value, "release") == 0) - return NEXTING_DEVICE_GESTURE_RELEASE; - if (strcmp(value, "hold") == 0) - return NEXTING_DEVICE_GESTURE_HOLD; - if (strcmp(value, "double") == 0) - return NEXTING_DEVICE_GESTURE_DOUBLE; - return NEXTING_DEVICE_GESTURE_NONE; -} - -static nexting_device_light_t parse_light(const char *value) { - if (strcmp(value, "off") == 0) - return NEXTING_DEVICE_LIGHT_OFF; - if (strcmp(value, "dim") == 0) - return NEXTING_DEVICE_LIGHT_DIM; - if (strcmp(value, "solid") == 0) - return NEXTING_DEVICE_LIGHT_SOLID; - if (strcmp(value, "pulse") == 0) - return NEXTING_DEVICE_LIGHT_PULSE; - return NEXTING_DEVICE_LIGHT_NONE; -} - -static nexting_device_voice_event_t parse_voice_event(const char *value) { - if (strcmp(value, "start") == 0) - return NEXTING_DEVICE_VOICE_EVENT_START; - if (strcmp(value, "stop") == 0) - return NEXTING_DEVICE_VOICE_EVENT_STOP; - if (strcmp(value, "cancel") == 0) - return NEXTING_DEVICE_VOICE_EVENT_CANCEL; - return NEXTING_DEVICE_VOICE_EVENT_NONE; -} - -static nexting_device_voice_state_t parse_voice_state(const char *value) { - if (strcmp(value, "idle") == 0) - return NEXTING_DEVICE_VOICE_IDLE; - if (strcmp(value, "listening") == 0) - return NEXTING_DEVICE_VOICE_LISTENING; - if (strcmp(value, "transcribing") == 0) - return NEXTING_DEVICE_VOICE_TRANSCRIBING; - if (strcmp(value, "submitted") == 0) - return NEXTING_DEVICE_VOICE_SUBMITTED; - if (strcmp(value, "error") == 0) - return NEXTING_DEVICE_VOICE_ERROR; - return NEXTING_DEVICE_VOICE_NONE; -} - -static nexting_device_config_status_t parse_config_status(const char *value) { - if (strcmp(value, "applied") == 0) - return NEXTING_DEVICE_CONFIG_APPLIED; - if (strcmp(value, "rejected") == 0) - return NEXTING_DEVICE_CONFIG_REJECTED; - return NEXTING_DEVICE_CONFIG_STATUS_NONE; -} - -static nexting_device_config_error_t parse_config_error(const char *value) { - if (strcmp(value, "unknown_key") == 0) - return NEXTING_DEVICE_CONFIG_UNKNOWN_KEY; - if (strcmp(value, "invalid_value") == 0) - return NEXTING_DEVICE_CONFIG_INVALID_VALUE; - if (strcmp(value, "storage_error") == 0) - return NEXTING_DEVICE_CONFIG_STORAGE_ERROR; - if (strcmp(value, "unsupported") == 0) - return NEXTING_DEVICE_CONFIG_UNSUPPORTED; - return NEXTING_DEVICE_CONFIG_ERROR_NONE; -} - -static bool parse_navigation_items(parser_t *parser, - nexting_device_message_t *message) { - nexting_device_navigation_payload_t *navigation = - &message->interaction.navigation; - if (!take(parser, '[')) - return false; - skip_space(parser); - if (take(parser, ']')) - return false; - for (;;) { - if (navigation->item_count >= NEXTING_DEVICE_NAV_MAX_ITEMS || - !parse_string(parser, navigation->items[navigation->item_count], - sizeof navigation->items[navigation->item_count]) || - !valid_profile_text(navigation->items[navigation->item_count], - sizeof navigation->items[navigation->item_count], - false, false)) - return false; - for (size_t index = 0; index < navigation->item_count; ++index) { - if (strcmp(navigation->items[index], - navigation->items[navigation->item_count]) == 0) - return false; - } - navigation->item_count += 1U; - skip_space(parser); - if (take(parser, ']')) - return navigation->item_count >= 2U; - if (!take(parser, ',')) - return false; - } -} - -static bool parse_rgb(parser_t *parser, uint8_t rgb[3]) { - if (!take(parser, '[')) - return false; - for (size_t index = 0; index < 3U; ++index) { - uint64_t value = 0; - if (!parse_uint(parser, &value) || value > 255U) - return false; - rgb[index] = (uint8_t)value; - if (index < 2U && !take(parser, ',')) - return false; - } - return take(parser, ']'); -} - -static bool parse_key_presentation(parser_t *parser, - nexting_device_key_presentation_t *key) { - bool has_slot = false; - bool has_label = false; - bool has_enabled = false; - bool has_light = false; - if (!take(parser, '{')) - return false; - for (;;) { - char field[16]; - if (!parse_object_key(parser, field, sizeof field) || !take(parser, ':')) - return false; - if (strcmp(field, "slot") == 0) { - uint64_t value = 0; - if (has_slot || !parse_uint(parser, &value) || value > 63U) - return false; - key->slot = (uint8_t)value; - has_slot = true; - } else if (strcmp(field, "label") == 0) { - if (has_label || - !parse_string(parser, key->label, sizeof key->label) || - !valid_profile_text(key->label, sizeof key->label, false, false)) - return false; - has_label = true; - } else if (strcmp(field, "enabled") == 0) { - if (has_enabled || !parse_boolean(parser, &key->enabled)) - return false; - has_enabled = true; - } else if (strcmp(field, "light") == 0) { - char value[16]; - if (has_light || !parse_string(parser, value, sizeof value)) - return false; - key->light = parse_light(value); - has_light = true; - } else if (strcmp(field, "rgb") == 0) { - if (key->has_rgb || !parse_rgb(parser, key->rgb)) - return false; - key->has_rgb = true; - } else { - return false; - } - skip_space(parser); - if (take(parser, '}')) - break; - if (!take(parser, ',')) - return false; - } - return has_slot && has_label && has_enabled && has_light && - key->light != NEXTING_DEVICE_LIGHT_NONE; -} - -static bool parse_keymap(parser_t *parser, nexting_device_message_t *message) { - nexting_device_keymap_payload_t *keymap = &message->interaction.keymap; - if (!take(parser, '[')) - return false; - skip_space(parser); - if (take(parser, ']')) - return true; - for (;;) { - if (keymap->key_count >= NEXTING_DEVICE_KEYS_MAX || - !parse_key_presentation(parser, &keymap->keys[keymap->key_count])) - return false; - for (size_t index = 0; index < keymap->key_count; ++index) { - if (keymap->keys[index].slot == keymap->keys[keymap->key_count].slot) - return false; - } - keymap->key_count += 1U; - skip_space(parser); - if (take(parser, ']')) - return true; - if (!take(parser, ',')) - return false; - } -} - -static bool parse_rotary_control(parser_t *parser, - nexting_device_rotary_control_t *control) { - bool has_slot = false; - bool has_label = false; - bool has_value = false; - bool has_minimum = false; - bool has_maximum = false; - bool has_wrap = false; - if (!take(parser, '{')) - return false; - for (;;) { - char field[16]; - if (!parse_object_key(parser, field, sizeof field) || !take(parser, ':')) - return false; - if (strcmp(field, "slot") == 0) { - uint64_t value = 0; - if (has_slot || !parse_uint(parser, &value) || value > 15U) - return false; - control->slot = (uint8_t)value; - has_slot = true; - } else if (strcmp(field, "label") == 0) { - if (has_label || - !parse_string(parser, control->label, sizeof control->label) || - !valid_profile_text(control->label, sizeof control->label, false, - false)) - return false; - has_label = true; - } else if (strcmp(field, "value") == 0) { - if (has_value || !parse_int32(parser, &control->value)) - return false; - has_value = true; - } else if (strcmp(field, "min") == 0) { - if (has_minimum || !parse_int32(parser, &control->minimum)) - return false; - has_minimum = true; - } else if (strcmp(field, "max") == 0) { - if (has_maximum || !parse_int32(parser, &control->maximum)) - return false; - has_maximum = true; - } else if (strcmp(field, "wrap") == 0) { - if (has_wrap || !parse_boolean(parser, &control->wrap)) - return false; - has_wrap = true; - } else { - return false; - } - skip_space(parser); - if (take(parser, '}')) - break; - if (!take(parser, ',')) - return false; - } - return has_slot && has_label && has_value && has_minimum && has_maximum && - has_wrap && control->minimum >= -1000000 && - control->maximum <= 1000000 && - control->minimum <= control->value && - control->value <= control->maximum; -} - -static bool parse_rotary_map(parser_t *parser, - nexting_device_message_t *message) { - nexting_device_rotary_map_payload_t *map = &message->interaction.rotary_map; - if (!take(parser, '[')) - return false; - skip_space(parser); - if (take(parser, ']')) - return true; - for (;;) { - if (map->control_count >= NEXTING_DEVICE_ROTARY_MAX || - !parse_rotary_control(parser, &map->controls[map->control_count])) - return false; - for (size_t index = 0; index < map->control_count; ++index) { - if (map->controls[index].slot == map->controls[map->control_count].slot) - return false; - } - map->control_count += 1U; - skip_space(parser); - if (take(parser, ']')) - return true; - if (!take(parser, ',')) - return false; - } -} - -static bool valid_config_key(const char *value) { - size_t length = 0; - if (!bounded_length(value, NEXTING_DEVICE_CONFIG_KEY_CAPACITY, &length) || - length == 0U) - return false; - for (size_t index = 0; index < length; ++index) { - const char byte = value[index]; - const bool alpha_numeric = - (byte >= 'A' && byte <= 'Z') || (byte >= 'a' && byte <= 'z') || - (byte >= '0' && byte <= '9'); - if (!alpha_numeric && - (index == 0U || (byte != '.' && byte != '_' && byte != '-'))) - return false; - } - return true; -} - -static bool parse_config_value(parser_t *parser, - nexting_device_config_entry_t *entry) { - const size_t start = parser->position; - if (parse_boolean(parser, &entry->boolean_value)) { - entry->type = NEXTING_DEVICE_CONFIG_BOOLEAN; - return true; - } - parser->position = start; - if (parse_int32(parser, &entry->integer_value)) { - if (entry->integer_value < -1000000 || entry->integer_value > 1000000) - return false; - entry->type = NEXTING_DEVICE_CONFIG_INTEGER; - return true; - } - parser->position = start; - if (parse_string(parser, entry->string_value, sizeof entry->string_value) && - valid_profile_text(entry->string_value, sizeof entry->string_value, - false, true)) { - entry->type = NEXTING_DEVICE_CONFIG_STRING; - return true; - } - return false; -} - -static bool parse_config_entry(parser_t *parser, - nexting_device_config_entry_t *entry) { - bool has_key = false; - bool has_value = false; - if (!take(parser, '{')) - return false; - for (;;) { - char field[16]; - if (!parse_object_key(parser, field, sizeof field) || !take(parser, ':')) - return false; - if (strcmp(field, "key") == 0) { - if (has_key || !parse_string(parser, entry->key, sizeof entry->key)) - return false; - has_key = true; - } else if (strcmp(field, "value") == 0) { - if (has_value || !parse_config_value(parser, entry)) - return false; - has_value = true; - } else { - return false; - } - skip_space(parser); - if (take(parser, '}')) - break; - if (!take(parser, ',')) - return false; - } - return has_key && has_value && valid_config_key(entry->key); -} - -static bool parse_config_entries(parser_t *parser, - nexting_device_message_t *message) { - nexting_device_config_payload_t *config = &message->interaction.config; - if (!take(parser, '[')) - return false; - skip_space(parser); - if (take(parser, ']')) - return true; - for (;;) { - if (config->entry_count >= NEXTING_DEVICE_CONFIG_MAX_ENTRIES || - !parse_config_entry(parser, &config->entries[config->entry_count])) - return false; - for (size_t index = 0; index < config->entry_count; ++index) { - if (strcmp(config->entries[index].key, - config->entries[config->entry_count].key) == 0) - return false; - } - config->entry_count += 1U; - skip_space(parser); - if (take(parser, ']')) - return true; - if (!take(parser, ',')) - return false; - } -} - -static bool claim_field(uint64_t *fields, uint64_t field) { +static bool claim_field(unsigned *fields, unsigned field) { if ((*fields & field) != 0U) return false; *fields |= field; @@ -1077,7 +618,7 @@ static bool claim_field(uint64_t *fields, uint64_t field) { } static bool parse_known_field(parser_t *parser, const char *key, - uint64_t *fields, uint64_t *version, char *type, + unsigned *fields, uint64_t *version, char *type, size_t type_capacity, nexting_device_message_t *message) { char value[32]; @@ -1122,7 +663,6 @@ static bool parse_known_field(parser_t *parser, const char *key, !parse_string(parser, value, sizeof value)) return false; message->resolution = parse_resolution(value); - message->interaction.navigation.resolution = parse_nav_resolution(value); return true; } if (strcmp(key, "code") == 0) { @@ -1130,161 +670,16 @@ static bool parse_known_field(parser_t *parser, const char *key, !parse_string(parser, value, sizeof value)) return false; message->error_code = parse_error(value); - message->interaction.config_error = parse_config_error(value); return true; } if (strcmp(key, "agents") == 0) { return claim_field(fields, FIELD_AGENTS) && parse_agents(parser, message); } - if (strcmp(key, "items") == 0) { - return claim_field(fields, FIELD_ITEMS) && - parse_navigation_items(parser, message); - } - if (strcmp(key, "cursor") == 0 || strcmp(key, "index") == 0 || - strcmp(key, "slot") == 0 || strcmp(key, "channel") == 0) { - const uint64_t field = strcmp(key, "cursor") == 0 - ? FIELD_CURSOR - : strcmp(key, "index") == 0 - ? FIELD_INDEX - : strcmp(key, "slot") == 0 ? FIELD_SLOT - : FIELD_CHANNEL; - if (!claim_field(fields, field) || !parse_uint(parser, &number) || - number > UINT8_MAX) - return false; - if (field == FIELD_CURSOR) - message->interaction.navigation.cursor = (uint8_t)number; - else if (field == FIELD_INDEX) - message->interaction.navigation.index = (uint8_t)number; - else if (field == FIELD_SLOT) - message->interaction.slot = (uint8_t)number; - else - message->interaction.channel = (uint8_t)number; - return true; - } - if (strcmp(key, "dir") == 0) { - if (!claim_field(fields, FIELD_DIR) || - !parse_string(parser, value, sizeof value)) - return false; - message->interaction.navigation.direction = parse_direction(value); - return true; - } - if (strcmp(key, "seq") == 0 || strcmp(key, "rev") == 0) { - const uint64_t field = strcmp(key, "seq") == 0 ? FIELD_SEQ : FIELD_REV; - if (!claim_field(fields, field) || !parse_uint(parser, &number) || - number > UINT32_MAX) - return false; - if (field == FIELD_SEQ) - message->interaction.sequence = (uint32_t)number; - else - message->interaction.revision = (uint32_t)number; - return true; - } - if (strcmp(key, "keys") == 0) { - return claim_field(fields, FIELD_KEYS) && parse_keymap(parser, message); - } - if (strcmp(key, "event") == 0) { - if (!claim_field(fields, FIELD_EVENT) || - !parse_string(parser, value, sizeof value)) - return false; - message->interaction.gesture = parse_gesture(value); - message->interaction.voice_event = parse_voice_event(value); - return true; - } - if (strcmp(key, "controls") == 0) { - return claim_field(fields, FIELD_CONTROLS) && - parse_rotary_map(parser, message); - } - if (strcmp(key, "delta") == 0) { - int32_t delta = 0; - if (!claim_field(fields, FIELD_DELTA) || !parse_int32(parser, &delta) || - delta < INT16_MIN || delta > INT16_MAX) - return false; - message->interaction.delta = (int16_t)delta; - return true; - } - if (strcmp(key, "state") == 0) { - if (!claim_field(fields, FIELD_STATE) || - !parse_string(parser, value, sizeof value)) - return false; - message->interaction.voice_state = parse_voice_state(value); - return true; - } - if (strcmp(key, "label") == 0) { - if (!claim_field(fields, FIELD_LABEL) || - !parse_string(parser, message->interaction.label, - sizeof message->interaction.label)) - return false; - message->interaction.has_label = true; - return true; - } - if (strcmp(key, "title") == 0) { - if (!claim_field(fields, FIELD_TITLE) || - !parse_string(parser, message->interaction.text.title, - sizeof message->interaction.text.title)) - return false; - message->interaction.text.has_title = true; - return true; - } - if (strcmp(key, "content") == 0) { - return claim_field(fields, FIELD_CONTENT) && - parse_string(parser, message->interaction.text.content, - sizeof message->interaction.text.content); - } - if (strcmp(key, "model") == 0) { - return claim_field(fields, FIELD_MODEL) && - parse_string(parser, message->interaction.usage.model, - sizeof message->interaction.usage.model); - } - if (strcmp(key, "input_tokens") == 0 || - strcmp(key, "output_tokens") == 0 || - strcmp(key, "cached_tokens") == 0 || - strcmp(key, "context_used") == 0 || - strcmp(key, "context_limit") == 0) { - const uint64_t field = - strcmp(key, "input_tokens") == 0 - ? FIELD_INPUT_TOKENS - : strcmp(key, "output_tokens") == 0 - ? FIELD_OUTPUT_TOKENS - : strcmp(key, "cached_tokens") == 0 - ? FIELD_CACHED_TOKENS - : strcmp(key, "context_used") == 0 - ? FIELD_CONTEXT_USED - : FIELD_CONTEXT_LIMIT; - if (!claim_field(fields, field) || !parse_uint(parser, &number) || - number > UINT64_C(9007199254740991)) - return false; - if (field == FIELD_INPUT_TOKENS) - message->interaction.usage.input_tokens = number; - else if (field == FIELD_OUTPUT_TOKENS) - message->interaction.usage.output_tokens = number; - else if (field == FIELD_CACHED_TOKENS) { - message->interaction.usage.cached_tokens = number; - message->interaction.usage.has_cached_tokens = true; - } else if (field == FIELD_CONTEXT_USED) { - message->interaction.usage.context_used = number; - message->interaction.usage.has_context = true; - } else { - message->interaction.usage.context_limit = number; - } - return true; - } - if (strcmp(key, "entries") == 0) { - return claim_field(fields, FIELD_ENTRIES) && - parse_config_entries(parser, message); - } - if (strcmp(key, "status") == 0) { - if (!claim_field(fields, FIELD_STATUS) || - !parse_string(parser, value, sizeof value)) - return false; - message->interaction.config_status = parse_config_status(value); - return true; - } - *fields |= FIELD_UNKNOWN; return skip_value(parser, 0); } static bool validate_message(nexting_device_message_t *message, - const char *type, uint64_t fields, + const char *type, unsigned fields, uint64_t version) { if ((fields & (FIELD_V | FIELD_T)) != (FIELD_V | FIELD_T) || version != 1) return false; @@ -1325,172 +720,6 @@ static bool validate_message(nexting_device_message_t *message, message->type = NEXTING_DEVICE_MESSAGE_STATUS; return true; } - if (strcmp(type, "nav_present") == 0) { - const uint64_t allowed = FIELD_V | FIELD_T | FIELD_ID | FIELD_ITEMS | - FIELD_CURSOR | FIELD_TTL; - if (fields != allowed || - message->interaction.navigation.item_count < 2U || - message->interaction.navigation.cursor >= - message->interaction.navigation.item_count || - message->ttl_ms < 1U || - message->ttl_ms > NEXTING_DEVICE_MAX_TTL_MS) - return false; - message->type = NEXTING_DEVICE_MESSAGE_NAV_PRESENT; - return true; - } - if (strcmp(type, "nav_move") == 0) { - const uint64_t allowed = - FIELD_V | FIELD_T | FIELD_ID | FIELD_DIR | FIELD_SEQ; - if (fields != allowed || - message->interaction.navigation.direction == - NEXTING_DEVICE_DIRECTION_NONE) - return false; - message->type = NEXTING_DEVICE_MESSAGE_NAV_MOVE; - return true; - } - if (strcmp(type, "nav_select") == 0) { - const uint64_t allowed = - FIELD_V | FIELD_T | FIELD_ID | FIELD_INDEX | FIELD_SEQ; - if (fields != allowed || message->interaction.navigation.index > 7U) - return false; - message->type = NEXTING_DEVICE_MESSAGE_NAV_SELECT; - return true; - } - if (strcmp(type, "nav_resolved") == 0) { - const uint64_t allowed = FIELD_V | FIELD_T | FIELD_ID | FIELD_R; - if (fields != allowed || - message->interaction.navigation.resolution == - NEXTING_DEVICE_NAV_RESOLUTION_NONE) - return false; - message->type = NEXTING_DEVICE_MESSAGE_NAV_RESOLVED; - return true; - } - if (strcmp(type, "keymap") == 0) { - if (fields != (FIELD_V | FIELD_T | FIELD_REV | FIELD_KEYS)) - return false; - message->type = NEXTING_DEVICE_MESSAGE_KEYMAP; - return true; - } - if (strcmp(type, "key_event") == 0) { - if (fields != (FIELD_V | FIELD_T | FIELD_SLOT | FIELD_EVENT | FIELD_SEQ) || - message->interaction.slot > 63U || - message->interaction.gesture == NEXTING_DEVICE_GESTURE_NONE) - return false; - message->type = NEXTING_DEVICE_MESSAGE_KEY_EVENT; - return true; - } - if (strcmp(type, "rotary_map") == 0) { - if (fields != (FIELD_V | FIELD_T | FIELD_REV | FIELD_CONTROLS)) - return false; - message->type = NEXTING_DEVICE_MESSAGE_ROTARY_MAP; - return true; - } - if (strcmp(type, "rotary_event") == 0) { - if (fields != - (FIELD_V | FIELD_T | FIELD_SLOT | FIELD_DELTA | FIELD_SEQ) || - message->interaction.slot > 15U || message->interaction.delta == 0 || - message->interaction.delta < -127 || message->interaction.delta > 127) - return false; - message->type = NEXTING_DEVICE_MESSAGE_ROTARY_EVENT; - return true; - } - if (strcmp(type, "rotary_press") == 0) { - if (fields != - (FIELD_V | FIELD_T | FIELD_SLOT | FIELD_EVENT | FIELD_SEQ) || - message->interaction.slot > 15U || - message->interaction.gesture == NEXTING_DEVICE_GESTURE_NONE) - return false; - message->type = NEXTING_DEVICE_MESSAGE_ROTARY_PRESS; - return true; - } - if (strcmp(type, "voice_event") == 0) { - if (fields != (FIELD_V | FIELD_T | FIELD_EVENT | FIELD_SEQ) || - message->interaction.voice_event == NEXTING_DEVICE_VOICE_EVENT_NONE) - return false; - message->type = NEXTING_DEVICE_MESSAGE_VOICE_EVENT; - return true; - } - if (strcmp(type, "voice_state") == 0) { - const uint64_t required = FIELD_V | FIELD_T | FIELD_STATE; - const uint64_t allowed = required | FIELD_LABEL; - if ((fields & required) != required || (fields & ~allowed) != 0U || - message->interaction.voice_state == NEXTING_DEVICE_VOICE_NONE || - (message->interaction.has_label && - !valid_profile_text(message->interaction.label, - sizeof message->interaction.label, false, false))) - return false; - message->type = NEXTING_DEVICE_MESSAGE_VOICE_STATE; - return true; - } - if (strcmp(type, "text") == 0) { - const uint64_t required = FIELD_V | FIELD_T | FIELD_CHANNEL | FIELD_CONTENT; - const uint64_t allowed = required | FIELD_TITLE; - if ((fields & required) != required || (fields & ~allowed) != 0U || - message->interaction.channel > 7U || - !valid_profile_text(message->interaction.text.content, - sizeof message->interaction.text.content, true, - true) || - (message->interaction.text.has_title && - !valid_profile_text(message->interaction.text.title, - sizeof message->interaction.text.title, false, - false))) - return false; - message->type = NEXTING_DEVICE_MESSAGE_TEXT; - return true; - } - if (strcmp(type, "usage") == 0) { - const uint64_t required = - FIELD_V | FIELD_T | FIELD_MODEL | FIELD_INPUT_TOKENS | - FIELD_OUTPUT_TOKENS; - const uint64_t allowed = required | FIELD_CACHED_TOKENS | - FIELD_CONTEXT_USED | FIELD_CONTEXT_LIMIT; - const bool context_pair = - (fields & (FIELD_CONTEXT_USED | FIELD_CONTEXT_LIMIT)) == 0U || - (fields & (FIELD_CONTEXT_USED | FIELD_CONTEXT_LIMIT)) == - (FIELD_CONTEXT_USED | FIELD_CONTEXT_LIMIT); - if ((fields & required) != required || (fields & ~allowed) != 0U || - !context_pair || - !valid_profile_text(message->interaction.usage.model, - sizeof message->interaction.usage.model, false, - false) || - ((fields & FIELD_CONTEXT_USED) != 0U && - message->interaction.usage.context_used > - message->interaction.usage.context_limit)) - return false; - message->interaction.usage.has_context = - (fields & FIELD_CONTEXT_USED) != 0U; - message->type = NEXTING_DEVICE_MESSAGE_USAGE; - return true; - } - if (strcmp(type, "usage_clear") == 0) { - if (fields != (FIELD_V | FIELD_T)) - return false; - message->type = NEXTING_DEVICE_MESSAGE_USAGE_CLEAR; - return true; - } - if (strcmp(type, "config") == 0) { - if (fields != (FIELD_V | FIELD_T | FIELD_REV | FIELD_ENTRIES)) - return false; - message->type = NEXTING_DEVICE_MESSAGE_CONFIG; - return true; - } - if (strcmp(type, "config_result") == 0) { - const uint64_t required = FIELD_V | FIELD_T | FIELD_REV | FIELD_STATUS; - const uint64_t allowed = required | FIELD_CODE; - if ((fields & required) != required || (fields & ~allowed) != 0U || - message->interaction.config_status == - NEXTING_DEVICE_CONFIG_STATUS_NONE || - (message->interaction.config_status == NEXTING_DEVICE_CONFIG_APPLIED && - (fields & FIELD_CODE) != 0U) || - (message->interaction.config_status == - NEXTING_DEVICE_CONFIG_REJECTED && - ((fields & FIELD_CODE) == 0U || - message->interaction.config_error == - NEXTING_DEVICE_CONFIG_ERROR_NONE))) - return false; - message->type = NEXTING_DEVICE_MESSAGE_CONFIG_RESULT; - return true; - } return false; } @@ -1500,7 +729,7 @@ nexting_device_decode(const char *wire, size_t wire_length, parser_t parser; nexting_device_message_t message = {0}; char type[16] = {0}; - uint64_t fields = 0; + unsigned fields = 0; uint64_t version = 0; if (wire == NULL || output == NULL || wire_length == 0) return NEXTING_DEVICE_BAD_MESSAGE; @@ -1997,21 +1226,6 @@ static bool validate_device_info(nexting_device_info_t *info, info->supports_approval_v1 = true; info->supports_status_v1 = info->status_slots > 0U && list_contains_profile(info, "status/1"); - info->supports_navigation_v1 = - list_contains_profile(info, "navigation/1"); - info->supports_keys_v1 = list_contains_profile(info, "keys/1"); - info->supports_rotary_v1 = list_contains_profile(info, "rotary/1"); - info->supports_voice_v1 = list_contains_profile(info, "voice/1"); - 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"); - return true; -} - -static bool claim_info_field(uint32_t *fields, uint32_t field) { - if ((*fields & field) != 0U) - return false; - *fields |= field; return true; } @@ -2019,7 +1233,7 @@ static bool parse_device_info_field(parser_t *parser, const char *key, uint32_t *fields, nexting_device_info_t *info) { uint64_t number = 0; -#define CLAIM_INFO_FIELD(bit) claim_info_field(fields, (uint32_t)(bit)) +#define CLAIM_INFO_FIELD(bit) claim_field((unsigned *)fields, (unsigned)(bit)) if (strcmp(key, "protocol") == 0) return CLAIM_INFO_FIELD(DI_FIELD_PROTOCOL) && parse_string(parser, info->protocol_name, @@ -2300,128 +1514,6 @@ static const char *agent_state_string(nexting_device_agent_state_t state) { } } -static const char *direction_string(nexting_device_direction_t direction) { - switch (direction) { - case NEXTING_DEVICE_DIRECTION_PREV: - return "prev"; - case NEXTING_DEVICE_DIRECTION_NEXT: - return "next"; - case NEXTING_DEVICE_DIRECTION_UP: - return "up"; - case NEXTING_DEVICE_DIRECTION_DOWN: - return "down"; - case NEXTING_DEVICE_DIRECTION_LEFT: - return "left"; - case NEXTING_DEVICE_DIRECTION_RIGHT: - return "right"; - default: - return NULL; - } -} - -static const char * -nav_resolution_string(nexting_device_nav_resolution_t resolution) { - switch (resolution) { - case NEXTING_DEVICE_NAV_RESOLUTION_SELECTED: - return "selected"; - case NEXTING_DEVICE_NAV_RESOLUTION_CANCELLED: - return "cancelled"; - case NEXTING_DEVICE_NAV_RESOLUTION_EXPIRED: - return "expired"; - case NEXTING_DEVICE_NAV_RESOLUTION_REPLACED: - return "replaced"; - default: - return NULL; - } -} - -static const char *gesture_string(nexting_device_gesture_t gesture) { - switch (gesture) { - case NEXTING_DEVICE_GESTURE_PRESS: - return "press"; - case NEXTING_DEVICE_GESTURE_RELEASE: - return "release"; - case NEXTING_DEVICE_GESTURE_HOLD: - return "hold"; - case NEXTING_DEVICE_GESTURE_DOUBLE: - return "double"; - default: - return NULL; - } -} - -static const char *light_string(nexting_device_light_t light) { - switch (light) { - case NEXTING_DEVICE_LIGHT_OFF: - return "off"; - case NEXTING_DEVICE_LIGHT_DIM: - return "dim"; - case NEXTING_DEVICE_LIGHT_SOLID: - return "solid"; - case NEXTING_DEVICE_LIGHT_PULSE: - return "pulse"; - default: - return NULL; - } -} - -static const char * -voice_event_string(nexting_device_voice_event_t voice_event) { - switch (voice_event) { - case NEXTING_DEVICE_VOICE_EVENT_START: - return "start"; - case NEXTING_DEVICE_VOICE_EVENT_STOP: - return "stop"; - case NEXTING_DEVICE_VOICE_EVENT_CANCEL: - return "cancel"; - default: - return NULL; - } -} - -static const char * -voice_state_string(nexting_device_voice_state_t voice_state) { - switch (voice_state) { - case NEXTING_DEVICE_VOICE_IDLE: - return "idle"; - case NEXTING_DEVICE_VOICE_LISTENING: - return "listening"; - case NEXTING_DEVICE_VOICE_TRANSCRIBING: - return "transcribing"; - case NEXTING_DEVICE_VOICE_SUBMITTED: - return "submitted"; - case NEXTING_DEVICE_VOICE_ERROR: - return "error"; - default: - return NULL; - } -} - -static const char * -config_status_string(nexting_device_config_status_t status) { - if (status == NEXTING_DEVICE_CONFIG_APPLIED) - return "applied"; - if (status == NEXTING_DEVICE_CONFIG_REJECTED) - return "rejected"; - return NULL; -} - -static const char * -config_error_string(nexting_device_config_error_t error) { - switch (error) { - case NEXTING_DEVICE_CONFIG_UNKNOWN_KEY: - return "unknown_key"; - case NEXTING_DEVICE_CONFIG_INVALID_VALUE: - return "invalid_value"; - case NEXTING_DEVICE_CONFIG_STORAGE_ERROR: - return "storage_error"; - case NEXTING_DEVICE_CONFIG_UNSUPPORTED: - return "unsupported"; - default: - return NULL; - } -} - static bool valid_message_for_encode(const nexting_device_message_t *message) { if (message == NULL) return false; @@ -2444,154 +1536,6 @@ static bool valid_message_for_encode(const nexting_device_message_t *message) { error_string(message->error_code) != NULL; case NEXTING_DEVICE_MESSAGE_STATUS: return valid_status_agents(message); - case NEXTING_DEVICE_MESSAGE_NAV_PRESENT: - if (!valid_request_id(message->request_id) || - message->interaction.navigation.item_count < 2U || - message->interaction.navigation.item_count > - NEXTING_DEVICE_NAV_MAX_ITEMS || - message->interaction.navigation.cursor >= - message->interaction.navigation.item_count || - message->ttl_ms < 1U || - message->ttl_ms > NEXTING_DEVICE_MAX_TTL_MS) - return false; - for (size_t index = 0; - index < message->interaction.navigation.item_count; ++index) { - if (!valid_profile_text(message->interaction.navigation.items[index], - sizeof message->interaction.navigation.items[index], - false, false)) - return false; - } - return true; - case NEXTING_DEVICE_MESSAGE_NAV_MOVE: - return valid_request_id(message->request_id) && - direction_string(message->interaction.navigation.direction) != NULL; - case NEXTING_DEVICE_MESSAGE_NAV_SELECT: - return valid_request_id(message->request_id) && - message->interaction.navigation.index <= 7U; - case NEXTING_DEVICE_MESSAGE_NAV_RESOLVED: - return valid_request_id(message->request_id) && - nav_resolution_string( - message->interaction.navigation.resolution) != NULL; - case NEXTING_DEVICE_MESSAGE_KEYMAP: - if (message->interaction.keymap.key_count > NEXTING_DEVICE_KEYS_MAX) - return false; - for (size_t index = 0; index < message->interaction.keymap.key_count; - ++index) { - const nexting_device_key_presentation_t *key = - &message->interaction.keymap.keys[index]; - if (key->slot > 63U || - !valid_profile_text(key->label, sizeof key->label, false, false) || - light_string(key->light) == NULL) - return false; - for (size_t previous = 0; previous < index; ++previous) { - if (message->interaction.keymap.keys[previous].slot == key->slot) - return false; - } - } - return true; - case NEXTING_DEVICE_MESSAGE_KEY_EVENT: - return message->interaction.slot <= 63U && - gesture_string(message->interaction.gesture) != NULL; - case NEXTING_DEVICE_MESSAGE_ROTARY_MAP: - if (message->interaction.rotary_map.control_count > - NEXTING_DEVICE_ROTARY_MAX) - return false; - for (size_t index = 0; - index < message->interaction.rotary_map.control_count; ++index) { - const nexting_device_rotary_control_t *control = - &message->interaction.rotary_map.controls[index]; - if (control->slot > 15U || - !valid_profile_text(control->label, sizeof control->label, false, - false) || - control->minimum < -1000000 || control->maximum > 1000000 || - control->minimum > control->value || - control->value > control->maximum) - return false; - for (size_t previous = 0; previous < index; ++previous) { - if (message->interaction.rotary_map.controls[previous].slot == - control->slot) - return false; - } - } - return true; - case NEXTING_DEVICE_MESSAGE_ROTARY_EVENT: - return message->interaction.slot <= 15U && - message->interaction.delta >= -127 && - message->interaction.delta <= 127 && - message->interaction.delta != 0; - case NEXTING_DEVICE_MESSAGE_ROTARY_PRESS: - return message->interaction.slot <= 15U && - gesture_string(message->interaction.gesture) != NULL; - case NEXTING_DEVICE_MESSAGE_VOICE_EVENT: - return voice_event_string(message->interaction.voice_event) != NULL; - case NEXTING_DEVICE_MESSAGE_VOICE_STATE: - return voice_state_string(message->interaction.voice_state) != NULL && - (!message->interaction.has_label || - valid_profile_text(message->interaction.label, - sizeof message->interaction.label, false, - false)); - case NEXTING_DEVICE_MESSAGE_TEXT: - return message->interaction.channel <= 7U && - (!message->interaction.text.has_title || - valid_profile_text(message->interaction.text.title, - sizeof message->interaction.text.title, false, - false)) && - valid_profile_text(message->interaction.text.content, - sizeof message->interaction.text.content, true, - true); - case NEXTING_DEVICE_MESSAGE_USAGE: - return valid_profile_text(message->interaction.usage.model, - sizeof message->interaction.usage.model, false, - false) && - message->interaction.usage.input_tokens <= - UINT64_C(9007199254740991) && - message->interaction.usage.output_tokens <= - UINT64_C(9007199254740991) && - (!message->interaction.usage.has_cached_tokens || - message->interaction.usage.cached_tokens <= - UINT64_C(9007199254740991)) && - (!message->interaction.usage.has_context || - (message->interaction.usage.context_used <= - message->interaction.usage.context_limit && - message->interaction.usage.context_limit <= - UINT64_C(9007199254740991))); - case NEXTING_DEVICE_MESSAGE_USAGE_CLEAR: - return true; - case NEXTING_DEVICE_MESSAGE_CONFIG: - if (message->interaction.config.entry_count > - NEXTING_DEVICE_CONFIG_MAX_ENTRIES) - return false; - for (size_t index = 0; index < message->interaction.config.entry_count; - ++index) { - const nexting_device_config_entry_t *entry = - &message->interaction.config.entries[index]; - if (!valid_config_key(entry->key) || - (entry->type == NEXTING_DEVICE_CONFIG_INTEGER && - (entry->integer_value < -1000000 || - entry->integer_value > 1000000)) || - (entry->type == NEXTING_DEVICE_CONFIG_STRING && - !valid_profile_text(entry->string_value, - sizeof entry->string_value, false, true)) || - (entry->type != NEXTING_DEVICE_CONFIG_BOOLEAN && - entry->type != NEXTING_DEVICE_CONFIG_INTEGER && - entry->type != NEXTING_DEVICE_CONFIG_STRING)) - return false; - for (size_t previous = 0; previous < index; ++previous) { - if (strcmp(message->interaction.config.entries[previous].key, - entry->key) == 0) - return false; - } - } - return true; - case NEXTING_DEVICE_MESSAGE_CONFIG_RESULT: - return config_status_string(message->interaction.config_status) != NULL && - ((message->interaction.config_status == - NEXTING_DEVICE_CONFIG_APPLIED && - message->interaction.config_error == - NEXTING_DEVICE_CONFIG_ERROR_NONE) || - (message->interaction.config_status == - NEXTING_DEVICE_CONFIG_REJECTED && - config_error_string(message->interaction.config_error) != NULL)); default: return false; } @@ -2601,7 +1545,7 @@ nexting_device_result_t nexting_device_encode(const nexting_device_message_t *message, char *output, size_t output_capacity, size_t *output_length) { writer_t writer = {output, output_capacity, 0, false}; - char number[32]; + char number[16]; if (output == NULL || output_length == NULL || !valid_message_for_encode(message)) return NEXTING_DEVICE_BAD_MESSAGE; @@ -2655,265 +1599,6 @@ nexting_device_encode(const nexting_device_message_t *message, char *output, } write_byte(&writer, ']'); break; - case NEXTING_DEVICE_MESSAGE_NAV_PRESENT: - write_text(&writer, "{\"v\":1,\"t\":\"nav_present\",\"id\":"); - write_json_string(&writer, message->request_id); - write_text(&writer, ",\"items\":["); - for (size_t index = 0; - index < message->interaction.navigation.item_count; ++index) { - if (index != 0U) - write_byte(&writer, ','); - write_json_string(&writer, message->interaction.navigation.items[index]); - } - write_text(&writer, "],\"cursor\":"); - (void)snprintf(number, sizeof number, "%u", - (unsigned)message->interaction.navigation.cursor); - write_text(&writer, number); - write_text(&writer, ",\"ttl\":"); - (void)snprintf(number, sizeof number, "%u", message->ttl_ms); - write_text(&writer, number); - break; - case NEXTING_DEVICE_MESSAGE_NAV_MOVE: - write_text(&writer, "{\"v\":1,\"t\":\"nav_move\",\"id\":"); - write_json_string(&writer, message->request_id); - write_text(&writer, ",\"dir\":"); - write_json_string( - &writer, direction_string(message->interaction.navigation.direction)); - write_text(&writer, ",\"seq\":"); - (void)snprintf(number, sizeof number, "%u", - message->interaction.sequence); - write_text(&writer, number); - break; - case NEXTING_DEVICE_MESSAGE_NAV_SELECT: - write_text(&writer, "{\"v\":1,\"t\":\"nav_select\",\"id\":"); - write_json_string(&writer, message->request_id); - write_text(&writer, ",\"index\":"); - (void)snprintf(number, sizeof number, "%u", - (unsigned)message->interaction.navigation.index); - write_text(&writer, number); - write_text(&writer, ",\"seq\":"); - (void)snprintf(number, sizeof number, "%u", - message->interaction.sequence); - write_text(&writer, number); - break; - case NEXTING_DEVICE_MESSAGE_NAV_RESOLVED: - write_text(&writer, "{\"v\":1,\"t\":\"nav_resolved\",\"id\":"); - write_json_string(&writer, message->request_id); - write_text(&writer, ",\"r\":"); - write_json_string( - &writer, - nav_resolution_string(message->interaction.navigation.resolution)); - break; - case NEXTING_DEVICE_MESSAGE_KEYMAP: - write_text(&writer, "{\"v\":1,\"t\":\"keymap\",\"rev\":"); - (void)snprintf(number, sizeof number, "%u", - message->interaction.revision); - write_text(&writer, number); - write_text(&writer, ",\"keys\":["); - for (size_t index = 0; index < message->interaction.keymap.key_count; - ++index) { - const nexting_device_key_presentation_t *key = - &message->interaction.keymap.keys[index]; - if (index != 0U) - write_byte(&writer, ','); - write_text(&writer, "{\"slot\":"); - (void)snprintf(number, sizeof number, "%u", (unsigned)key->slot); - write_text(&writer, number); - write_text(&writer, ",\"label\":"); - write_json_string(&writer, key->label); - write_text(&writer, ",\"enabled\":"); - write_text(&writer, key->enabled ? "true" : "false"); - write_text(&writer, ",\"light\":"); - write_json_string(&writer, light_string(key->light)); - if (key->has_rgb) { - write_text(&writer, ",\"rgb\":["); - for (size_t component = 0; component < 3U; ++component) { - if (component != 0U) - write_byte(&writer, ','); - (void)snprintf(number, sizeof number, "%u", - (unsigned)key->rgb[component]); - write_text(&writer, number); - } - write_byte(&writer, ']'); - } - write_byte(&writer, '}'); - } - write_byte(&writer, ']'); - break; - case NEXTING_DEVICE_MESSAGE_KEY_EVENT: - write_text(&writer, "{\"v\":1,\"t\":\"key_event\",\"slot\":"); - (void)snprintf(number, sizeof number, "%u", - (unsigned)message->interaction.slot); - write_text(&writer, number); - write_text(&writer, ",\"event\":"); - write_json_string(&writer, gesture_string(message->interaction.gesture)); - write_text(&writer, ",\"seq\":"); - (void)snprintf(number, sizeof number, "%u", - message->interaction.sequence); - write_text(&writer, number); - break; - case NEXTING_DEVICE_MESSAGE_ROTARY_MAP: - write_text(&writer, "{\"v\":1,\"t\":\"rotary_map\",\"rev\":"); - (void)snprintf(number, sizeof number, "%u", - message->interaction.revision); - write_text(&writer, number); - write_text(&writer, ",\"controls\":["); - for (size_t index = 0; - index < message->interaction.rotary_map.control_count; ++index) { - const nexting_device_rotary_control_t *control = - &message->interaction.rotary_map.controls[index]; - if (index != 0U) - write_byte(&writer, ','); - write_text(&writer, "{\"slot\":"); - (void)snprintf(number, sizeof number, "%u", (unsigned)control->slot); - write_text(&writer, number); - write_text(&writer, ",\"label\":"); - write_json_string(&writer, control->label); - write_text(&writer, ",\"value\":"); - (void)snprintf(number, sizeof number, "%" PRId32, control->value); - write_text(&writer, number); - write_text(&writer, ",\"min\":"); - (void)snprintf(number, sizeof number, "%" PRId32, control->minimum); - write_text(&writer, number); - write_text(&writer, ",\"max\":"); - (void)snprintf(number, sizeof number, "%" PRId32, control->maximum); - write_text(&writer, number); - write_text(&writer, ",\"wrap\":"); - write_text(&writer, control->wrap ? "true" : "false"); - write_byte(&writer, '}'); - } - write_byte(&writer, ']'); - break; - case NEXTING_DEVICE_MESSAGE_ROTARY_EVENT: - write_text(&writer, "{\"v\":1,\"t\":\"rotary_event\",\"slot\":"); - (void)snprintf(number, sizeof number, "%u", - (unsigned)message->interaction.slot); - write_text(&writer, number); - write_text(&writer, ",\"delta\":"); - (void)snprintf(number, sizeof number, "%d", - (int)message->interaction.delta); - write_text(&writer, number); - write_text(&writer, ",\"seq\":"); - (void)snprintf(number, sizeof number, "%u", - message->interaction.sequence); - write_text(&writer, number); - break; - case NEXTING_DEVICE_MESSAGE_ROTARY_PRESS: - write_text(&writer, "{\"v\":1,\"t\":\"rotary_press\",\"slot\":"); - (void)snprintf(number, sizeof number, "%u", - (unsigned)message->interaction.slot); - write_text(&writer, number); - write_text(&writer, ",\"event\":"); - write_json_string(&writer, gesture_string(message->interaction.gesture)); - write_text(&writer, ",\"seq\":"); - (void)snprintf(number, sizeof number, "%u", - message->interaction.sequence); - write_text(&writer, number); - break; - case NEXTING_DEVICE_MESSAGE_VOICE_EVENT: - write_text(&writer, "{\"v\":1,\"t\":\"voice_event\",\"event\":"); - write_json_string( - &writer, voice_event_string(message->interaction.voice_event)); - write_text(&writer, ",\"seq\":"); - (void)snprintf(number, sizeof number, "%u", - message->interaction.sequence); - write_text(&writer, number); - break; - case NEXTING_DEVICE_MESSAGE_VOICE_STATE: - write_text(&writer, "{\"v\":1,\"t\":\"voice_state\",\"state\":"); - write_json_string( - &writer, voice_state_string(message->interaction.voice_state)); - if (message->interaction.has_label) { - write_text(&writer, ",\"label\":"); - write_json_string(&writer, message->interaction.label); - } - break; - case NEXTING_DEVICE_MESSAGE_TEXT: - write_text(&writer, "{\"v\":1,\"t\":\"text\",\"channel\":"); - (void)snprintf(number, sizeof number, "%u", - (unsigned)message->interaction.channel); - write_text(&writer, number); - if (message->interaction.text.has_title) { - write_text(&writer, ",\"title\":"); - write_json_string(&writer, message->interaction.text.title); - } - write_text(&writer, ",\"content\":"); - write_json_string(&writer, message->interaction.text.content); - break; - case NEXTING_DEVICE_MESSAGE_USAGE: - write_text(&writer, "{\"v\":1,\"t\":\"usage\",\"model\":"); - write_json_string(&writer, message->interaction.usage.model); - write_text(&writer, ",\"input_tokens\":"); - (void)snprintf(number, sizeof number, "%" PRIu64, - message->interaction.usage.input_tokens); - write_text(&writer, number); - write_text(&writer, ",\"output_tokens\":"); - (void)snprintf(number, sizeof number, "%" PRIu64, - message->interaction.usage.output_tokens); - write_text(&writer, number); - if (message->interaction.usage.has_cached_tokens) { - write_text(&writer, ",\"cached_tokens\":"); - (void)snprintf(number, sizeof number, "%" PRIu64, - message->interaction.usage.cached_tokens); - write_text(&writer, number); - } - if (message->interaction.usage.has_context) { - write_text(&writer, ",\"context_used\":"); - (void)snprintf(number, sizeof number, "%" PRIu64, - message->interaction.usage.context_used); - write_text(&writer, number); - write_text(&writer, ",\"context_limit\":"); - (void)snprintf(number, sizeof number, "%" PRIu64, - message->interaction.usage.context_limit); - write_text(&writer, number); - } - break; - case NEXTING_DEVICE_MESSAGE_USAGE_CLEAR: - write_text(&writer, "{\"v\":1,\"t\":\"usage_clear\""); - break; - case NEXTING_DEVICE_MESSAGE_CONFIG: - write_text(&writer, "{\"v\":1,\"t\":\"config\",\"rev\":"); - (void)snprintf(number, sizeof number, "%u", - message->interaction.revision); - write_text(&writer, number); - write_text(&writer, ",\"entries\":["); - for (size_t index = 0; index < message->interaction.config.entry_count; - ++index) { - const nexting_device_config_entry_t *entry = - &message->interaction.config.entries[index]; - if (index != 0U) - write_byte(&writer, ','); - write_text(&writer, "{\"key\":"); - write_json_string(&writer, entry->key); - write_text(&writer, ",\"value\":"); - if (entry->type == NEXTING_DEVICE_CONFIG_BOOLEAN) { - write_text(&writer, entry->boolean_value ? "true" : "false"); - } else if (entry->type == NEXTING_DEVICE_CONFIG_INTEGER) { - (void)snprintf(number, sizeof number, "%" PRId32, - entry->integer_value); - write_text(&writer, number); - } else { - write_json_string(&writer, entry->string_value); - } - write_byte(&writer, '}'); - } - write_byte(&writer, ']'); - break; - case NEXTING_DEVICE_MESSAGE_CONFIG_RESULT: - write_text(&writer, "{\"v\":1,\"t\":\"config_result\",\"rev\":"); - (void)snprintf(number, sizeof number, "%u", - message->interaction.revision); - write_text(&writer, number); - write_text(&writer, ",\"status\":"); - write_json_string( - &writer, - config_status_string(message->interaction.config_status)); - if (message->interaction.config_status == NEXTING_DEVICE_CONFIG_REJECTED) { - write_text(&writer, ",\"code\":"); - write_json_string( - &writer, config_error_string(message->interaction.config_error)); - } - break; default: return NEXTING_DEVICE_BAD_MESSAGE; } @@ -3101,23 +1786,3 @@ nexting_device_status_on_message(nexting_device_status_state_t *state, void nexting_device_status_disconnect(nexting_device_status_state_t *state) { nexting_device_status_init(state); } - -void nexting_device_sequence_init(nexting_device_sequence_state_t *state) { - if (state != NULL) - memset(state, 0, sizeof *state); -} - -bool nexting_device_sequence_accept(nexting_device_sequence_state_t *state, - uint32_t sequence) { - if (state == NULL) - return false; - if (state->has_value && sequence <= state->latest) - return false; - state->has_value = true; - state->latest = sequence; - return true; -} - -void nexting_device_sequence_disconnect(nexting_device_sequence_state_t *state) { - nexting_device_sequence_init(state); -} diff --git a/devices/sdk/c/tests/generate_interaction_vectors.mjs b/devices/sdk/c/tests/generate_interaction_vectors.mjs deleted file mode 100644 index 26a7b55..0000000 --- a/devices/sdk/c/tests/generate_interaction_vectors.mjs +++ /dev/null @@ -1,42 +0,0 @@ -import { readFileSync, writeFileSync } from "node:fs"; -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 valid = []; -const invalid = []; -for (const profile of profiles) { - const vectors = JSON.parse( - readFileSync(join(vectorDirectory, `${profile}-v1.json`), "utf8"), - ); - valid.push(...vectors.valid.map((vector) => vector.wire)); - invalid.push(...vectors.invalid.map((vector) => vector.wire)); -} - -function literal(value) { - return JSON.stringify(value); -} - -function array(name, values) { - return [ - `static const char *const ${name}[] = {`, - ...values.map((value) => ` ${literal(value)},`), - "};", - `static const size_t ${name}_count = sizeof(${name}) / sizeof(${name}[0]);`, - ].join("\n"); -} - -writeFileSync( - outputPath, - [ - "#ifndef NEXTING_DEVICE_GENERATED_INTERACTION_VECTORS_H", - "#define NEXTING_DEVICE_GENERATED_INTERACTION_VECTORS_H", - "#include ", - array("nexting_device_interaction_valid_vectors", valid), - array("nexting_device_interaction_invalid_vectors", invalid), - "#endif", - "", - ].join("\n"), -); 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_interactions.c b/devices/sdk/c/tests/test_interactions.c deleted file mode 100644 index c5e7db0..0000000 --- a/devices/sdk/c/tests/test_interactions.c +++ /dev/null @@ -1,132 +0,0 @@ -#include "generated_interaction_vectors.h" -#include "nexting_device.h" - -#include -#include -#include - -static nexting_device_message_t decode_ok(const char *wire) { - nexting_device_message_t message = {0}; - char encoded[NEXTING_DEVICE_DEFAULT_MAX_MESSAGE_BYTES + 1]; - size_t encoded_length = 0; - assert(nexting_device_decode(wire, strlen(wire), &message) == - NEXTING_DEVICE_OK); - 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); - return message; -} - -static void decodes_navigation_and_physical_events(void) { - nexting_device_message_t message = decode_ok( - "{\"v\":1,\"t\":\"nav_present\",\"id\":\"q7\",\"items\":[\"Fix " - "it\",\"Explain\"],\"cursor\":0,\"ttl\":30000}\n"); - assert(message.type == NEXTING_DEVICE_MESSAGE_NAV_PRESENT); - assert(message.interaction.navigation.item_count == 2U); - assert(strcmp(message.interaction.navigation.items[1], "Explain") == 0); - - message = decode_ok( - "{\"v\":1,\"t\":\"key_event\",\"slot\":0,\"event\":\"press\"," - "\"seq\":41}\n"); - assert(message.type == NEXTING_DEVICE_MESSAGE_KEY_EVENT); - assert(message.interaction.slot == 0U); - assert(message.interaction.sequence == 41U); - assert(message.interaction.gesture == NEXTING_DEVICE_GESTURE_PRESS); - - message = decode_ok( - "{\"v\":1,\"t\":\"rotary_event\",\"slot\":0,\"delta\":-2," - "\"seq\":52}\n"); - assert(message.type == NEXTING_DEVICE_MESSAGE_ROTARY_EVENT); - assert(message.interaction.delta == -2); -} - -static void decodes_display_and_configuration_state(void) { - nexting_device_message_t message = decode_ok( - "{\"v\":1,\"t\":\"voice_state\",\"state\":\"listening\"," - "\"label\":\"Release to send\"}\n"); - assert(message.type == NEXTING_DEVICE_MESSAGE_VOICE_STATE); - assert(message.interaction.voice_state == NEXTING_DEVICE_VOICE_LISTENING); - - message = decode_ok( - "{\"v\":1,\"t\":\"text\",\"channel\":0,\"title\":\"Current task\"," - "\"content\":\"Waiting for approval\"}\n"); - assert(message.type == NEXTING_DEVICE_MESSAGE_TEXT); - assert(strcmp(message.interaction.text.content, "Waiting for approval") == 0); - - message = decode_ok( - "{\"v\":1,\"t\":\"config\",\"rev\":7,\"entries\":[" - "{\"key\":\"display.brightness\",\"value\":70}," - "{\"key\":\"haptics.enabled\",\"value\":true}]}\n"); - assert(message.type == NEXTING_DEVICE_MESSAGE_CONFIG); - assert(message.interaction.revision == 7U); - assert(message.interaction.config.entry_count == 2U); - assert(message.interaction.config.entries[0].type == - NEXTING_DEVICE_CONFIG_INTEGER); - assert(message.interaction.config.entries[1].type == - NEXTING_DEVICE_CONFIG_BOOLEAN); -} - -static void rejects_hostile_interaction_frames(void) { - const char *invalid[] = { - "{\"v\":1,\"t\":\"nav_present\",\"id\":\"q\",\"items\":[\"A\"]," - "\"cursor\":0,\"ttl\":1}\n", - "{\"v\":1,\"t\":\"key_event\",\"slot\":64,\"event\":\"press\"," - "\"seq\":1}\n", - "{\"v\":1,\"t\":\"rotary_event\",\"slot\":0,\"delta\":0,\"seq\":1}\n", - "{\"v\":1,\"t\":\"voice_event\",\"event\":\"audio\",\"seq\":1}\n", - "{\"v\":1,\"t\":\"usage\",\"model\":\"GPT\",\"input_tokens\":-1," - "\"output_tokens\":0}\n", - "{\"v\":1,\"t\":\"config\",\"rev\":1,\"entries\":[" - "{\"key\":\"a\",\"value\":1},{\"key\":\"a\",\"value\":2}]}\n", - }; - for (size_t index = 0; index < sizeof invalid / sizeof invalid[0]; ++index) { - nexting_device_message_t message = {0}; - assert(nexting_device_decode(invalid[index], strlen(invalid[index]), - &message) == NEXTING_DEVICE_BAD_MESSAGE); - } -} - -static void sequence_gate_is_connection_scoped(void) { - nexting_device_sequence_state_t state; - nexting_device_sequence_init(&state); - assert(nexting_device_sequence_accept(&state, 41U)); - assert(!nexting_device_sequence_accept(&state, 41U)); - assert(!nexting_device_sequence_accept(&state, 40U)); - assert(nexting_device_sequence_accept(&state, 42U)); - nexting_device_sequence_disconnect(&state); - assert(nexting_device_sequence_accept(&state, 1U)); -} - -static void matches_every_shared_interaction_vector(void) { - char encoded[NEXTING_DEVICE_DEFAULT_MAX_MESSAGE_BYTES + 1]; - for (size_t index = 0; - index < nexting_device_interaction_valid_vectors_count; ++index) { - nexting_device_message_t message = {0}; - size_t encoded_length = 0; - const char *wire = nexting_device_interaction_valid_vectors[index]; - assert(nexting_device_decode(wire, strlen(wire), &message) == - NEXTING_DEVICE_OK); - 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); - } - for (size_t index = 0; - index < nexting_device_interaction_invalid_vectors_count; ++index) { - nexting_device_message_t message = {0}; - const char *wire = nexting_device_interaction_invalid_vectors[index]; - assert(nexting_device_decode(wire, strlen(wire), &message) != - NEXTING_DEVICE_OK); - } -} - -int main(void) { - decodes_navigation_and_physical_events(); - decodes_display_and_configuration_state(); - rejects_hostile_interaction_frames(); - sequence_gate_is_connection_scoped(); - matches_every_shared_interaction_vector(); - puts("interaction tests passed"); - return 0; -} diff --git a/devices/sdk/kotlin/README.md b/devices/sdk/kotlin/README.md index 788e5cd..5fac6e2 100644 --- a/devices/sdk/kotlin/README.md +++ b/devices/sdk/kotlin/README.md @@ -1,50 +1,23 @@ -# Nexting Devices Kotlin SDK — 0.2.0-experimental.2 +# Nexting Devices Kotlin SDK -The Kotlin/JVM Host SDK parses the same bounded Device Info and nine-profile -Wire vectors as the Swift, C99, and JavaScript implementations. It exposes -typed hardware identity, capabilities, inert vendor facts, and standard Battery -Service constants for Android Hosts without containing Nexting App, account, -Agent, or cloud code. +The Kotlin/JVM Host SDK parses the same bounded Device Info 0.2 vectors as the +Swift, C99, and JavaScript implementations. It exposes typed hardware identity, +capabilities, inert vendor facts, and standard Battery Service constants for +Android hosts without containing Nexting App, account, Agent, or cloud code. ## Requirements - JDK 17 bytecode target -- `curl` and `unzip` for the checksum-pinned Gradle 9.0.0 launcher +- Gradle 9 or newer - Kotlin 2.2 ## Test ```sh -./gradlew test +gradle test ``` -The launcher downloads the official binary distribution into the user Gradle -cache and verifies its published SHA-256 before execution. - `DeviceInfoCodec.decode(ByteArray)` returns `null` for malformed core data. Malformed optional vendor data is omitted while otherwise valid core data remains usable. `DeviceBattery.decodeLevel(ByteArray)` accepts exactly one byte and clamps it to 0–100. - -## Encode and route an interaction - -```kotlin -val message = DeviceMessage.RotaryEvent( - slot = 0, - delta = 1, - sequence = 42, -) -if (deviceInfo.supportsProfile(message.requiredProfile)) { - val wire = DeviceMessageCodec.encode(message) ?: return - transport.send(wire) -} -``` - -`DeviceMessage` covers `approval/1`, `status/1`, `navigation/1`, `keys/1`, -`rotary/1`, `voice/1`, `text/1`, `usage/1`, and `config/1`. -`interactionSequence` identifies the per-source counter used to reject replayed -or out-of-order navigation, key, rotary, and push-to-talk events. - -`voice/1` is control only. Android microphone permission, audio capture, -transcription, Agent mapping, encrypted authorization storage, and UI stay in -the Host application. diff --git a/devices/sdk/kotlin/build.gradle.kts b/devices/sdk/kotlin/build.gradle.kts index 5c12b35..ad6ee63 100644 --- a/devices/sdk/kotlin/build.gradle.kts +++ b/devices/sdk/kotlin/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } group = "ai.nexting.devices" -version = "0.2.0-experimental.2" +version = "0.2.0-experimental.0" kotlin { compilerOptions { diff --git a/devices/sdk/kotlin/gradlew b/devices/sdk/kotlin/gradlew deleted file mode 100755 index 4c2a03b..0000000 --- a/devices/sdk/kotlin/gradlew +++ /dev/null @@ -1,39 +0,0 @@ -#!/usr/bin/env sh -set -eu - -gradle_version="9.0.0" -distribution_sha256="8fad3d78296ca518113f3d29016617c7f9367dc005f932bd9d93bf45ba46072b" -distribution_url="https://services.gradle.org/distributions/gradle-${gradle_version}-bin.zip" -cache_root="${GRADLE_USER_HOME:-${HOME}/.gradle}/wrapper/dists/nexting-gradle-${gradle_version}" -gradle_bin="${cache_root}/gradle-${gradle_version}/bin/gradle" - -if [ ! -x "${gradle_bin}" ]; then - command -v curl >/dev/null 2>&1 || { - echo "ERROR: curl is required to download pinned Gradle ${gradle_version}." >&2 - exit 69 - } - command -v unzip >/dev/null 2>&1 || { - echo "ERROR: unzip is required to install pinned Gradle ${gradle_version}." >&2 - exit 69 - } - mkdir -p "${cache_root}" - archive="${cache_root}/gradle-${gradle_version}-bin.zip" - partial="${archive}.part.$$" - trap 'rm -f "${partial}"' EXIT HUP INT TERM - curl --fail --location --retry 3 --retry-all-errors \ - --output "${partial}" "${distribution_url}" - if command -v sha256sum >/dev/null 2>&1; then - actual_sha256="$(sha256sum "${partial}" | awk '{print $1}')" - else - actual_sha256="$(shasum -a 256 "${partial}" | awk '{print $1}')" - fi - if [ "${actual_sha256}" != "${distribution_sha256}" ]; then - echo "ERROR: Gradle distribution checksum mismatch." >&2 - exit 65 - fi - mv "${partial}" "${archive}" - unzip -q -o "${archive}" -d "${cache_root}" - trap - EXIT HUP INT TERM -fi - -exec "${gradle_bin}" "$@" diff --git a/devices/sdk/kotlin/src/main/kotlin/ai/nexting/devices/DeviceInfo.kt b/devices/sdk/kotlin/src/main/kotlin/ai/nexting/devices/DeviceInfo.kt index 075a678..7561838 100644 --- a/devices/sdk/kotlin/src/main/kotlin/ai/nexting/devices/DeviceInfo.kt +++ b/devices/sdk/kotlin/src/main/kotlin/ai/nexting/devices/DeviceInfo.kt @@ -72,24 +72,6 @@ data class DeviceInfo( get() = supportsApprovalV1 && profiles.contains("status/1") && capabilities.statusSlots > 0 - - fun supportsProfile(profile: String): Boolean = - supportsApprovalV1 && profiles.contains(profile) - - val supportsNavigationV1: Boolean - get() = supportsProfile(DeviceMessageCodec.NAVIGATION_PROFILE) - val supportsKeysV1: Boolean - get() = supportsProfile(DeviceMessageCodec.KEYS_PROFILE) - val supportsRotaryV1: Boolean - get() = supportsProfile(DeviceMessageCodec.ROTARY_PROFILE) - val supportsVoiceV1: Boolean - get() = supportsProfile(DeviceMessageCodec.VOICE_PROFILE) - val supportsTextV1: Boolean - get() = supportsProfile(DeviceMessageCodec.TEXT_PROFILE) - val supportsUsageV1: Boolean - get() = supportsProfile(DeviceMessageCodec.USAGE_PROFILE) - val supportsConfigV1: Boolean - get() = supportsProfile(DeviceMessageCodec.CONFIG_PROFILE) } object DeviceBattery { diff --git a/devices/sdk/kotlin/src/main/kotlin/ai/nexting/devices/Protocol.kt b/devices/sdk/kotlin/src/main/kotlin/ai/nexting/devices/Protocol.kt index 630ff04..771ad21 100644 --- a/devices/sdk/kotlin/src/main/kotlin/ai/nexting/devices/Protocol.kt +++ b/devices/sdk/kotlin/src/main/kotlin/ai/nexting/devices/Protocol.kt @@ -7,12 +7,10 @@ import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.booleanOrNull import kotlinx.serialization.json.intOrNull import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonObject import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.longOrNull import kotlinx.serialization.json.put enum class DeviceChoice(val wireValue: String) { @@ -52,76 +50,6 @@ data class DeviceAgentStatus( val label: String? = null, ) -enum class NavigationDirection(val wireValue: String) { - PREVIOUS("prev"), NEXT("next"), UP("up"), DOWN("down"), LEFT("left"), RIGHT("right"), -} - -enum class NavigationResolution(val wireValue: String) { - SELECTED("selected"), CANCELLED("cancelled"), EXPIRED("expired"), REPLACED("replaced"), -} - -enum class ControlGesture(val wireValue: String) { - PRESS("press"), RELEASE("release"), HOLD("hold"), DOUBLE("double"), -} - -enum class KeyLight(val wireValue: String) { - OFF("off"), DIM("dim"), SOLID("solid"), PULSE("pulse"), -} - -data class Rgb(val red: Int, val green: Int, val blue: Int) - -data class KeyPresentation( - val slot: Int, - val label: String, - val enabled: Boolean, - val light: KeyLight, - val rgb: Rgb? = null, -) - -data class RotaryControl( - val slot: Int, - val label: String, - val value: Int, - val minimum: Int, - val maximum: Int, - val wrap: Boolean, -) - -enum class VoiceEvent(val wireValue: String) { - START("start"), STOP("stop"), CANCEL("cancel"), -} - -enum class VoiceState(val wireValue: String) { - IDLE("idle"), LISTENING("listening"), TRANSCRIBING("transcribing"), - SUBMITTED("submitted"), ERROR("error"), -} - -data class UsageSnapshot( - val model: String, - val inputTokens: Long, - val outputTokens: Long, - val cachedTokens: Long? = null, - val contextUsed: Long? = null, - val contextLimit: Long? = null, -) - -sealed interface ConfigValue { - data class BooleanValue(val value: Boolean) : ConfigValue - data class IntegerValue(val value: Int) : ConfigValue - data class StringValue(val value: String) : ConfigValue -} - -data class ConfigEntry(val key: String, val value: ConfigValue) - -enum class ConfigStatus(val wireValue: String) { - APPLIED("applied"), REJECTED("rejected"), -} - -enum class ConfigError(val wireValue: String) { - UNKNOWN_KEY("unknown_key"), INVALID_VALUE("invalid_value"), - STORAGE_ERROR("storage_error"), UNSUPPORTED("unsupported"), -} - sealed interface DeviceMessage { data class Present( val requestId: String, @@ -147,87 +75,12 @@ sealed interface DeviceMessage { data class Status( val agents: List, ) : DeviceMessage - - data class NavigationPresent( - val requestId: String, - val items: List, - val cursor: Int, - val ttlMilliseconds: Int, - ) : DeviceMessage - - data class NavigationMove( - val requestId: String, - val direction: NavigationDirection, - val sequence: Long, - ) : DeviceMessage - - data class NavigationSelect( - val requestId: String, - val index: Int, - val sequence: Long, - ) : DeviceMessage - - data class NavigationResolved( - val requestId: String, - val reason: NavigationResolution, - ) : DeviceMessage - - data class Keymap(val revision: Long, val keys: List) : DeviceMessage - data class KeyEvent(val slot: Int, val event: ControlGesture, val sequence: Long) : DeviceMessage - data class RotaryMap(val revision: Long, val controls: List) : DeviceMessage - data class RotaryEvent(val slot: Int, val delta: Int, val sequence: Long) : DeviceMessage - data class RotaryPress(val slot: Int, val event: ControlGesture, val sequence: Long) : DeviceMessage - data class VoiceControl(val event: VoiceEvent, val sequence: Long) : DeviceMessage - data class VoiceStatus(val state: VoiceState, val label: String? = null) : DeviceMessage - data class Text(val channel: Int, val title: String? = null, val content: String) : DeviceMessage - data class Usage(val snapshot: UsageSnapshot) : DeviceMessage - data object UsageClear : DeviceMessage - data class Config(val revision: Long, val entries: List) : DeviceMessage - data class ConfigResult( - val revision: Long, - val status: ConfigStatus, - val code: ConfigError? = null, - ) : DeviceMessage - - val requiredProfile: String - get() = when (this) { - is Present, is Answer, is Resolved, is Error -> - DeviceMessageCodec.APPROVAL_PROFILE - is Status -> DeviceMessageCodec.STATUS_PROFILE - is NavigationPresent, is NavigationMove, is NavigationSelect, - is NavigationResolved -> DeviceMessageCodec.NAVIGATION_PROFILE - is Keymap, is KeyEvent -> DeviceMessageCodec.KEYS_PROFILE - is RotaryMap, is RotaryEvent, is RotaryPress -> - DeviceMessageCodec.ROTARY_PROFILE - is VoiceControl, is VoiceStatus -> DeviceMessageCodec.VOICE_PROFILE - is Text -> DeviceMessageCodec.TEXT_PROFILE - is Usage, UsageClear -> DeviceMessageCodec.USAGE_PROFILE - is Config, is ConfigResult -> DeviceMessageCodec.CONFIG_PROFILE - } - - val interactionSequence: Pair? - get() = when (this) { - is NavigationMove -> "navigation:$requestId" to sequence - is NavigationSelect -> "navigation:$requestId" to sequence - is KeyEvent -> "key:$slot" to sequence - is RotaryEvent -> "rotary:$slot" to sequence - is RotaryPress -> "rotary:$slot" to sequence - is VoiceControl -> "voice" to sequence - else -> null - } } object DeviceMessageCodec { const val WIRE_VERSION = 1 const val APPROVAL_PROFILE = "approval/1" const val STATUS_PROFILE = "status/1" - const val NAVIGATION_PROFILE = "navigation/1" - const val KEYS_PROFILE = "keys/1" - const val ROTARY_PROFILE = "rotary/1" - const val VOICE_PROFILE = "voice/1" - const val TEXT_PROFILE = "text/1" - const val USAGE_PROFILE = "usage/1" - const val CONFIG_PROFILE = "config/1" const val MAX_REQUEST_ID_BYTES = 64 const val MAX_SUMMARY_BYTES = 240 const val MAX_TTL_MILLISECONDS = 300_000 @@ -304,225 +157,6 @@ object DeviceMessageCodec { }) } } - is DeviceMessage.NavigationPresent -> { - if ( - !validId(message.requestId) || - !validNavigationItems(message.items) || - message.cursor !in message.items.indices || - message.ttlMilliseconds !in 1..MAX_TTL_MILLISECONDS - ) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "nav_present") - put("id", message.requestId) - put("items", buildJsonArray { - message.items.forEach { add(JsonPrimitive(it)) } - }) - put("cursor", message.cursor) - put("ttl", message.ttlMilliseconds) - } - } - is DeviceMessage.NavigationMove -> { - if (!validId(message.requestId) || !validU32(message.sequence)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "nav_move") - put("id", message.requestId) - put("dir", message.direction.wireValue) - put("seq", message.sequence) - } - } - is DeviceMessage.NavigationSelect -> { - if ( - !validId(message.requestId) || message.index !in 0..7 || - !validU32(message.sequence) - ) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "nav_select") - put("id", message.requestId) - put("index", message.index) - put("seq", message.sequence) - } - } - is DeviceMessage.NavigationResolved -> { - if (!validId(message.requestId)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "nav_resolved") - put("id", message.requestId) - put("r", message.reason.wireValue) - } - } - is DeviceMessage.Keymap -> { - if (!validU32(message.revision) || !validKeys(message.keys)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "keymap") - put("rev", message.revision) - put("keys", buildJsonArray { - message.keys.forEach { key -> - add(buildJsonObject { - put("slot", key.slot) - put("label", key.label) - put("enabled", key.enabled) - put("light", key.light.wireValue) - key.rgb?.let { rgb -> - put("rgb", buildJsonArray { - add(JsonPrimitive(rgb.red)) - add(JsonPrimitive(rgb.green)) - add(JsonPrimitive(rgb.blue)) - }) - } - }) - } - }) - } - } - is DeviceMessage.KeyEvent -> { - if ( - message.slot !in 0..63 || !validU32(message.sequence) - ) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "key_event") - put("slot", message.slot) - put("event", message.event.wireValue) - put("seq", message.sequence) - } - } - is DeviceMessage.RotaryMap -> { - if ( - !validU32(message.revision) || - !validRotaryControls(message.controls) - ) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "rotary_map") - put("rev", message.revision) - put("controls", buildJsonArray { - message.controls.forEach { control -> - add(buildJsonObject { - put("slot", control.slot) - put("label", control.label) - put("value", control.value) - put("min", control.minimum) - put("max", control.maximum) - put("wrap", control.wrap) - }) - } - }) - } - } - is DeviceMessage.RotaryEvent -> { - if ( - message.slot !in 0..15 || message.delta !in -127..127 || - message.delta == 0 || !validU32(message.sequence) - ) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "rotary_event") - put("slot", message.slot) - put("delta", message.delta) - put("seq", message.sequence) - } - } - is DeviceMessage.RotaryPress -> { - if (message.slot !in 0..15 || !validU32(message.sequence)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "rotary_press") - put("slot", message.slot) - put("event", message.event.wireValue) - put("seq", message.sequence) - } - } - is DeviceMessage.VoiceControl -> { - if (!validU32(message.sequence)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "voice_event") - put("event", message.event.wireValue) - put("seq", message.sequence) - } - } - is DeviceMessage.VoiceStatus -> { - if (message.label != null && !validText(message.label, 1, 64)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "voice_state") - put("state", message.state.wireValue) - message.label?.let { put("label", it) } - } - } - is DeviceMessage.Text -> { - if ( - message.channel !in 0..7 || - !validText(message.content, 0, 1_024, allowLayout = true) || - (message.title != null && !validText(message.title, 1, 64)) - ) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "text") - put("channel", message.channel) - message.title?.let { put("title", it) } - put("content", message.content) - } - } - is DeviceMessage.Usage -> { - if (!validUsage(message.snapshot)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "usage") - put("model", message.snapshot.model) - put("input_tokens", message.snapshot.inputTokens) - put("output_tokens", message.snapshot.outputTokens) - message.snapshot.cachedTokens?.let { put("cached_tokens", it) } - message.snapshot.contextUsed?.let { put("context_used", it) } - message.snapshot.contextLimit?.let { put("context_limit", it) } - } - } - DeviceMessage.UsageClear -> buildJsonObject { - put("v", WIRE_VERSION) - put("t", "usage_clear") - } - is DeviceMessage.Config -> { - if ( - !validU32(message.revision) || - !validConfigEntries(message.entries) - ) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "config") - put("rev", message.revision) - put("entries", buildJsonArray { - message.entries.forEach { entry -> - add(buildJsonObject { - put("key", entry.key) - when (val value = entry.value) { - is ConfigValue.BooleanValue -> put("value", value.value) - is ConfigValue.IntegerValue -> put("value", value.value) - is ConfigValue.StringValue -> put("value", value.value) - } - }) - } - }) - } - } - is DeviceMessage.ConfigResult -> { - if ( - !validU32(message.revision) || - (message.status == ConfigStatus.APPLIED && message.code != null) || - (message.status == ConfigStatus.REJECTED && message.code == null) - ) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "config_result") - put("rev", message.revision) - put("status", message.status.wireValue) - message.code?.let { put("code", it.wireValue) } - } - } } val encoded = (objectValue.toString() + "\n").encodeToByteArray() return encoded.takeIf { it.size <= MAX_MESSAGE_BYTES } @@ -543,22 +177,6 @@ object DeviceMessageCodec { "resolved" -> decodeResolved(root) "error" -> decodeError(root) "status" -> decodeStatus(root) - "nav_present" -> decodeNavigationPresent(root) - "nav_move" -> decodeNavigationMove(root) - "nav_select" -> decodeNavigationSelect(root) - "nav_resolved" -> decodeNavigationResolved(root) - "keymap" -> decodeKeymap(root) - "key_event" -> decodeKeyEvent(root) - "rotary_map" -> decodeRotaryMap(root) - "rotary_event" -> decodeRotaryEvent(root) - "rotary_press" -> decodeRotaryPress(root) - "voice_event" -> decodeVoiceEvent(root) - "voice_state" -> decodeVoiceState(root) - "text" -> decodeText(root) - "usage" -> decodeUsage(root) - "usage_clear" -> decodeUsageClear(root) - "config" -> decodeConfig(root) - "config_result" -> decodeConfigResult(root) else -> null } } @@ -618,343 +236,6 @@ object DeviceMessageCodec { return DeviceMessage.Status(agents) } - private fun decodeNavigationPresent(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "id", "items", "cursor", "ttl")) return null - val id = root.string("id")?.takeIf(::validId) ?: return null - val items = (root["items"] as? JsonArray)?.map { - val value = it as? JsonPrimitive ?: return null - if (!value.isString) return null - value.content - } ?: return null - if (!validNavigationItems(items)) return null - val cursor = root.canonicalInt("cursor")?.takeIf(items.indices::contains) ?: return null - val ttl = root.canonicalInt("ttl") - ?.takeIf { it in 1..MAX_TTL_MILLISECONDS } ?: return null - return DeviceMessage.NavigationPresent(id, items, cursor, ttl) - } - - private fun decodeNavigationMove(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "id", "dir", "seq")) return null - val id = root.string("id")?.takeIf(::validId) ?: return null - val direction = NavigationDirection.entries.firstOrNull { - it.wireValue == root.string("dir") - } ?: return null - val sequence = root.u32("seq") ?: return null - return DeviceMessage.NavigationMove(id, direction, sequence) - } - - private fun decodeNavigationSelect(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "id", "index", "seq")) return null - val id = root.string("id")?.takeIf(::validId) ?: return null - val index = root.canonicalInt("index")?.takeIf { it in 0..7 } ?: return null - val sequence = root.u32("seq") ?: return null - return DeviceMessage.NavigationSelect(id, index, sequence) - } - - private fun decodeNavigationResolved(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "id", "r")) return null - val id = root.string("id")?.takeIf(::validId) ?: return null - val reason = NavigationResolution.entries.firstOrNull { - it.wireValue == root.string("r") - } ?: return null - return DeviceMessage.NavigationResolved(id, reason) - } - - private fun decodeKeymap(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "rev", "keys")) return null - val revision = root.u32("rev") ?: return null - val rawKeys = root["keys"] as? JsonArray ?: return null - val keys = rawKeys.map { raw -> - val item = raw as? JsonObject ?: return null - if (!item.only("slot", "label", "enabled", "light", "rgb")) return null - val slot = item.canonicalInt("slot") ?: return null - val label = item.string("label") ?: return null - val enabled = item.boolean("enabled") ?: return null - val light = KeyLight.entries.firstOrNull { - it.wireValue == item.string("light") - } ?: return null - val rgb = if ("rgb" in item) { - val values = item["rgb"] as? JsonArray ?: return null - if (values.size != 3) return null - Rgb( - (values[0] as? JsonPrimitive)?.canonicalInt() ?: return null, - (values[1] as? JsonPrimitive)?.canonicalInt() ?: return null, - (values[2] as? JsonPrimitive)?.canonicalInt() ?: return null, - ) - } else { - null - } - KeyPresentation(slot, label, enabled, light, rgb) - } - if (!validKeys(keys)) return null - return DeviceMessage.Keymap(revision, keys) - } - - private fun decodeKeyEvent(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "slot", "event", "seq")) return null - val slot = root.canonicalInt("slot")?.takeIf { it in 0..63 } ?: return null - val event = ControlGesture.entries.firstOrNull { - it.wireValue == root.string("event") - } ?: return null - return DeviceMessage.KeyEvent(slot, event, root.u32("seq") ?: return null) - } - - private fun decodeRotaryMap(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "rev", "controls")) return null - val revision = root.u32("rev") ?: return null - val rawControls = root["controls"] as? JsonArray ?: return null - val controls = rawControls.map { raw -> - val item = raw as? JsonObject ?: return null - if (!item.only("slot", "label", "value", "min", "max", "wrap")) return null - RotaryControl( - slot = item.canonicalInt("slot") ?: return null, - label = item.string("label") ?: return null, - value = item.signedInt("value") ?: return null, - minimum = item.signedInt("min") ?: return null, - maximum = item.signedInt("max") ?: return null, - wrap = item.boolean("wrap") ?: return null, - ) - } - if (!validRotaryControls(controls)) return null - return DeviceMessage.RotaryMap(revision, controls) - } - - private fun decodeRotaryEvent(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "slot", "delta", "seq")) return null - val slot = root.canonicalInt("slot")?.takeIf { it in 0..15 } ?: return null - val delta = root.signedInt("delta") - ?.takeIf { it in -127..127 && it != 0 } ?: return null - return DeviceMessage.RotaryEvent(slot, delta, root.u32("seq") ?: return null) - } - - private fun decodeRotaryPress(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "slot", "event", "seq")) return null - val slot = root.canonicalInt("slot")?.takeIf { it in 0..15 } ?: return null - val event = ControlGesture.entries.firstOrNull { - it.wireValue == root.string("event") - } ?: return null - return DeviceMessage.RotaryPress(slot, event, root.u32("seq") ?: return null) - } - - private fun decodeVoiceEvent(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "event", "seq")) return null - val event = VoiceEvent.entries.firstOrNull { - it.wireValue == root.string("event") - } ?: return null - return DeviceMessage.VoiceControl(event, root.u32("seq") ?: return null) - } - - private fun decodeVoiceState(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "state", "label")) return null - val state = VoiceState.entries.firstOrNull { - it.wireValue == root.string("state") - } ?: return null - val label = if ("label" in root) { - root.string("label")?.takeIf { validText(it, 1, 64) } ?: return null - } else { - null - } - return DeviceMessage.VoiceStatus(state, label) - } - - private fun decodeText(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "channel", "title", "content")) return null - val channel = root.canonicalInt("channel")?.takeIf { it in 0..7 } ?: return null - val title = if ("title" in root) { - root.string("title")?.takeIf { validText(it, 1, 64) } ?: return null - } else { - null - } - val content = root.string("content") - ?.takeIf { validText(it, 0, 1_024, allowLayout = true) } ?: return null - return DeviceMessage.Text(channel, title, content) - } - - private fun decodeUsage(root: JsonObject): DeviceMessage? { - if ( - !root.only( - "v", "t", "model", "input_tokens", "output_tokens", - "cached_tokens", "context_used", "context_limit", - ) - ) return null - val snapshot = UsageSnapshot( - model = root.string("model") ?: return null, - inputTokens = root.safeCounter("input_tokens") ?: return null, - outputTokens = root.safeCounter("output_tokens") ?: return null, - cachedTokens = if ("cached_tokens" in root) { - root.safeCounter("cached_tokens") ?: return null - } else null, - contextUsed = if ("context_used" in root) { - root.safeCounter("context_used") ?: return null - } else null, - contextLimit = if ("context_limit" in root) { - root.safeCounter("context_limit") ?: return null - } else null, - ) - if (!validUsage(snapshot)) return null - return DeviceMessage.Usage(snapshot) - } - - private fun decodeUsageClear(root: JsonObject): DeviceMessage? = - DeviceMessage.UsageClear.takeIf { root.keys == setOf("v", "t") } - - private fun decodeConfig(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "rev", "entries")) return null - val revision = root.u32("rev") ?: return null - val rawEntries = root["entries"] as? JsonArray ?: return null - val entries = rawEntries.map { raw -> - val item = raw as? JsonObject ?: return null - if (item.keys != setOf("key", "value")) return null - ConfigEntry( - item.string("key") ?: return null, - configValue(item["value"]) ?: return null, - ) - } - if (!validConfigEntries(entries)) return null - return DeviceMessage.Config(revision, entries) - } - - private fun decodeConfigResult(root: JsonObject): DeviceMessage? { - if (!root.only("v", "t", "rev", "status", "code")) return null - val revision = root.u32("rev") ?: return null - val status = ConfigStatus.entries.firstOrNull { - it.wireValue == root.string("status") - } ?: return null - val code = if ("code" in root) { - ConfigError.entries.firstOrNull { - it.wireValue == root.string("code") - } ?: return null - } else { - null - } - if ( - status == ConfigStatus.APPLIED && code != null || - status == ConfigStatus.REJECTED && code == null - ) return null - return DeviceMessage.ConfigResult(revision, status, code) - } - - private fun JsonObject.only(vararg allowed: String): Boolean = - keys.all(allowed.toSet()::contains) - - private fun validText( - value: String, - minimumBytes: Int, - maximumBytes: Int, - allowLayout: Boolean = false, - ): Boolean { - val size = value.encodeToByteArray().size - return size in minimumBytes..maximumBytes && - value.none { - it.code == 0x7f || - it.code < 0x20 && !(allowLayout && (it == '\n' || it == '\t')) - } - } - - private fun validNavigationItems(items: List): Boolean = - items.size in 2..8 && - items.distinct().size == items.size && - items.all { validText(it, 1, 64) } - - private fun validU32(value: Long): Boolean = value in 0..0xffff_ffffL - - private fun validKeys(keys: List): Boolean = - keys.size <= 64 && - keys.map { it.slot }.distinct().size == keys.size && - keys.all { key -> - key.slot in 0..63 && - validText(key.label, 1, 32) && - (key.rgb == null || - listOf(key.rgb.red, key.rgb.green, key.rgb.blue).all { it in 0..255 }) - } - - private fun validRotaryControls(controls: List): Boolean = - controls.size <= 16 && - controls.map { it.slot }.distinct().size == controls.size && - controls.all { - it.slot in 0..15 && - validText(it.label, 1, 32) && - it.minimum in -1_000_000..1_000_000 && - it.maximum in -1_000_000..1_000_000 && - it.value in it.minimum..it.maximum - } - - private fun validCounter(value: Long): Boolean = - value in 0..9_007_199_254_740_991L - - private fun validUsage(snapshot: UsageSnapshot): Boolean = - validText(snapshot.model, 1, 64) && - validCounter(snapshot.inputTokens) && - validCounter(snapshot.outputTokens) && - (snapshot.cachedTokens == null || validCounter(snapshot.cachedTokens)) && - (snapshot.contextUsed == null) == (snapshot.contextLimit == null) && - ( - snapshot.contextUsed == null || - validCounter(snapshot.contextUsed) && - validCounter(snapshot.contextLimit!!) && - snapshot.contextUsed <= snapshot.contextLimit - ) - - private val configKey = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,47}") - - private fun validConfigEntries(entries: List): Boolean = - entries.size <= 32 && - entries.map { it.key }.distinct().size == entries.size && - entries.all { entry -> - configKey.matches(entry.key) && - when (val value = entry.value) { - is ConfigValue.BooleanValue -> true - is ConfigValue.IntegerValue -> value.value in -1_000_000..1_000_000 - is ConfigValue.StringValue -> validText(value.value, 0, 128) - } - } - - private fun configValue(raw: kotlinx.serialization.json.JsonElement?): ConfigValue? { - val primitive = raw as? JsonPrimitive ?: return null - if (primitive.isString) return ConfigValue.StringValue(primitive.content) - primitive.booleanOrNull?.let { return ConfigValue.BooleanValue(it) } - val value = primitive.content - .takeIf { Regex("-?(0|[1-9][0-9]*)").matches(it) } - ?.toIntOrNull() ?: return null - return ConfigValue.IntegerValue(value) - } - - private fun JsonObject.boolean(key: String): Boolean? { - val primitive = this[key] as? JsonPrimitive ?: return null - return primitive.booleanOrNull - } - - private fun JsonObject.u32(key: String): Long? { - val primitive = this[key] as? JsonPrimitive ?: return null - if (primitive.isString || !Regex("0|[1-9][0-9]*").matches(primitive.content)) { - return null - } - return primitive.longOrNull?.takeIf(::validU32) - } - - private fun JsonObject.signedInt(key: String): Int? { - val primitive = this[key] as? JsonPrimitive ?: return null - if ( - primitive.isString || - !Regex("-?(0|[1-9][0-9]*)").matches(primitive.content) || - primitive.content == "-0" - ) return null - return primitive.intOrNull - } - - private fun JsonPrimitive.canonicalInt(): Int? { - if (isString || !Regex("0|[1-9][0-9]*").matches(content)) return null - return intOrNull - } - - private fun JsonObject.safeCounter(key: String): Long? { - val primitive = this[key] as? JsonPrimitive ?: return null - if (primitive.isString || !Regex("0|[1-9][0-9]*").matches(primitive.content)) { - return null - } - return primitive.longOrNull?.takeIf(::validCounter) - } - private fun validId(value: String): Boolean = value.encodeToByteArray().size <= MAX_REQUEST_ID_BYTES && requestId.matches(value) diff --git a/devices/sdk/kotlin/src/test/kotlin/ai/nexting/devices/InteractionProfileTest.kt b/devices/sdk/kotlin/src/test/kotlin/ai/nexting/devices/InteractionProfileTest.kt deleted file mode 100644 index ba2e1a0..0000000 --- a/devices/sdk/kotlin/src/test/kotlin/ai/nexting/devices/InteractionProfileTest.kt +++ /dev/null @@ -1,58 +0,0 @@ -package ai.nexting.devices - -import java.nio.file.Files -import java.nio.file.Path -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertFalse -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlin.test.assertTrue - -class InteractionProfileTest { - private val vectorDirectory: Path = Path.of(System.getProperty("user.dir")) - .resolve("../../protocol/vectors") - .normalize() - - @Test - fun `all interaction vectors use the same strict Kotlin codec`() { - val profiles = listOf("navigation", "keys", "rotary", "voice", "text", "usage", "config") - profiles.forEach { profile -> - val document = Json.parseToJsonElement( - Files.readString(vectorDirectory.resolve("$profile-v1.json")), - ).jsonObject - document["valid"]!!.jsonArray.forEach { raw -> - val vector = raw.jsonObject - val name = vector["name"]!!.jsonPrimitive.content - val wire = vector["wire"]!!.jsonPrimitive.content.encodeToByteArray() - val message = assertNotNull(DeviceMessageCodec.decode(wire), name) - assertContentEquals(wire, DeviceMessageCodec.encode(message), name) - } - document["invalid"]!!.jsonArray.forEach { raw -> - val vector = raw.jsonObject - assertNull( - DeviceMessageCodec.decode( - vector["wire"]!!.jsonPrimitive.content.encodeToByteArray(), - ), - vector["name"]!!.jsonPrimitive.content, - ) - } - } - } - - @Test - fun `interaction profile negotiation is explicit`() { - val info = DeviceInfoCodec.decode( - """{"protocol":"nexting-device","spec":"0.2.0-experimental.2","wire":[1],"profiles":["approval/1","navigation/1","keys/1"],"model":"multi-pad","fw":"0.2.0","max_message_bytes":4096,"max_summary_bytes":240}""" - .encodeToByteArray(), - ) - assertNotNull(info) - assertTrue(info.supportsNavigationV1) - assertTrue(info.supportsKeysV1) - assertFalse(info.supportsConfigV1) - } -} diff --git a/devices/sdk/swift/Package.swift b/devices/sdk/swift/Package.swift index 471c06d..b96a7f8 100644 --- a/devices/sdk/swift/Package.swift +++ b/devices/sdk/swift/Package.swift @@ -9,17 +9,9 @@ let package = Package( ], products: [ .library(name: "NextingDeviceKit", targets: ["NextingDeviceKit"]), - .executable( - name: "nexting-device-host-smoke", - targets: ["NextingDeviceHostSmoke"] - ), ], targets: [ .target(name: "NextingDeviceKit"), - .executableTarget( - name: "NextingDeviceHostSmoke", - dependencies: ["NextingDeviceKit"] - ), .testTarget(name: "NextingDeviceKitTests", dependencies: ["NextingDeviceKit"]), ] ) diff --git a/devices/sdk/swift/README.md b/devices/sdk/swift/README.md index 87916c9..c8c60d4 100644 --- a/devices/sdk/swift/README.md +++ b/devices/sdk/swift/README.md @@ -1,26 +1,6 @@ # NextingDeviceKit -## Real-hardware smoke test - -On macOS, prove discovery, Device Info, encrypted BLE, framing, and one physical -Allow/Deny answer without Agent credentials: - -```sh -swift run --package-path sdk/swift nexting-device-host-smoke \ - --summary "Allow the Nexting hardware smoke test?" -``` - -The tool temporarily authorizes only the first matching device for this -process, prints the complete negotiated identity/capability summary, sends one -synthetic request, and exits with `PASS answer=allow` or `PASS answer=deny`. -It persists no peripheral authorization. See the root `QUICKSTART.md` before -running it. - -`NextingDeviceKit` is the Apple Host SDK for Nexting Device Protocol -`0.2.0-experimental.2`. It provides strict encoding and decoding for all nine -profiles, newline framing, extensible Device Info negotiation, sequence -sources for replay rejection, standard Battery Service helpers, explicit -peripheral authorization, approval coordination, and a CoreBluetooth central. +`NextingDeviceKit` is the Apple host SDK for Nexting Device Protocol Experimental 0.2. It provides strict message encoding and decoding, newline framing, extensible Device Info negotiation, standard Battery Service helpers, explicit peripheral authorization, approval coordination, and a CoreBluetooth central. The package does not contain the Nexting App, Agent adapters, cloud APIs, UI, accounts, or production device identity. A host application supplies its own prompt context, enrollment UI, risk policy, and final Agent answer path. @@ -43,26 +23,25 @@ swift build --package-path sdk/swift ``` The test target reads the canonical vectors from -every file in `protocol/vectors/`, including approval, status, navigation, -keys, rotary, voice, text, usage, config, and Device Info; it does not keep a -private copy. +`protocol/vectors/approval-v1.json`, `status-v1.json`, and +`device-info-v1.json`; it does not keep a private copy. ## Public surface -| Type | Responsibility | -| --- | --- | -| `NextingDeviceMessage` | Typed values for all nine profiles, plus `requiredProfile` and `interactionSequence` | -| `NextingDeviceCodec` | Strict newline-terminated wire encoding and decoding | -| `NextingDeviceLineDecoder` | Bounded fragmented-message assembly | -| `NextingDeviceInfo` | Negotiated wire, identity, capabilities, vendor facts, firmware, and limits | -| `NextingDeviceBattery` | Standard Battery Service UUIDs and bounded Battery Level parsing | -| `NextingDeviceAuthorizationStore` | Explicit stable-peripheral allowlist | -| `NextingDeviceAuthorizationPolicy` | Release deny-by-default and optional Debug simulator rule | -| `NextingDeviceCentral` | Recoverable CoreBluetooth setup, negotiated limits, and bounded acknowledged writes | -| `NextingDeviceSendRejection` | Explicit not-ready, oversize-frame, and queue-backpressure results | -| `NextingDevicePromptRelay` | One-current-prompt state, two-phase answer completion, and race handling | -| `NextingDeviceRelayCoordinator` | Connects an authorized transport to host prompt context and authoritative action result | -| `NextingDeviceAnswerClaimGate` | Single-consumption gate shared by phone and hardware input | +| Type | Responsibility | +| ---------------------------------- | --------------------------------------------------------------------------------------- | +| `NextingDeviceMessage` | Present, Answer, Resolved, and Error values | +| `NextingDeviceCodec` | Strict newline-terminated wire encoding and decoding | +| `NextingDeviceLineDecoder` | Bounded fragmented-message assembly | +| `NextingDeviceInfo` | Negotiated wire, identity, capabilities, vendor facts, firmware, and limits | +| `NextingDeviceBattery` | Standard Battery Service UUIDs and bounded Battery Level parsing | +| `NextingDeviceAuthorizationStore` | Explicit stable-peripheral allowlist | +| `NextingDeviceAuthorizationPolicy` | Release deny-by-default and optional Debug simulator rule | +| `NextingDeviceCentral` | Recoverable CoreBluetooth setup, negotiated limits, and bounded acknowledged writes | +| `NextingDeviceSendRejection` | Explicit not-ready, oversize-frame, and queue-backpressure results | +| `NextingDevicePromptRelay` | One-current-prompt state, two-phase answer completion, and race handling | +| `NextingDeviceRelayCoordinator` | Connects an authorized transport to host prompt context and authoritative action result | +| `NextingDeviceAnswerClaimGate` | Single-consumption gate shared by phone and hardware input | ## Encode a message @@ -80,23 +59,6 @@ let wire = NextingDeviceCodec.encode( `wire` is optional because invalid IDs, summaries, or TTL values fail closed. -Generic physical controls use the same codec. The Host owns their product -meaning: - -```swift -let message = NextingDeviceMessage.keyEvent( - slot: 3, - event: .press, - sequence: 18 -) -guard deviceInfo.supportsProfile(message.requiredProfile) else { return } -guard let wire = NextingDeviceCodec.encode(message) else { return } -``` - -Use `interactionSequence` to reject replayed or out-of-order navigation, key, -rotary, and voice control events. `voice/1` carries only start/stop/cancel; -capture and transcription stay on the Host microphone. - The codec's public Experimental 0.2 ceiling is 4096 bytes for the complete compact JSON message including its terminating newline. `NextingDeviceLineDecoder` may be configured lower, but not higher, and transport callbacks should pass bounded chunks into it. ## Create an authorized BLE central diff --git a/devices/sdk/swift/Sources/NextingDeviceHostSmoke/main.swift b/devices/sdk/swift/Sources/NextingDeviceHostSmoke/main.swift deleted file mode 100644 index 51e8fee..0000000 --- a/devices/sdk/swift/Sources/NextingDeviceHostSmoke/main.swift +++ /dev/null @@ -1,139 +0,0 @@ -import Foundation -import NextingDeviceKit - -#if canImport(Darwin) -import Darwin -#endif - -private let usage = """ -Usage: nexting-device-host-smoke [--summary TEXT] [--timeout 5...300] - -Discovers one nearby Nexting reference device, validates Device Info, sends a -synthetic approval over encrypted BLE, and exits after a real button answer. -Success: PASS answer=allow or PASS answer=deny -""" - -if CommandLine.arguments.dropFirst().contains("--help") { - print(usage) - exit(0) -} - -let configuration: NextingDeviceHostSmokeConfiguration -do { - configuration = try .parse(arguments: Array(CommandLine.arguments.dropFirst())) -} catch { - FileHandle.standardError.write( - Data("ERROR arguments=\(error)\n\(usage)\n".utf8) - ) - exit(64) -} - -var authorizedID: UUID? -var finished = false -let requestID = "host-smoke-\(UUID().uuidString.prefix(8).lowercased())" -let startedAt = Date() - -let central = NextingDeviceCentral( - authorizationPredicate: { identity in - identity.id == authorizedID - } -) - -@MainActor -func fail(_ reason: String, repair: String) -> Never { - guard !finished else { exit(2) } - finished = true - FileHandle.standardError.write( - Data("FAIL reason=\(reason)\nREPAIR \(repair)\n".utf8) - ) - central.disconnect() - exit(2) -} - -central.onDiscovered = { identity, rssi in - guard authorizedID == nil else { return } - authorizedID = identity.id - print( - "DISCOVERED name=\(identity.name ?? "(unnamed)") id=\(identity.id.uuidString) rssi=\(rssi)" - ) - print("AUTHORIZATION ephemeral=first-discovered persistence=none") -} - -central.onSendRejected = { rejection in - fail( - "send_rejected_\(String(describing: rejection))", - repair: "Reflash the tagged reference firmware and retry." - ) -} - -central.onConnectedChange = { ready in - guard ready, !finished else { return } - guard let identity = central.connectedIdentity, - let info = central.connectedDeviceInfo else { - fail( - "missing_device_info", - repair: "See docs/troubleshooting.md#the-host-rejects-device-info." - ) - } - - print("CONNECTED id=\(identity.id.uuidString)") - print("Device Info:") - print(" model=\(info.model)") - print(" firmware=\(info.firmwareVersion)") - print(" protocol=\(info.protocolName)") - print(" wire=\(info.wireVersions.map(String.init).joined(separator: ","))") - print(" profiles=\(info.profiles.joined(separator: ","))") - print(" max_message_bytes=\(info.maxMessageBytes)") - print(" max_summary_bytes=\(info.maxSummaryBytes)") - print(" buttons=\(info.capabilities.buttonCount.map(String.init) ?? "unknown")") - if let battery = central.connectedBatteryLevel { - print(" battery=\(battery)%") - } - - guard let wire = NextingDeviceCodec.encode(.present( - requestId: requestID, - summary: configuration.summary, - ttlMs: configuration.timeoutSeconds * 1_000 - )), central.send(wire) else { - fail( - "present_not_sent", - repair: "Check Device Info limits and rerun the command." - ) - } - print("PRESENT id=\(requestID) ttl=\(configuration.timeoutSeconds)s") - print("ACTION press Allow or Deny once") -} - -central.onMessage = { identity, message in - guard !finished, identity.id == authorizedID else { return } - guard case let .answer(id, choice) = message, id == requestID else { return } - finished = true - if let resolved = NextingDeviceCodec.encode(.resolved( - requestId: requestID, - reason: .answered - )) { - _ = central.send(resolved) - } - print(NextingDeviceHostSmokeConfiguration.passLine(choice: choice)) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - central.disconnect() - exit(0) - } -} - -print("Bluetooth discovery starting; approve the macOS Bluetooth prompt if shown.") -central.startScan() - -let timer = Timer.scheduledTimer(withTimeInterval: 0.25, repeats: true) { _ in - Task { @MainActor in - guard !finished else { return } - if Date().timeIntervalSince(startedAt) >= Double(configuration.timeoutSeconds) { - fail( - "timeout", - repair: "Turn on Bluetooth, grant Terminal access, power the board, then read docs/troubleshooting.md." - ) - } - } -} -RunLoop.main.add(timer, forMode: .common) -RunLoop.main.run() diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift b/devices/sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift index 47f25a2..fde89e4 100644 --- a/devices/sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift +++ b/devices/sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift @@ -65,38 +65,6 @@ public struct NextingDeviceInfo: Equatable, Sendable { && statusSlots >= 1 } - public func supportsProfile(_ profile: String) -> Bool { - supportsApprovalV1 && profiles.contains(profile) - } - - public var supportsNavigationV1: Bool { - supportsProfile(NextingDeviceCodec.navigationProfile) - } - - public var supportsKeysV1: Bool { - supportsProfile(NextingDeviceCodec.keysProfile) - } - - public var supportsRotaryV1: Bool { - supportsProfile(NextingDeviceCodec.rotaryProfile) - } - - public var supportsVoiceV1: Bool { - supportsProfile(NextingDeviceCodec.voiceProfile) - } - - public var supportsTextV1: Bool { - supportsProfile(NextingDeviceCodec.textProfile) - } - - public var supportsUsageV1: Bool { - supportsProfile(NextingDeviceCodec.usageProfile) - } - - public var supportsConfigV1: Bool { - supportsProfile(NextingDeviceCodec.configProfile) - } - public static func decode(_ data: Data) -> NextingDeviceInfo? { guard data.count <= maxEncodedBytes, String(data: data, encoding: .utf8) != nil, diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/HostSmoke.swift b/devices/sdk/swift/Sources/NextingDeviceKit/HostSmoke.swift deleted file mode 100644 index e84e1d7..0000000 --- a/devices/sdk/swift/Sources/NextingDeviceKit/HostSmoke.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -public struct NextingDeviceHostSmokeConfiguration: Equatable, Sendable { - public enum ParseError: Error, Equatable { - case missingValue(String) - case invalidTimeout(String) - case invalidSummary - case unknownArgument(String) - } - - public let summary: String - public let timeoutSeconds: Int - - public init(summary: String, timeoutSeconds: Int) { - self.summary = summary - self.timeoutSeconds = timeoutSeconds - } - - public static func parse(arguments: [String]) throws -> Self { - var summary = "Allow the Nexting hardware smoke test?" - var timeoutSeconds = 60 - var index = 0 - - while index < arguments.count { - switch arguments[index] { - case "--summary": - guard index + 1 < arguments.count else { - throw ParseError.missingValue("--summary") - } - summary = arguments[index + 1] - index += 2 - case "--timeout": - guard index + 1 < arguments.count else { - throw ParseError.missingValue("--timeout") - } - let raw = arguments[index + 1] - guard let value = Int(raw), (5 ... 300).contains(value) else { - throw ParseError.invalidTimeout(raw) - } - timeoutSeconds = value - index += 2 - default: - throw ParseError.unknownArgument(arguments[index]) - } - } - - guard NextingDeviceCodec.encode(.present( - requestId: "host-smoke", - summary: summary, - ttlMs: timeoutSeconds * 1_000 - )) != nil else { - throw ParseError.invalidSummary - } - return Self(summary: summary, timeoutSeconds: timeoutSeconds) - } - - public static func passLine(choice: NextingDeviceChoice) -> String { - "PASS answer=\(choice.rawValue)" - } -} diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/Protocol.swift b/devices/sdk/swift/Sources/NextingDeviceKit/Protocol.swift index ca1cffc..13151b2 100644 --- a/devices/sdk/swift/Sources/NextingDeviceKit/Protocol.swift +++ b/devices/sdk/swift/Sources/NextingDeviceKit/Protocol.swift @@ -44,238 +44,18 @@ public struct NextingDeviceAgentStatus: Equatable, Sendable { } } -public enum NextingNavigationDirection: String, Equatable, Sendable { - case previous = "prev" - case next - case up - case down - case left - case right -} - -public enum NextingNavigationResolution: String, Equatable, Sendable { - case selected - case cancelled - case expired - case replaced -} - -public enum NextingControlGesture: String, Equatable, Sendable { - case press - case release - case hold - case double -} - -public enum NextingKeyLight: String, Equatable, Sendable { - case off - case dim - case solid - case pulse -} - -public struct NextingRGB: Equatable, Sendable { - public let red: Int - public let green: Int - public let blue: Int - - public init(red: Int, green: Int, blue: Int) { - self.red = red - self.green = green - self.blue = blue - } -} - -public struct NextingKeyPresentation: Equatable, Sendable { - public let slot: Int - public let label: String - public let enabled: Bool - public let light: NextingKeyLight - public let rgb: NextingRGB? - - public init( - slot: Int, - label: String, - enabled: Bool, - light: NextingKeyLight, - rgb: NextingRGB? = nil - ) { - self.slot = slot - self.label = label - self.enabled = enabled - self.light = light - self.rgb = rgb - } -} - -public struct NextingRotaryControl: Equatable, Sendable { - public let slot: Int - public let label: String - public let value: Int - public let minimum: Int - public let maximum: Int - public let wrap: Bool - - public init( - slot: Int, - label: String, - value: Int, - minimum: Int, - maximum: Int, - wrap: Bool - ) { - self.slot = slot - self.label = label - self.value = value - self.minimum = minimum - self.maximum = maximum - self.wrap = wrap - } -} - -public enum NextingVoiceEvent: String, Equatable, Sendable { - case start - case stop - case cancel -} - -public enum NextingVoiceState: String, Equatable, Sendable { - case idle - case listening - case transcribing - case submitted - case error -} - -public struct NextingUsageSnapshot: Equatable, Sendable { - public let model: String - public let inputTokens: Int - public let outputTokens: Int - public let cachedTokens: Int? - public let contextUsed: Int? - public let contextLimit: Int? - - public init( - model: String, - inputTokens: Int, - outputTokens: Int, - cachedTokens: Int? = nil, - contextUsed: Int? = nil, - contextLimit: Int? = nil - ) { - self.model = model - self.inputTokens = inputTokens - self.outputTokens = outputTokens - self.cachedTokens = cachedTokens - self.contextUsed = contextUsed - self.contextLimit = contextLimit - } -} - -public enum NextingConfigValue: Equatable, Sendable { - case boolean(Bool) - case integer(Int) - case string(String) -} - -public struct NextingConfigEntry: Equatable, Sendable { - public let key: String - public let value: NextingConfigValue - - public init(key: String, value: NextingConfigValue) { - self.key = key - self.value = value - } -} - -public enum NextingConfigStatus: String, Equatable, Sendable { - case applied - case rejected -} - -public enum NextingConfigError: String, Equatable, Sendable { - case unknownKey = "unknown_key" - case invalidValue = "invalid_value" - case storageError = "storage_error" - case unsupported -} - public enum NextingDeviceMessage: Equatable, Sendable { case present(requestId: String, summary: String, ttlMs: Int) case answer(requestId: String, choice: NextingDeviceChoice) case resolved(requestId: String, reason: NextingDeviceResolutionReason) case error(requestId: String?, code: NextingDeviceErrorCode) case status(agents: [NextingDeviceAgentStatus]) - case navigationPresent(requestId: String, items: [String], cursor: Int, ttlMs: Int) - case navigationMove(requestId: String, direction: NextingNavigationDirection, sequence: UInt32) - case navigationSelect(requestId: String, index: Int, sequence: UInt32) - case navigationResolved(requestId: String, reason: NextingNavigationResolution) - case keymap(revision: UInt32, keys: [NextingKeyPresentation]) - case keyEvent(slot: Int, event: NextingControlGesture, sequence: UInt32) - case rotaryMap(revision: UInt32, controls: [NextingRotaryControl]) - case rotaryEvent(slot: Int, delta: Int, sequence: UInt32) - case rotaryPress(slot: Int, event: NextingControlGesture, sequence: UInt32) - case voiceEvent(event: NextingVoiceEvent, sequence: UInt32) - case voiceState(state: NextingVoiceState, label: String?) - case text(channel: Int, title: String?, content: String) - case usage(NextingUsageSnapshot) - case usageClear - case config(revision: UInt32, entries: [NextingConfigEntry]) - case configResult(revision: UInt32, status: NextingConfigStatus, code: NextingConfigError?) - - public var requiredProfile: String { - switch self { - case .present, .answer, .resolved, .error: - NextingDeviceCodec.profile - case .status: - NextingDeviceCodec.statusProfile - case .navigationPresent, .navigationMove, .navigationSelect, - .navigationResolved: - NextingDeviceCodec.navigationProfile - case .keymap, .keyEvent: - NextingDeviceCodec.keysProfile - case .rotaryMap, .rotaryEvent, .rotaryPress: - NextingDeviceCodec.rotaryProfile - case .voiceEvent, .voiceState: - NextingDeviceCodec.voiceProfile - case .text: - NextingDeviceCodec.textProfile - case .usage, .usageClear: - NextingDeviceCodec.usageProfile - case .config, .configResult: - NextingDeviceCodec.configProfile - } - } - - public var interactionSequence: (source: String, value: UInt32)? { - switch self { - case let .navigationMove(requestId, _, sequence), - let .navigationSelect(requestId, _, sequence): - ("navigation:\(requestId)", sequence) - case let .keyEvent(slot, _, sequence): - ("key:\(slot)", sequence) - case let .rotaryEvent(slot, _, sequence), - let .rotaryPress(slot, _, sequence): - ("rotary:\(slot)", sequence) - case let .voiceEvent(_, sequence): - ("voice", sequence) - default: - nil - } - } } public enum NextingDeviceCodec { public static let wireVersion = 1 public static let profile = "approval/1" public static let statusProfile = "status/1" - public static let navigationProfile = "navigation/1" - public static let keysProfile = "keys/1" - public static let rotaryProfile = "rotary/1" - public static let voiceProfile = "voice/1" - public static let textProfile = "text/1" - public static let usageProfile = "usage/1" - public static let configProfile = "config/1" public static let maxRequestIDBytes = 64 public static let maxSummaryBytes = 240 public static let maxTTLMilliseconds = 300_000 @@ -285,15 +65,6 @@ public enum NextingDeviceCodec { private static let knownWireFields: Set = [ "v", "t", "id", "sum", "opt", "ttl", "ch", "r", "code", "agents", - "items", "cursor", "dir", "seq", "index", "rev", "keys", "slot", - "event", "controls", "delta", "state", "label", "channel", "title", - "content", "model", "input_tokens", "output_tokens", "cached_tokens", - "context_used", "context_limit", "entries", "status", - ] - private static let canonicalUnsignedFields: Set = [ - "v", "ttl", "cursor", "seq", "index", "rev", "slot", "channel", - "input_tokens", "output_tokens", "cached_tokens", "context_used", - "context_limit", ] public static func encode(_ message: NextingDeviceMessage) -> Data? { @@ -327,93 +98,6 @@ public enum NextingDeviceCodec { return entry + "}" }.joined(separator: ",") text = "{\"v\":1,\"t\":\"status\",\"agents\":[\(entries)]}\n" - case let .navigationPresent(requestId, items, cursor, ttlMs): - guard validID(requestId), validNavigationItems(items), - items.indices.contains(cursor), validTTL(ttlMs) else { return nil } - let encodedItems = items.map(jsonString).joined(separator: ",") - text = "{\"v\":1,\"t\":\"nav_present\",\"id\":\(jsonString(requestId)),\"items\":[\(encodedItems)],\"cursor\":\(cursor),\"ttl\":\(ttlMs)}\n" - case let .navigationMove(requestId, direction, sequence): - guard validID(requestId) else { return nil } - text = "{\"v\":1,\"t\":\"nav_move\",\"id\":\(jsonString(requestId)),\"dir\":\(jsonString(direction.rawValue)),\"seq\":\(sequence)}\n" - case let .navigationSelect(requestId, index, sequence): - guard validID(requestId), (0 ... 7).contains(index) else { return nil } - text = "{\"v\":1,\"t\":\"nav_select\",\"id\":\(jsonString(requestId)),\"index\":\(index),\"seq\":\(sequence)}\n" - case let .navigationResolved(requestId, reason): - guard validID(requestId) else { return nil } - text = "{\"v\":1,\"t\":\"nav_resolved\",\"id\":\(jsonString(requestId)),\"r\":\(jsonString(reason.rawValue))}\n" - case let .keymap(revision, keys): - guard validKeyPresentations(keys) else { return nil } - let entries = keys.map { key in - var entry = "{\"slot\":\(key.slot),\"label\":\(jsonString(key.label)),\"enabled\":\(key.enabled ? "true" : "false"),\"light\":\(jsonString(key.light.rawValue))" - if let rgb = key.rgb { - entry += ",\"rgb\":[\(rgb.red),\(rgb.green),\(rgb.blue)]" - } - return entry + "}" - }.joined(separator: ",") - text = "{\"v\":1,\"t\":\"keymap\",\"rev\":\(revision),\"keys\":[\(entries)]}\n" - case let .keyEvent(slot, event, sequence): - guard (0 ... 63).contains(slot) else { return nil } - text = "{\"v\":1,\"t\":\"key_event\",\"slot\":\(slot),\"event\":\(jsonString(event.rawValue)),\"seq\":\(sequence)}\n" - case let .rotaryMap(revision, controls): - guard validRotaryControls(controls) else { return nil } - let entries = controls.map { - "{\"slot\":\($0.slot),\"label\":\(jsonString($0.label)),\"value\":\($0.value),\"min\":\($0.minimum),\"max\":\($0.maximum),\"wrap\":\($0.wrap ? "true" : "false")}" - }.joined(separator: ",") - text = "{\"v\":1,\"t\":\"rotary_map\",\"rev\":\(revision),\"controls\":[\(entries)]}\n" - case let .rotaryEvent(slot, delta, sequence): - guard (0 ... 15).contains(slot), (-127 ... 127).contains(delta), - delta != 0 else { return nil } - text = "{\"v\":1,\"t\":\"rotary_event\",\"slot\":\(slot),\"delta\":\(delta),\"seq\":\(sequence)}\n" - case let .rotaryPress(slot, event, sequence): - guard (0 ... 15).contains(slot) else { return nil } - text = "{\"v\":1,\"t\":\"rotary_press\",\"slot\":\(slot),\"event\":\(jsonString(event.rawValue)),\"seq\":\(sequence)}\n" - case let .voiceEvent(event, sequence): - text = "{\"v\":1,\"t\":\"voice_event\",\"event\":\(jsonString(event.rawValue)),\"seq\":\(sequence)}\n" - case let .voiceState(state, label): - if let label { - guard validText(label, minimumBytes: 1, maximumBytes: 64) else { return nil } - text = "{\"v\":1,\"t\":\"voice_state\",\"state\":\(jsonString(state.rawValue)),\"label\":\(jsonString(label))}\n" - } else { - text = "{\"v\":1,\"t\":\"voice_state\",\"state\":\(jsonString(state.rawValue))}\n" - } - case let .text(channel, title, content): - guard (0 ... 7).contains(channel), - validText(content, minimumBytes: 0, maximumBytes: 1_024, allowLayout: true), - title.map({ validText($0, minimumBytes: 1, maximumBytes: 64) }) ?? true - else { return nil } - var body = "{\"v\":1,\"t\":\"text\",\"channel\":\(channel)" - if let title { body += ",\"title\":\(jsonString(title))" } - text = body + ",\"content\":\(jsonString(content))}\n" - case let .usage(snapshot): - guard validUsage(snapshot) else { return nil } - var body = "{\"v\":1,\"t\":\"usage\",\"model\":\(jsonString(snapshot.model)),\"input_tokens\":\(snapshot.inputTokens),\"output_tokens\":\(snapshot.outputTokens)" - if let cached = snapshot.cachedTokens { body += ",\"cached_tokens\":\(cached)" } - if let used = snapshot.contextUsed, let limit = snapshot.contextLimit { - body += ",\"context_used\":\(used),\"context_limit\":\(limit)" - } - text = body + "}\n" - case .usageClear: - text = "{\"v\":1,\"t\":\"usage_clear\"}\n" - case let .config(revision, entries): - guard validConfigEntries(entries) else { return nil } - let encodedEntries = entries.map { entry in - let value: String - switch entry.value { - case let .boolean(flag): value = flag ? "true" : "false" - case let .integer(number): value = String(number) - case let .string(string): value = jsonString(string) - } - return "{\"key\":\(jsonString(entry.key)),\"value\":\(value)}" - }.joined(separator: ",") - text = "{\"v\":1,\"t\":\"config\",\"rev\":\(revision),\"entries\":[\(encodedEntries)]}\n" - case let .configResult(revision, status, code): - if status == .applied { - guard code == nil else { return nil } - text = "{\"v\":1,\"t\":\"config_result\",\"rev\":\(revision),\"status\":\"applied\"}\n" - } else { - guard let code else { return nil } - text = "{\"v\":1,\"t\":\"config_result\",\"rev\":\(revision),\"status\":\"rejected\",\"code\":\(jsonString(code.rawValue))}\n" - } } return Data(text.utf8) } @@ -477,212 +161,6 @@ public enum NextingDeviceCodec { } guard validStatusAgents(agents) else { return nil } return .status(agents: agents) - case "nav_present": - guard exactKeys(message, ["v", "t", "id", "items", "cursor", "ttl"]), - let requestID = message["id"] as? String, validID(requestID), - let items = message["items"] as? [String], validNavigationItems(items), - let cursor = integer(message["cursor"]), items.indices.contains(cursor), - let ttl = integer(message["ttl"]), validTTL(ttl) - else { return nil } - return .navigationPresent( - requestId: requestID, - items: items, - cursor: cursor, - ttlMs: ttl - ) - case "nav_move": - guard exactKeys(message, ["v", "t", "id", "dir", "seq"]), - let requestID = message["id"] as? String, validID(requestID), - let rawDirection = message["dir"] as? String, - let direction = NextingNavigationDirection(rawValue: rawDirection), - let sequence = uint32(message["seq"]) - else { return nil } - return .navigationMove( - requestId: requestID, - direction: direction, - sequence: sequence - ) - case "nav_select": - guard exactKeys(message, ["v", "t", "id", "index", "seq"]), - let requestID = message["id"] as? String, validID(requestID), - let index = integer(message["index"]), (0 ... 7).contains(index), - let sequence = uint32(message["seq"]) - else { return nil } - return .navigationSelect(requestId: requestID, index: index, sequence: sequence) - case "nav_resolved": - guard exactKeys(message, ["v", "t", "id", "r"]), - let requestID = message["id"] as? String, validID(requestID), - let rawReason = message["r"] as? String, - let reason = NextingNavigationResolution(rawValue: rawReason) - else { return nil } - return .navigationResolved(requestId: requestID, reason: reason) - case "keymap": - guard exactKeys(message, ["v", "t", "rev", "keys"]), - let revision = uint32(message["rev"]), - let rawKeys = message["keys"] as? [[String: Any]] - else { return nil } - var keys: [NextingKeyPresentation] = [] - for rawKey in rawKeys { - guard exactKeys(rawKey, ["slot", "label", "enabled", "light", "rgb"]), - let slot = integer(rawKey["slot"]), - let label = rawKey["label"] as? String, - let enabled = boolean(rawKey["enabled"]), - let rawLight = rawKey["light"] as? String, - let light = NextingKeyLight(rawValue: rawLight) - else { return nil } - var rgb: NextingRGB? - if rawKey.keys.contains("rgb") { - guard let values = rawKey["rgb"] as? [Any], values.count == 3, - let red = integer(values[0]), - let green = integer(values[1]), - let blue = integer(values[2]) - else { return nil } - rgb = NextingRGB(red: red, green: green, blue: blue) - } - keys.append(.init( - slot: slot, - label: label, - enabled: enabled, - light: light, - rgb: rgb - )) - } - guard validKeyPresentations(keys) else { return nil } - return .keymap(revision: revision, keys: keys) - case "key_event": - guard exactKeys(message, ["v", "t", "slot", "event", "seq"]), - let slot = integer(message["slot"]), (0 ... 63).contains(slot), - let rawEvent = message["event"] as? String, - let event = NextingControlGesture(rawValue: rawEvent), - let sequence = uint32(message["seq"]) - else { return nil } - return .keyEvent(slot: slot, event: event, sequence: sequence) - case "rotary_map": - guard exactKeys(message, ["v", "t", "rev", "controls"]), - let revision = uint32(message["rev"]), - let rawControls = message["controls"] as? [[String: Any]] - else { return nil } - var controls: [NextingRotaryControl] = [] - for rawControl in rawControls { - guard exactKeys( - rawControl, - ["slot", "label", "value", "min", "max", "wrap"] - ), - let slot = integer(rawControl["slot"]), - let label = rawControl["label"] as? String, - let value = integer(rawControl["value"]), - let minimum = integer(rawControl["min"]), - let maximum = integer(rawControl["max"]), - let wrap = boolean(rawControl["wrap"]) - else { return nil } - controls.append(.init( - slot: slot, - label: label, - value: value, - minimum: minimum, - maximum: maximum, - wrap: wrap - )) - } - guard validRotaryControls(controls) else { return nil } - return .rotaryMap(revision: revision, controls: controls) - case "rotary_event": - guard exactKeys(message, ["v", "t", "slot", "delta", "seq"]), - let slot = integer(message["slot"]), (0 ... 15).contains(slot), - let delta = integer(message["delta"]), (-127 ... 127).contains(delta), - delta != 0, let sequence = uint32(message["seq"]) - else { return nil } - return .rotaryEvent(slot: slot, delta: delta, sequence: sequence) - case "rotary_press": - guard exactKeys(message, ["v", "t", "slot", "event", "seq"]), - let slot = integer(message["slot"]), (0 ... 15).contains(slot), - let rawEvent = message["event"] as? String, - let event = NextingControlGesture(rawValue: rawEvent), - let sequence = uint32(message["seq"]) - else { return nil } - return .rotaryPress(slot: slot, event: event, sequence: sequence) - case "voice_event": - guard exactKeys(message, ["v", "t", "event", "seq"]), - let rawEvent = message["event"] as? String, - let event = NextingVoiceEvent(rawValue: rawEvent), - let sequence = uint32(message["seq"]) - else { return nil } - return .voiceEvent(event: event, sequence: sequence) - case "voice_state": - guard exactKeys(message, ["v", "t", "state", "label"]), - let rawState = message["state"] as? String, - let state = NextingVoiceState(rawValue: rawState) - else { return nil } - let label = message["label"] as? String - guard !message.keys.contains("label") - || label.map({ validText($0, minimumBytes: 1, maximumBytes: 64) }) == true - else { return nil } - return .voiceState(state: state, label: label) - case "text": - guard exactKeys(message, ["v", "t", "channel", "title", "content"]), - let channel = integer(message["channel"]), (0 ... 7).contains(channel), - let content = message["content"] as? String, - validText(content, minimumBytes: 0, maximumBytes: 1_024, allowLayout: true) - else { return nil } - let title = message["title"] as? String - guard !message.keys.contains("title") - || title.map({ validText($0, minimumBytes: 1, maximumBytes: 64) }) == true - else { return nil } - return .text(channel: channel, title: title, content: content) - case "usage": - guard exactKeys(message, [ - "v", "t", "model", "input_tokens", "output_tokens", - "cached_tokens", "context_used", "context_limit", - ]), - let model = message["model"] as? String, - let input = integer(message["input_tokens"]), - let output = integer(message["output_tokens"]) - else { return nil } - let cached = optionalInteger(message, "cached_tokens") - let contextUsed = optionalInteger(message, "context_used") - let contextLimit = optionalInteger(message, "context_limit") - guard cached.valid, contextUsed.valid, contextLimit.valid else { return nil } - let snapshot = NextingUsageSnapshot( - model: model, - inputTokens: input, - outputTokens: output, - cachedTokens: cached.value, - contextUsed: contextUsed.value, - contextLimit: contextLimit.value - ) - guard validUsage(snapshot) else { return nil } - return .usage(snapshot) - case "usage_clear": - guard exactKeys(message, ["v", "t"]) else { return nil } - return .usageClear - case "config": - guard exactKeys(message, ["v", "t", "rev", "entries"]), - let revision = uint32(message["rev"]), - let rawEntries = message["entries"] as? [[String: Any]] - else { return nil } - var entries: [NextingConfigEntry] = [] - for rawEntry in rawEntries { - guard exactKeys(rawEntry, ["key", "value"]), - let key = rawEntry["key"] as? String, - let value = configValue(rawEntry["value"]) - else { return nil } - entries.append(.init(key: key, value: value)) - } - guard validConfigEntries(entries) else { return nil } - return .config(revision: revision, entries: entries) - case "config_result": - guard exactKeys(message, ["v", "t", "rev", "status", "code"]), - let revision = uint32(message["rev"]), - let rawStatus = message["status"] as? String, - let status = NextingConfigStatus(rawValue: rawStatus) - else { return nil } - let code = (message["code"] as? String).flatMap(NextingConfigError.init(rawValue:)) - if status == .applied { - guard !message.keys.contains("code") else { return nil } - } else { - guard code != nil else { return nil } - } - return .configResult(revision: revision, status: status, code: code) default: return nil } @@ -726,144 +204,6 @@ public enum NextingDeviceCodec { return true } - private static func exactKeys( - _ value: [String: Any], - _ allowed: Set - ) -> Bool { - Set(value.keys).isSubset(of: allowed) - } - - private static func validText( - _ value: String, - minimumBytes: Int, - maximumBytes: Int, - allowLayout: Bool = false - ) -> Bool { - let count = value.utf8.count - guard (minimumBytes ... maximumBytes).contains(count) else { return false } - return value.unicodeScalars.allSatisfy { - if allowLayout, $0.value == 0x09 || $0.value == 0x0A { return true } - return $0.value > 0x1F && $0.value != 0x7F - } - } - - private static func validNavigationItems(_ items: [String]) -> Bool { - (2 ... 8).contains(items.count) - && Set(items).count == items.count - && items.allSatisfy { - validText($0, minimumBytes: 1, maximumBytes: 64) - } - } - - private static func validKeyPresentations( - _ keys: [NextingKeyPresentation] - ) -> Bool { - guard keys.count <= 64, Set(keys.map(\.slot)).count == keys.count else { - return false - } - return keys.allSatisfy { key in - (0 ... 63).contains(key.slot) - && validText(key.label, minimumBytes: 1, maximumBytes: 32) - && key.rgb.map { - [ $0.red, $0.green, $0.blue ].allSatisfy { - (0 ... 255).contains($0) - } - } ?? true - } - } - - private static func validRotaryControls( - _ controls: [NextingRotaryControl] - ) -> Bool { - guard controls.count <= 16, - Set(controls.map(\.slot)).count == controls.count else { - return false - } - return controls.allSatisfy { - (0 ... 15).contains($0.slot) - && validText($0.label, minimumBytes: 1, maximumBytes: 32) - && (-1_000_000 ... 1_000_000).contains($0.minimum) - && (-1_000_000 ... 1_000_000).contains($0.maximum) - && $0.minimum <= $0.value - && $0.value <= $0.maximum - } - } - - private static func validCounter(_ value: Int) -> Bool { - (0 ... 9_007_199_254_740_991).contains(value) - } - - private static func validUsage(_ snapshot: NextingUsageSnapshot) -> Bool { - validText(snapshot.model, minimumBytes: 1, maximumBytes: 64) - && validCounter(snapshot.inputTokens) - && validCounter(snapshot.outputTokens) - && (snapshot.cachedTokens.map(validCounter) ?? true) - && ((snapshot.contextUsed == nil) == (snapshot.contextLimit == nil)) - && { - guard let used = snapshot.contextUsed, - let limit = snapshot.contextLimit else { return true } - return validCounter(used) && validCounter(limit) && used <= limit - }() - } - - private static let configKeyPattern = try! NSRegularExpression( - pattern: #"^[A-Za-z0-9][A-Za-z0-9._-]{0,47}$"# - ) - - private static func validConfigEntries( - _ entries: [NextingConfigEntry] - ) -> Bool { - guard entries.count <= 32, - Set(entries.map(\.key)).count == entries.count else { return false } - return entries.allSatisfy { entry in - let range = NSRange(entry.key.startIndex..., in: entry.key) - guard configKeyPattern.firstMatch( - in: entry.key, - range: range - ) != nil else { return false } - switch entry.value { - case .boolean: - return true - case let .integer(value): - return (-1_000_000 ... 1_000_000).contains(value) - case let .string(value): - return validText( - value, - minimumBytes: 0, - maximumBytes: 128 - ) - } - } - } - - private static func boolean(_ value: Any?) -> Bool? { - guard let number = value as? NSNumber, - CFGetTypeID(number) == CFBooleanGetTypeID() else { return nil } - return number.boolValue - } - - private static func uint32(_ value: Any?) -> UInt32? { - guard let value = integer(value), (0 ... Int(UInt32.max)).contains(value) - else { return nil } - return UInt32(value) - } - - private static func optionalInteger( - _ message: [String: Any], - _ key: String - ) -> (valid: Bool, value: Int?) { - guard message.keys.contains(key) else { return (true, nil) } - guard let value = integer(message[key]) else { return (false, nil) } - return (true, value) - } - - private static func configValue(_ raw: Any?) -> NextingConfigValue? { - if let value = boolean(raw) { return .boolean(value) } - if let value = raw as? String { return .string(value) } - if let value = integer(raw) { return .integer(value) } - return nil - } - private static func integer(_ value: Any?) -> Int? { guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID(), @@ -894,7 +234,7 @@ public enum NextingDeviceCodec { if knownWireFields.contains(key), !seenKnownFields.insert(key).inserted { return false } - if canonicalUnsignedFields.contains(key), + if (key == "v" || key == "ttl"), !hasCanonicalUnsignedIntegerValue(bytes, afterKey: end) { return false } diff --git a/devices/sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift b/devices/sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift index e166ff2..2022740 100644 --- a/devices/sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift +++ b/devices/sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift @@ -92,7 +92,7 @@ private func loadDeviceInfoVectors() throws -> DeviceInfoVectors { @Test("Device Info 0.2 shared vectors normalize identically") func deviceInfoSharedVectors() throws { let vectors = try loadDeviceInfoVectors() - #expect(vectors.spec == "0.2.0-experimental.2") + #expect(vectors.spec == "0.2.0-experimental.0") #expect(vectors.wire == 1) for item in vectors.valid { diff --git a/devices/sdk/swift/Tests/NextingDeviceKitTests/HostSmokeTests.swift b/devices/sdk/swift/Tests/NextingDeviceKitTests/HostSmokeTests.swift deleted file mode 100644 index 68b1775..0000000 --- a/devices/sdk/swift/Tests/NextingDeviceKitTests/HostSmokeTests.swift +++ /dev/null @@ -1,35 +0,0 @@ -import Testing -@testable import NextingDeviceKit - -@Test("host smoke arguments have safe defaults and bounded overrides") -func hostSmokeArguments() throws { - let defaults = try NextingDeviceHostSmokeConfiguration.parse(arguments: []) - #expect(defaults.summary == "Allow the Nexting hardware smoke test?") - #expect(defaults.timeoutSeconds == 60) - - let custom = try NextingDeviceHostSmokeConfiguration.parse(arguments: [ - "--summary", "Allow fixture 7?", - "--timeout", "15", - ]) - #expect(custom.summary == "Allow fixture 7?") - #expect(custom.timeoutSeconds == 15) - - #expect(throws: NextingDeviceHostSmokeConfiguration.ParseError.self) { - try NextingDeviceHostSmokeConfiguration.parse(arguments: ["--timeout", "0"]) - } - #expect(throws: NextingDeviceHostSmokeConfiguration.ParseError.self) { - try NextingDeviceHostSmokeConfiguration.parse(arguments: ["--mystery"]) - } -} - -@Test("host smoke PASS line is machine-readable") -func hostSmokePassLine() { - #expect( - NextingDeviceHostSmokeConfiguration.passLine(choice: .allow) - == "PASS answer=allow" - ) - #expect( - NextingDeviceHostSmokeConfiguration.passLine(choice: .deny) - == "PASS answer=deny" - ) -} diff --git a/devices/sdk/swift/Tests/NextingDeviceKitTests/InteractionProfileTests.swift b/devices/sdk/swift/Tests/NextingDeviceKitTests/InteractionProfileTests.swift deleted file mode 100644 index b1a1515..0000000 --- a/devices/sdk/swift/Tests/NextingDeviceKitTests/InteractionProfileTests.swift +++ /dev/null @@ -1,82 +0,0 @@ -import Foundation -import Testing -@testable import NextingDeviceKit - -private struct InteractionVectorDocument: Decodable { - struct Valid: Decodable { - let name: String - let wire: String - } - - struct Invalid: Decodable { - let name: String - let wire: String - } - - let profile: String - let valid: [Valid] - let invalid: [Invalid] -} - -private func interactionVectorDirectory() -> URL { - URL(fileURLWithPath: #filePath) - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .appendingPathComponent("protocol/vectors", isDirectory: true) -} - -@Test("all interaction vectors use the same strict Swift codec") -func interactionVectorsRoundTrip() throws { - let profiles = ["navigation", "keys", "rotary", "voice", "text", "usage", "config"] - for profile in profiles { - let document = try JSONDecoder().decode( - InteractionVectorDocument.self, - from: Data( - contentsOf: interactionVectorDirectory() - .appendingPathComponent("\(profile)-v1.json") - ) - ) - #expect(document.profile == "\(profile)/1") - for vector in document.valid { - let message = try #require( - NextingDeviceCodec.decode(Data(vector.wire.utf8)), - Comment(rawValue: vector.name) - ) - #expect( - NextingDeviceCodec.encode(message) == Data(vector.wire.utf8), - Comment(rawValue: vector.name) - ) - } - for vector in document.invalid { - #expect( - NextingDeviceCodec.decode(Data(vector.wire.utf8)) == nil, - Comment(rawValue: vector.name) - ) - } - } -} - -@Test("interaction profile negotiation is explicit") -func interactionProfileNegotiation() throws { - let root = interactionVectorDirectory() - .appendingPathComponent("device-info-v1.json") - let document = try JSONSerialization.jsonObject( - with: Data(contentsOf: root) - ) as! [String: Any] - let valid = (document["valid"] as! [[String: Any]])[0] - var payload = try JSONSerialization.jsonObject( - with: Data((valid["wire"] as! String).utf8) - ) as! [String: Any] - payload["profiles"] = ["approval/1", "navigation/1", "keys/1"] - let info = try #require( - NextingDeviceInfo.decode( - try JSONSerialization.data(withJSONObject: payload) - ) - ) - #expect(info.supportsNavigationV1) - #expect(info.supportsKeysV1) - #expect(!info.supportsConfigV1) -} From c87bf23668d6faaec10cc91c50726c3e6904a096 Mon Sep 17 00:00:00 2001 From: Shang Date: Fri, 31 Jul 2026 16:05:56 +0800 Subject: [PATCH 2/3] ci(devices): sync public SDK workflows Use the exported toolchain commands for the audited devices subtree. Co-Authored-By: Claude Fable 5 --- .github/workflows/nexting-devices-ci.yml | 16 ++-- .../workflows/nexting-devices-firmware.yml | 93 +++---------------- 2 files changed, 23 insertions(+), 86 deletions(-) diff --git a/.github/workflows/nexting-devices-ci.yml b/.github/workflows/nexting-devices-ci.yml index 69f2055..d403989 100644 --- a/.github/workflows/nexting-devices-ci.yml +++ b/.github/workflows/nexting-devices-ci.yml @@ -19,8 +19,8 @@ jobs: javascript-and-boundary: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-node@v7 + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 with: node-version: 22 - run: npm run check @@ -29,7 +29,7 @@ jobs: swift: runs-on: macos-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - run: swift test - run: >- swiftc -warnings-as-errors -o /tmp/nexting-device-sim @@ -39,18 +39,18 @@ jobs: kotlin: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 - - uses: actions/setup-java@v5 + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 with: distribution: temurin java-version: 21 - - uses: gradle/actions/setup-gradle@v6 - - run: ./gradlew test + - uses: gradle/actions/setup-gradle@v4 + - run: gradle test working-directory: devices/sdk/kotlin c99: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v7 + - uses: actions/checkout@v4 - run: cmake -S devices/sdk/c -B build -DNEXTING_DEVICE_SANITIZE=ON - run: cmake --build build && ctest --test-dir build --output-on-failure diff --git a/.github/workflows/nexting-devices-firmware.yml b/.github/workflows/nexting-devices-firmware.yml index cab3880..1b10b3e 100644 --- a/.github/workflows/nexting-devices-firmware.yml +++ b/.github/workflows/nexting-devices-firmware.yml @@ -8,8 +8,6 @@ on: - "devices/west.yml" - ".github/workflows/nexting-devices-firmware.yml" push: - tags: - - "devices-v*" paths: - "devices/firmware/**" - "devices/sdk/c/**" @@ -32,13 +30,11 @@ jobs: include: - board: nrf52840dk/nrf52840 artifact: nrf52840dk - flash: west flash with the onboard SWD debugger - board: xiao_ble/nrf52840/sense artifact: xiao-nrf52840-sense - flash: copy zephyr.uf2 to the XIAO BLE mass-storage bootloader steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.12" - uses: zephyrproject-rtos/action-zephyr-setup@v1 @@ -48,30 +44,14 @@ jobs: sdk-version: 0.17.4 west-version: 1.5.0 - run: west build -p always -b ${{ matrix.board }} devices/firmware/zephyr - - name: Package self-describing firmware asset - env: - BOARD: ${{ matrix.board }} - ARTIFACT: ${{ matrix.artifact }} - FLASH: ${{ matrix.flash }} - run: | - set -eu - bundle="nexting-device-${ARTIFACT}-${GITHUB_SHA}" - mkdir -p "${bundle}" - for image in build/zephyr/zephyr.elf build/zephyr/zephyr.bin build/zephyr/zephyr.hex build/zephyr/zephyr.uf2; do - if [ -f "${image}" ]; then cp "${image}" "${bundle}/"; fi - done - test -n "$(find "${bundle}" -type f -print -quit)" - ( - cd "${bundle}" - sha256sum zephyr.* > SHA256SUMS - printf '{\n "schemaVersion": 1,\n "sourceCommit": "%s",\n "board": "%s",\n "artifact": "%s",\n "flash": "%s",\n "zephyr": "4.3.0",\n "zephyrSdk": "0.17.4",\n "west": "1.5.0",\n "evidence": "Build verified",\n "boardVerified": false\n}\n' \ - "${GITHUB_SHA}" "${BOARD}" "${ARTIFACT}" "${FLASH}" > artifact-manifest.json - ) - tar -czf "${bundle}.tar.gz" "${bundle}" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@v4 with: name: nexting-device-${{ matrix.artifact }}-${{ github.sha }} - path: nexting-device-${{ matrix.artifact }}-${{ github.sha }}.tar.gz + path: | + build/zephyr/zephyr.elf + build/zephyr/zephyr.bin + build/zephyr/zephyr.hex + build/zephyr/zephyr.uf2 if-no-files-found: error espressif: @@ -82,13 +62,11 @@ jobs: include: - board: xiao_esp32c3/esp32c3 artifact: xiao-esp32c3 - flash: west flash through the Espressif serial bootloader - board: xiao_esp32s3/esp32s3/procpu artifact: xiao-esp32s3 - flash: west flash through the Espressif serial bootloader steps: - - uses: actions/checkout@v7 - - uses: actions/setup-python@v7 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.12" - uses: zephyrproject-rtos/action-zephyr-setup@v1 @@ -99,52 +77,11 @@ jobs: west-version: 1.5.0 - run: west blobs fetch hal_espressif - run: west build -p always -b ${{ matrix.board }} devices/firmware/zephyr - - name: Package self-describing firmware asset - env: - BOARD: ${{ matrix.board }} - ARTIFACT: ${{ matrix.artifact }} - FLASH: ${{ matrix.flash }} - run: | - set -eu - bundle="nexting-device-${ARTIFACT}-${GITHUB_SHA}" - mkdir -p "${bundle}" - for image in build/zephyr/zephyr.elf build/zephyr/zephyr.bin build/zephyr/zephyr.hex; do - if [ -f "${image}" ]; then cp "${image}" "${bundle}/"; fi - done - test -n "$(find "${bundle}" -type f -print -quit)" - ( - cd "${bundle}" - sha256sum zephyr.* > SHA256SUMS - printf '{\n "schemaVersion": 1,\n "sourceCommit": "%s",\n "board": "%s",\n "artifact": "%s",\n "flash": "%s",\n "zephyr": "4.3.0",\n "zephyrSdk": "0.17.4",\n "west": "1.5.0",\n "evidence": "Build verified",\n "boardVerified": false\n}\n' \ - "${GITHUB_SHA}" "${BOARD}" "${ARTIFACT}" "${FLASH}" > artifact-manifest.json - ) - tar -czf "${bundle}.tar.gz" "${bundle}" - - uses: actions/upload-artifact@v7 + - uses: actions/upload-artifact@v4 with: name: nexting-device-${{ matrix.artifact }}-${{ github.sha }} - path: nexting-device-${{ matrix.artifact }}-${{ github.sha }}.tar.gz + path: | + build/zephyr/zephyr.elf + build/zephyr/zephyr.bin + build/zephyr/zephyr.hex if-no-files-found: error - - release: - if: startsWith(github.ref, 'refs/tags/devices-v') - needs: - - nordic - - espressif - runs-on: ubuntu-latest - permissions: - contents: write - steps: - - uses: actions/download-artifact@v8 - with: - pattern: nexting-device-*-${{ github.sha }} - path: release-assets - merge-multiple: true - - name: Publish immutable Experimental release - env: - GH_TOKEN: ${{ github.token }} - run: | - gh release create "${GITHUB_REF_NAME}" release-assets/*.tar.gz \ - --repo "${GITHUB_REPOSITORY}" \ - --prerelease \ - --title "Nexting Devices SDK ${GITHUB_REF_NAME#devices-v}" \ - --notes "Public Experimental release. Firmware assets are Build verified with pinned Zephyr 4.3.0, Zephyr SDK 0.17.4, and west 1.5.0. They are not Board verified; inspect each artifact-manifest.json and the repository conformance guide." From 24415459b15af2b29baba90362bde4b98e3f042f Mon Sep 17 00:00:00 2001 From: Shang Date: Fri, 31 Jul 2026 16:11:04 +0800 Subject: [PATCH 3/3] docs(devices): record MultiPad pre-flash bring-up state Keep PCB, bootloader, pin, and physical evidence blockers explicit before any hardware write. Co-Authored-By: Claude Fable 5 --- devices/SHA256SUMS | 5 ++- devices/firmware/multipad/bringup-status.md | 40 +++++++++++++++++ .../firmware/multipad/hw-review-findings.md | 16 +++++++ devices/firmware/multipad/pin-function-map.md | 43 +++++++++++++++++++ devices/scripts/export-manifest.json | 3 ++ 5 files changed, 106 insertions(+), 1 deletion(-) 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/pin-function-map.md diff --git a/devices/SHA256SUMS b/devices/SHA256SUMS index 45491ab..c2da75d 100644 --- a/devices/SHA256SUMS +++ b/devices/SHA256SUMS @@ -17,10 +17,13 @@ dc9e38145477ae5c9f9818ad3567d7d57f9bd31f264279014b30ffec097b22e7 docs/README.md 5b04ce5083234a4137fad6eee987946b55bfe7a758220394eb127b804aa4f873 docs/use-cases.md cddf6aec5d4750dc34c9a6f642c604c8786ba89018082b96db8a7b6fe716b7b5 examples/macos-device-simulator/main.swift 85a7b0f0976f7b43099629335e9b61be625a77ce548e438956dbf3a5e1de2e27 examples/macos-device-simulator/README.md +3f56b901400960503a304e6c0f8f4a6aa0ab5467b8f097737b3a2d99dda6e5d0 firmware/multipad/bringup-status.md e93dad280dad39b252d5d0edaf549cf9db8a8833eb009bc2b089da8ebfb181d2 firmware/multipad/CMakeLists.txt +e86c924b3b6cdd85fcf311dba4d85804cdf70a3fbeb554c4215d4617e37b9850 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 +b077373089e8bc8100b9dbb003fafb6e9bd97b2d471df84c0dc8a990e4e2c680 firmware/multipad/pin-function-map.md 38103d0b2ed11139f1a1d3cfae91daccc6c57514086621a1ef49ca9e8d1d0270 firmware/multipad/README.md 49d3bf8fc0756bf4c69eb6d5af3b7cfe788c2c56822f000ba05adcad8d16e7c7 firmware/multipad/tests/test_adapter.c a111d8e0484745a126069df990273171181a61caa5a3a751043974cc52f43f00 firmware/multipad/tools/flash-multipad.sh @@ -61,7 +64,7 @@ dd94f6782ad7f3f8ab102806ac2b32555a8f61ef6da04eca504250591f43d697 reference/js/t e3f347b6e98e6a1ca3f9f842a6c2f1245f0f6953680a685ecbdd42d17dd1e1ed scripts/check-public-boundary.mjs 8d7aad1d02044e2762835d765a6bad5fc41c454e15d75111f957db45bc72348c scripts/check-public-boundary.test.mjs 2ff5e27e3ce3c7d4e0623d1b66d5ff7fb24c2edf8308e35ba7b5fcdb59e76e4d scripts/documentation-contract.test.mjs -4d4f5bdc49028e5c05941cab8a387ee65a5723508755f4efbb6fe2f240b251b6 scripts/export-manifest.json +222cee57ac6ec97208cea67a6e3e7cf8672000f10acfd60a09d6f21af4b0b10f scripts/export-manifest.json f461f2683632c7fb38a5c04b2fa03a0293fe791571b1b173982514530cc793d6 scripts/export-nexting-devices.mjs 85f681344b04754999e17a4ec472dfae1ed1c2daa49f065d77bacdb223ea57fe scripts/export-nexting-devices.test.mjs a7120b95b63f5c7004f5099fcd9bf073fd34e167cef6e6382258d869f6faa2ac scripts/public-workflows/nexting-devices-ci.yml diff --git a/devices/firmware/multipad/bringup-status.md b/devices/firmware/multipad/bringup-status.md new file mode 100644 index 0000000..106e884 --- /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..5323430 --- /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/pin-function-map.md b/devices/firmware/multipad/pin-function-map.md new file mode 100644 index 0000000..8034f7d --- /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/scripts/export-manifest.json b/devices/scripts/export-manifest.json index 3ee78fc..e67d735 100644 --- a/devices/scripts/export-manifest.json +++ b/devices/scripts/export-manifest.json @@ -39,7 +39,10 @@ "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",