diff --git a/.github/workflows/nexting-devices-ci.yml b/.github/workflows/nexting-devices-ci.yml index d403989..dee7f6d 100644 --- a/.github/workflows/nexting-devices-ci.yml +++ b/.github/workflows/nexting-devices-ci.yml @@ -4,12 +4,10 @@ on: pull_request: paths: - "devices/**" - - "Package.swift" - ".github/workflows/nexting-devices-ci.yml" push: paths: - "devices/**" - - "Package.swift" - ".github/workflows/nexting-devices-ci.yml" permissions: @@ -19,38 +17,16 @@ jobs: javascript-and-boundary: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 22 - run: npm run check working-directory: devices - swift: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - run: swift test - - run: >- - swiftc -warnings-as-errors -o /tmp/nexting-device-sim - main.swift ../../sdk/swift/Sources/NextingDeviceKit/Protocol.swift - working-directory: devices/examples/macos-device-simulator - - kotlin: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 21 - - uses: gradle/actions/setup-gradle@v4 - - run: gradle test - working-directory: devices/sdk/kotlin - c99: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - 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 1b10b3e..cab3880 100644 --- a/.github/workflows/nexting-devices-firmware.yml +++ b/.github/workflows/nexting-devices-firmware.yml @@ -8,6 +8,8 @@ on: - "devices/west.yml" - ".github/workflows/nexting-devices-firmware.yml" push: + tags: + - "devices-v*" paths: - "devices/firmware/**" - "devices/sdk/c/**" @@ -30,11 +32,13 @@ 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@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" - uses: zephyrproject-rtos/action-zephyr-setup@v1 @@ -44,14 +48,30 @@ jobs: sdk-version: 0.17.4 west-version: 1.5.0 - run: west build -p always -b ${{ matrix.board }} devices/firmware/zephyr - - uses: actions/upload-artifact@v4 + - 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 with: name: nexting-device-${{ matrix.artifact }}-${{ github.sha }} - path: | - build/zephyr/zephyr.elf - build/zephyr/zephyr.bin - build/zephyr/zephyr.hex - build/zephyr/zephyr.uf2 + path: nexting-device-${{ matrix.artifact }}-${{ github.sha }}.tar.gz if-no-files-found: error espressif: @@ -62,11 +82,13 @@ 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@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" - uses: zephyrproject-rtos/action-zephyr-setup@v1 @@ -77,11 +99,52 @@ jobs: west-version: 1.5.0 - run: west blobs fetch hal_espressif - run: west build -p always -b ${{ matrix.board }} devices/firmware/zephyr - - uses: actions/upload-artifact@v4 + - 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 with: name: nexting-device-${{ matrix.artifact }}-${{ github.sha }} - path: | - build/zephyr/zephyr.elf - build/zephyr/zephyr.bin - build/zephyr/zephyr.hex + path: nexting-device-${{ matrix.artifact }}-${{ github.sha }}.tar.gz 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/Package.swift b/Package.swift deleted file mode 100644 index eeccac6..0000000 --- a/Package.swift +++ /dev/null @@ -1,28 +0,0 @@ -// Generated by devices/scripts/export-nexting-devices.mjs -// This root package makes the public host SDK installable directly from -// github.com/Nexting-ai/nexting. - -// swift-tools-version: 6.0 -import PackageDescription - -let package = Package( - name: "Nexting", - platforms: [ - .iOS(.v16), - .macOS(.v13), - ], - products: [ - .library(name: "NextingDeviceKit", targets: ["NextingDeviceKit"]), - ], - targets: [ - .target( - name: "NextingDeviceKit", - path: "devices/sdk/swift/Sources/NextingDeviceKit" - ), - .testTarget( - name: "NextingDeviceKitTests", - dependencies: ["NextingDeviceKit"], - path: "devices/sdk/swift/Tests/NextingDeviceKitTests" - ), - ] -) diff --git a/README.md b/README.md index e46efaf..e8beb3b 100644 --- a/README.md +++ b/README.md @@ -242,8 +242,8 @@ MIT ## Build hardware for Nexting -The open hardware protocol, C99 device SDK, Swift and Kotlin host SDKs, -conformance vectors, and reference firmware live in [`devices/`](devices/). +The open hardware protocol, C99 device SDK, conformance vectors, and reference +firmware live in [`devices/`](devices/). - Start with the [Devices SDK overview](devices/README.md). - Read the [wire protocol](devices/SPEC.md). diff --git a/devices/AGENTS.md b/devices/AGENTS.md index def5458..3e5f36f 100644 --- a/devices/AGENTS.md +++ b/devices/AGENTS.md @@ -34,7 +34,7 @@ Do not import files from outside this repository root into public code. The expo - Interface or behavior change: `docs/foundation-development.md` → `docs/interfaces.md` → `SPEC.md` → vectors → component README. - New chip or board: `docs/foundation-development.md` → `docs/implementation-tracks.md` → `docs/porting-guide.md` → `docs/hardware-support.md` → `docs/conformance.md`. -- Host product integration: `docs/interfaces.md` → the Host track in `docs/implementation-tracks.md` → `sdk/swift/README.md`. Keep the product adapter thin and private. +- Device integration: `docs/interfaces.md` → the reference-board or MCU track in `docs/implementation-tracks.md`. The official Nexting App owns Host integration. - Documentation change: update the owning guide and the documentation-contract test in the same commit. ## Protocol change order @@ -57,7 +57,6 @@ From the repository root: ```sh npm run test:reference -swift test --package-path sdk/swift cmake -S sdk/c -B /tmp/nexting-device-c -DNEXTING_DEVICE_SANITIZE=ON cmake --build /tmp/nexting-device-c ctest --test-dir /tmp/nexting-device-c --output-on-failure @@ -69,8 +68,7 @@ npm run check:naming `npm run check` is the final aggregate gate. At the 2026-07-27 snapshot it passes, including Device Info 0.2, the audited exporter, mandatory bonding, three-second bond reset, and notification fragmentation. Run -`gradle test` in `sdk/kotlin` as the Android Host SDK gate. Do not weaken or -delete those assertions; a source contract and compiler gate still do not +Do not weaken or delete these assertions; a source contract and compiler gate still do not replace the real-board checklist. ## Hardware claims diff --git a/devices/CHANGELOG.md b/devices/CHANGELOG.md index 766b051..b903856 100644 --- a/devices/CHANGELOG.md +++ b/devices/CHANGELOG.md @@ -4,18 +4,68 @@ This file records changes to public protocol behavior, shared vectors, SDK surfa ## Unreleased -- Add the first ILX MultiPad case guide with pinned upstream source, public - adapter integration, key mapping, build separation, and SWD/serial evidence - rules. +### Changed + +- Reframed the Quickstart around the device–App–Agent architecture and split + the XIAO two-button exercise into a clearly labeled Developer Reference + tutorial. +- Removed custom Apple and Android Host SDKs from the public candidate. The + official Nexting App is the supported Host; this package is device-side. +- 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 and fixed-buffer C99 implementations of every new message. +- Device Info profile negotiation helpers and device-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 guide now documents the public protocol boundary 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 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. +- 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 reproducible firmware and protocol verification path for the Developer + Reference boards. +- 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 keep the official App + boundary explicit. ## 0.2.0-experimental.0 — 2026-07-27 @@ -27,33 +77,32 @@ profile versions. ### Added - BLE GATT and newline-delimited `approval/1` protocol for one bounded Allow/Deny request. -- Profile `status/1`: a Host→device downlink that replaces the full rendered state of up to eight anonymous agent-status slots (`idle`, `thinking`, `working`, `complete`, `needs_input`, `error`) with an optional 64-byte label, negotiated through the new optional `statusSlots` Device Info capability field and cleared on disconnect or reboot. Ships with shared `status-v1.json` vectors, a schema def, and JavaScript, Swift, and C implementations. The Zephyr reference does not declare `statusSlots` until per-slot indicator rendering is implemented and board-verified. +- Profile `status/1`: an App→device downlink that replaces the full rendered state of up to eight anonymous agent-status slots (`idle`, `thinking`, `working`, `complete`, `needs_input`, `error`) with an optional 64-byte label, negotiated through the new optional `statusSlots` Device Info capability field and cleared on disconnect or reboot. Ships with shared `status-v1.json` vectors, a schema def, and JavaScript and C implementations. The Zephyr reference does not declare `statusSlots` until per-slot indicator rendering is implemented and board-verified. - Shared valid and hostile-input vectors plus a JSON Schema. - Executable JavaScript reference with explicit `protocol`, `framing`, and `relay` entry points. -- `NextingDeviceKit` for Apple hosts and a fixed-buffer portable C99 device core. +- A fixed-buffer portable C99 device core for firmware authors. - Developer documentation for architecture, security, ports, hardware support, verification, and Agent-driven changes. - Public foundation blueprint, interface catalog, implementation tracks, conformance evidence rules, and an executable documentation contract for third-party and Agent development. - Optional, bounded Device Info identity, hardware-capability, Battery Service, - and vendor-extension metadata shared by all language SDKs. -- A Kotlin/JVM protocol SDK for Android hosts. + and vendor-extension metadata shared by the device SDK and protocol reference. - Explicit iOS and Android enrollment, revocation, and extensible accessory information-table contracts in the Nexting App. - A deterministic allowlisted exporter into `Nexting-ai/nexting/devices`, - root SwiftPM integration, SHA-256 manifest, and hostile-input tests. + SHA-256 manifest, and hostile-input tests. ### Security and correctness -- Official JavaScript, Swift, and C decoders share 15 valid and 29 hostile vectors, covering every enum and exact summary boundaries while consistently rejecting duplicate known fields, decoded NUL, lone surrogates, non-canonical integers, excessive nesting, oversized frames, invalid UTF-8, and over-limit summaries. +- Official JavaScript and C decoders share 15 valid and 29 hostile vectors, covering every enum and exact summary boundaries while consistently rejecting duplicate known fields, decoded NUL, lone surrogates, non-canonical integers, excessive nesting, oversized frames, invalid UTF-8, and over-limit summaries. - Complete-frame byte limits include the newline across all three stream decoders; Device Info accepts safely negotiable larger capacities while rejecting incompatible profiles and control characters. - Physical approval is restricted to exact two-option, one-time Allow/Deny prompts and resolves only after the Agent accepts the answer. - Host queues limit bytes and retained frame objects; synchronous enqueue rejection rolls back presentation state, and discovery recovers from malformed or failed Device Info reads. -- The macOS peripheral simulator requires encrypted answer subscription, accepts writes only from the subscribed central, reuses the Swift codec and monotonic TTL, and fragments notifications without interleaving frames. +- The reference protocol tests exercise encrypted-link assumptions, monotonic TTL, + and fragmented notifications without interleaving frames. - The Zephyr Developer Reference requires bonding, offers a fail-closed three-second physical bond reset, fragments one fixed answer frame through notification completion callbacks with stream resynchronization after an abandoned partial frame, disconnects failed security upgrades, and retries advertising after the connection slot is released. ### Verification status -- JavaScript, Swift, Kotlin, C sanitizer, iOS Simulator, Android build/unit/lint, - private integration, exporter, boundary, and simulator gates pass at the +- JavaScript, C sanitizer, firmware, exporter, and boundary gates pass at the 2026-07-27 snapshot. - Pinned Zephyr 4.3.0 / SDK 0.17.4 builds produce named artifacts for all four reference boards. Real-board BLE evidence remains pending; see diff --git a/devices/CONTRIBUTING.md b/devices/CONTRIBUTING.md index a81a6f5..3bdcba9 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 | +| Official App integration | Private Nexting App validation; not part of this public repository | +| Public documentation system | Documentation contract, links, public-boundary, naming, and diff checks | +| C device core | CMake build and CTest with ASan/UBSan | +| BLE simulator | Private integration contract and warnings-as-errors `swiftc` build | +| Reference firmware | Firmware contract plus every affected pinned board build | +| Hardware claim | Complete dated real-iPhone checklist for the exact board and firmware commit | +| Documentation only | Link, public-boundary, naming, and diff checks | The exact commands and current known failures live in [`docs/development.md`](docs/development.md). Do not delete, skip, or weaken a failing security assertion to make a branch green. diff --git a/devices/QUICKSTART.md b/devices/QUICKSTART.md new file mode 100644 index 0000000..79a3616 --- /dev/null +++ b/devices/QUICKSTART.md @@ -0,0 +1,97 @@ +# 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 official Nexting App. It owns credentials, +authorization, policy, and Agent-specific mapping. The device owns physical +input and minimum display state. This public repository documents the device +side of that contract; it does not provide a kit for building another App. + +## What each layer does + +| Layer | Responsibility | Does not receive | +| --- | --- | --- | +| Nexting device | Report physical intent and render bounded state | Agent credentials, accounts, session routes | +| Trusted Host | Authorize the device, validate fresh input, and map profiles to Agent actions | Unbounded or unauthenticated device commands | +| Agent integration | Continue the Claude Code, Codex, or compatible Agent session | A direct connection from accessory firmware | + +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 protocol and firmware checks before adapting hardware | A deterministic profile exchange and a reproducible firmware build | + +First-party product onboarding does not authorize third-party or DIY hardware. +If you do not own a supported product, use the SDK path today. + +**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. Build and validate the +device locally; connect it through the official Nexting App when your device +enrollment is enabled. + +## 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 protocol/firmware checks, it +is a local device proof: useful for validating the open contract, but not a +claim that the public Nexting App already enrolls DIY hardware. + +## Why the App 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 without + exposing Agent credentials or App internals to firmware. + +## 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) +- [Integrate the C99 Device SDK](sdk/c/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 37f6c07..d23ce97 100644 --- a/devices/README.md +++ b/devices/README.md @@ -2,9 +2,18 @@ Open interfaces for building physical control surfaces for AI agents. -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. +Nexting Devices lets a button, wearable, desk panel, macropad, or custom +product control and display bounded AI Agent interactions through the official +Nexting 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. +**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). +App availability is machine-readable in +[`docs/availability.json`](docs/availability.json). + +**Nearby device, agent anywhere.** The device talks to the official Nexting App over Bluetooth LE; the App 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. ## The first product experience @@ -18,32 +27,41 @@ The device does not run the Agent session and does not receive Agent credentials ## Choose your path -| 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 | -| Reproduce the first public hardware case | Follow the [ILX MultiPad case](docs/cases/multipad-first-case.md); build the upstream firmware before adding Nexting | -| 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. +| You want to… | Start here | +| --- | --- | +| Understand and connect Nexting | [Follow the public Quickstart](QUICKSTART.md) | +| Build the XIAO approval reference | [Build the reference approval controller](docs/reference-approval-controller.md) | +| Get ideas for what to build | [Browse the use cases](docs/use-cases.md) | +| Build and flash a supported board | [Run the first approval](docs/first-approval.md), then use the [reference-board track](docs/implementation-tracks.md#track-1-run-a-reference-board) | +| Understand the device connection contract | [Browse every public interface](docs/interfaces.md) | +| Port a new MCU, RTOS, or chip family | [Build the public foundation](docs/foundation-development.md), then use the [MCU port track](docs/implementation-tracks.md#track-3-port-a-new-mcu-or-rtos) | +| Implement another protocol tool | Use the [protocol-tooling track](docs/implementation-tracks.md#track-3-maintain-protocol-tooling) | +| Make a compatibility claim | [Understand compatibility evidence](docs/conformance.md) | +| Upgrade an Experimental 0.1 integration | [Read the 0.2 migration guide](docs/migration-0.1-to-0.2.md) | + +## Experimental 0.2.0-experimental.2 + +The release keeps wire major 1 and publishes nine independently negotiated +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 | Public: - BLE GATT and wire specification; - shared schema and valid/hostile vectors; - JavaScript readable reference; -- Swift and Kotlin Host SDKs plus a portable C99 device SDK; -- macOS BLE simulator; +- portable C99 device SDK and JavaScript protocol reference; - equal Zephyr references for nRF52840 and ESP32; - conformance tests, build workflows, and developer documentation. @@ -56,14 +74,18 @@ Not public: The wire format may change before 1.0. Developer Reference devices are not production security certifications. -## The capability direction +## 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. -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). +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. 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. @@ -78,6 +100,9 @@ All four share one Zephyr application and the portable C99 core. All four are Bu ## Documentation +- [Understand the device–App–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) @@ -88,7 +113,6 @@ All four share one Zephyr application and the portable C99 core. All four are Bu - [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 a7077f9..0b82e47 100644 --- a/devices/SECURITY.md +++ b/devices/SECURITY.md @@ -11,7 +11,8 @@ The device receives only an opaque request ID, a bounded summary, fixed choices, ## Minimum transport controls - BLE LE Secure Connections and bonding; -- encryption required for approval writes and notification subscription; +- encrypted GATT permissions required for control 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; @@ -19,6 +20,44 @@ 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 78cc35a..f903c32 100644 --- a/devices/SHA256SUMS +++ b/devices/SHA256SUMS @@ -1,110 +1,86 @@ -5e79c3b2a9dc997d6faa762fa3d420da27243c27a60726a5b4d94dabfdfcfb17 AGENTS.md -362a3eea47663c7cb770739dba81e86818624b0874d448b6136873156521a2eb CHANGELOG.md -0905c822fd9d85cb05d05539ca0b41ce807109621f06e5d18792505a78fc270f CONTRIBUTING.md -f0e5a3b3fc8d8d664d76be452f1631a296ef89566acb58440354da31af2074bd docs/board-verification.md -06a0f796634a47af9fb8628b11206a91120505e0a0d0fd6757915ef44f961f47 docs/cases/multipad-first-case.md -bdfc3ae27b547003179322577ca28a03e8d06bb46815625ec1270ff3142b50da docs/conformance.md -92b35fee2f0ae4195fc31614a01b1aeba3b2bdc24b8f59c38e46a2c0c537dcc2 docs/development.md -b6b2a3bc464d167d920f143c71ffde52c51d264f5cc71c0e8928f95986fa0e2b docs/first-approval.md -e27b38c5b92c32d3f0028996516b027fb842183730dae8c7ba3c87e5be882329 docs/foundation-development.md -253e7aff8894704a280e9a588e0181da98a9bd825fd506625b9325e357f6e419 docs/hardware-support.md -1cb75f130268edcf545e5525a08c948f2f470a6cdc23a0734dc0766d0f1e932b docs/implementation-tracks.md -16815c4d5e0ae09b8502f0109a0a4e38b64b467a4b0a82b029905093b63310af docs/interfaces.md -026b3ce7d5970d2a34d64862de12882f8da9e014d2ef9ab4a9c3d5e99af31b1b docs/migration-0.1-to-0.2.md -74f60bf30d5940a6cb2f71b208abb48196249a0f1195eb7ac7f6eeb125c3e573 docs/multipad-usb.md -36f7151ddbf307f8ff4cf5487d094930882b78b7ee91d241adf2eff53753ac55 docs/porting-guide.md -e0200e8dbcd3d758ae6fea3b608a3e648291877f7c5de284ea902ca1170062bf docs/project-status.md -dd62331e63b11b15874989474836fdff06b0282eca3340555554e0614ba266ef docs/README.md -5b04ce5083234a4137fad6eee987946b55bfe7a758220394eb127b804aa4f873 docs/use-cases.md -cddf6aec5d4750dc34c9a6f642c604c8786ba89018082b96db8a7b6fe716b7b5 examples/macos-device-simulator/main.swift -85a7b0f0976f7b43099629335e9b61be625a77ce548e438956dbf3a5e1de2e27 examples/macos-device-simulator/README.md -445fd02928f57ec5cb23b15c6fd4617cff6348e2df991736d04bad5d4ae8ec90 firmware/multipad/bringup-status.md -e93dad280dad39b252d5d0edaf549cf9db8a8833eb009bc2b089da8ebfb181d2 firmware/multipad/CMakeLists.txt -58edf13b1bbc8c855432f921eab5ac74befe9a78ac8254c9040eeef4b0704c52 firmware/multipad/hw-review-findings.md -d40be2000fae5697e0d40abebfc833d31c8568679c66c3f1b1cbb85744dac7d4 firmware/multipad/nexting_multipad_adapter.c -f4bab39e2320ae1f171eca78005cdfd355848dda5690020f2201de4a96d37587 firmware/multipad/nexting_multipad_adapter.h -55e8c889cfe9b62dcc62c591880aba2f9826a6a1353d7a89213916efd27c0bce firmware/multipad/nexting-multipad-device-info.template.json -732ad9fa55a9055e2d8c1c701489fa09608634bda4b89a2e1d601dff5e75e013 firmware/multipad/pin-function-map.md -2c743c98200d48a611fc9cb5994e11372b9daa3dc944570c3d48e55bf88be7ba firmware/multipad/README.md -49d3bf8fc0756bf4c69eb6d5af3b7cfe788c2c56822f000ba05adcad8d16e7c7 firmware/multipad/tests/test_adapter.c -a111d8e0484745a126069df990273171181a61caa5a3a751043974cc52f43f00 firmware/multipad/tools/flash-multipad.sh -4c27437ae7b7c5555a8a21eb763785e8600682ad71f0b12f29304e56cf132d5f firmware/multipad/tools/multipad-cdc-smoke.py +2640a33ac6d69556ef36c3d32708d2cd4c79eed7089e63d72746823614a09501 AGENTS.md +a311a5de4c12cedd2803da7c3fa12d759c90a4d2edcc2af18ab6a8f6048f2308 CHANGELOG.md +841a6883b971b9d286eb847d75925d498f66a624cdd331da57363bc41be70ee4 CONTRIBUTING.md +af25616c5fde628b7fd02d2ca52a7ac284fa98d985875ea0293122f07890fe6b docs/availability.json +fee2bc61257031a3a77f16d08317ffc900399da391e23c34c5a8eeee5e1242a4 docs/board-verification.md +32b057ed232482338571cdfe059e7cafd39e3bb50181faafc20d4e5686a6d442 docs/conformance.md +29dc27c8528032e2a5336f321d46168ff7fbf4e77eda64660a5bc2ecb0483b01 docs/development.md +554f14560e53c8428ef18575cc155eef52650767e698ca66b2f3de5c01c7eee9 docs/first-approval.md +e1a42beb0cc2e2715cfb50d4c2d9aaa4f9cc5e0b03f1168b6dfe8d9ab9624162 docs/foundation-development.md +45a6f16c8f43443ee7035efa0dd5d0c87839e2835510f52a4760ab77bbe76569 docs/hardware-support.md +b653e1bd72ddf8f9bbaa4a11457ac17a35ad9331bd655fa79cace3f9b54cd74e docs/implementation-tracks.md +3efcaa345571e2b67393634a8622328540e660d97f7811b9ede05d2d70caf289 docs/interfaces.md +8912e3a90ae17e5b864141efef5017bab3cc62e23ff432595e87114168905f5a docs/migration-0.1-to-0.2.md +b9dda6616039d28de4247731274a96b3b861fa5bd931a234ae4b05e3f2ca1978 docs/porting-guide.md +967ef62628994aaa085e8b617888f186e21d923515670422a672e09492ecbcfd docs/project-status.md +fcdfdd6dc2cdbf13d608b339dbd8439bbcfba3c4568816d7dd00b66e869fb796 docs/README.md +773df90e0d34f4c079ff48066383ba2f0dc1d872079c4ac3c3bd2e33eb21ee97 docs/reference-approval-controller.md +317190076de89ff907b686a29df6303e271ca65bb5383deee7e80122425d1cd9 docs/troubleshooting.md +8bded68d2fb5341d6595bc44ecfee4e16319f0f5a5a9431a09d8b013a63ba0cc docs/use-cases.md 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 -9d355da3bfffe4a950fdcf3bb7f1f0045a7da054f7e66f15824efc292546bf1d firmware/zephyr/package.json +363579f1d3b70d4e83de33a51587ee496c1f9d26105ace386251b6187ac0b321 firmware/zephyr/package.json ca92428690e5d784eea852e5f31573d78c6bc42c9fb7229d7989ff89cbd3b543 firmware/zephyr/prj.conf -d060ff9e9a7c6fb3cebc008b1db5b0127bbf077008e3553c95d7afdf37115c36 firmware/zephyr/README.md -91ad6d3fb2d182abd860c64a95e5297bb9e66f812317b89b8aa2daad7e977514 firmware/zephyr/src/main.c -386a921f3b12f805a676f5fad56b807f3c49b95f8cf3174d8bbd619476559377 firmware/zephyr/tests/firmware-contract.test.mjs +c700f02585e957eb666ce6b014a0cd9c30690c77deda5b19a86073c67716c1d7 firmware/zephyr/README.md +958e3a5e90984f2a0e2915c6c4047d98bbf4b46a9853e4ace2e2fe2143fc242d firmware/zephyr/src/main.c +2cf5855a3de0ebf320ad2df4b7c31cc22818d0dd149fef88b94e18eab5aba31c firmware/zephyr/tests/firmware-contract.test.mjs 01d0ae601acc2f5654a086597be88738ddf92967dc9d70fe81941afe42a62b44 LICENSE ffb09337e397e3597a52cacd161efd2a1b274fb6800a75fd1de67893a9d27021 LICENSES/CC-BY-4.0.txt faab06a4630dfc7b7e5c1fc7ecef24ae6ca0c0040f3b71f3d112d1a816a410b9 NOTICE -6470d2b9476248dad74f3882ca3d9afb4e85d8f1af8e84e96c32c0127ad9dfe1 package.json -af1ea5c4627d67b21afd238f48b7eb4931655894bd899450b16fc802d8cdcf68 protocol/vectors/approval-v1.json -eb2a6b125d123a5644a0773593c2eee730339f2809e51605f2f82eb518dde1d4 protocol/vectors/device-info-v1.json +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 3523ce906d597735ed9de66d79923b12de284595566c556725c10bf726ba84b0 protocol/vectors/status-v1.json -7ed0ffa1a546c198a2008f9fc9a1325035248c3fd4ccad3cce667790e579ddb9 README.md -83c03d7b5779aaf86471945890b5e699cba1171fe3064b58942a3784c4efa9d6 reference/js/package.json -18374cf8708c75abce5a22d6d445cf5ab48b6b62774ecf3af8f780693d1cf45e reference/js/README.md -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 +592cf022e7fb1310fbded1a7e5389abffdc42faf1c1c226feb8be41f5849d2c0 protocol/vectors/text-v1.json +052e46a781878cf3f343370c0492391f011577df44c7e3f8fe81c7c21e8499b1 protocol/vectors/usage-v1.json +2a5298dba7fb95fc5883407723ee178065139d81b5c6f46248c65829d8a6afbf protocol/vectors/voice-v1.json +8e3158f9e254db8a160c30a97539d94c768fdb38812181438e08df4854272d88 QUICKSTART.md +c074fecbd58ed23cd3d267a30a3ce939f05881efce3a7b1345c1b301dab54d64 README.md +d34ae54889cc7d601bc57011a65f2377bf70ee08cbbfba2aec081bd5e9ec02c7 reference/js/package.json +268baee817da2c39b26da691c9da621945528f5d7598e33b3dd55732bbe47335 reference/js/README.md +b25bb77cd8c9301aa664062b1ef00208f2e7d220419173268842338b7fd7254f reference/js/src/device-info.mjs +f438e875009475d7abfd7648607c7583ffac30e039807fa7b77eac4ee7106ec7 reference/js/src/framing.mjs +cc9bb6c2e427c6f6ffebc9334d24f8185a2c8e36cb0b6ad0912a056793e946d5 reference/js/src/protocol.mjs +a991cfc985e6623fed6d32cf8f0131b05940c8be04c418725dead5a60dd7cf56 reference/js/src/relay.mjs +4ab587c2d31a6a244a3f7a3501fea05afadb5d54f21921bbb5dc438e7d7b08d3 reference/js/test/device-info.test.mjs +028ce673b8b54d939b50d47b4cf534e2bf5cefa1bbca5030ec93b9bd78630c41 reference/js/test/framing.test.mjs +f253d2a3465557af1c228a04c820e837f95c8ee53b8f5af098240ae9b29c13e8 reference/js/test/package.test.mjs +7f52ef92691df05c526aa04b9283d924c9786f96d5e4288a9e2b28813f61979e reference/js/test/protocol.test.mjs +5589b79e0966553dc1f8067295ffbff4ec915f52eef060ba9af73fdeb72084b6 reference/js/test/relay.test.mjs +f98d37b7d3ec94cde23d46631d452f9a984bff7bc42a2ecd79383e91c9b75b18 reference/js/test/status.test.mjs +1f2e8af46598004fbfc7d21e1457506766835c7599be54f3a5f0cbaa206711e3 reference/js/test/vectors.test.mjs +224b6cd5e57ad61cdc531671cc1c2bf264b6131c8d7a5385f1b51f91b6dc7ca3 schemas/message.schema.json +9bc0262c63eb03ea8adf4eea273d213dc82ffa3c69b2fc838aca6772f785ced2 scripts/bootstrap-zephyr.sh +edd88282a6903f32c585803a60c3c5bb076445d36fc76e511d9965ed57e20422 scripts/bootstrap-zephyr.test.mjs +327730ddb2cc1bc0265d6a15df0cd89bb94b154d1eb18d698cc57fa2be4cacfd scripts/check-naming.mjs +0ac9fbc4e0688525458164868e417919bc176fbae0c21181b3ea4b8711cc9a34 scripts/check-public-boundary.mjs 8d7aad1d02044e2762835d765a6bad5fc41c454e15d75111f957db45bc72348c scripts/check-public-boundary.test.mjs -72e5a1d62ee49c9ca32dc026cf1d16bd1b55269cd10d00d0ad8c67efa01eb99a scripts/documentation-contract.test.mjs -2e6e8e0d3577700cc2e68aab5b034cffca7598474d54250ab9536eef25a7c6ad 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 +65094ffb7be92f7e2c39fb99be908515efd0ab0fa2ad8ff43cd599c330271488 scripts/documentation-contract.test.mjs +013437897df7e948137e9bfa27f1696d018553aa459ccf8fc32c3487455664e9 scripts/export-manifest.json +7050262f57951c99f890fdda5ef055809a09d70672aa69045e1c24934417030b scripts/export-nexting-devices.mjs +42f3f2d66a5bf8a5c723d00c94c98f4c37eb3da26be3162e5c0b8d04ca26b76d scripts/export-nexting-devices.test.mjs +f44eaaeef69779a0b34fcd343cda3dab63cf20ba33e9961f05a8ddafff5a4bc9 scripts/public-workflows/nexting-devices-ci.yml +61a898bbca6629ab9e990fb8fca9b22928cf245b6bfab91f85c9c70b96307e0d scripts/public-workflows/nexting-devices-firmware.yml +52f65438c304b96e40cffab7b4bc85bb29adda221fe67dfd4a8be43801b15a62 scripts/simulator-contract.test.mjs +cd514db604b66c71a3838261b66f74082ecfe2ff20050d9be8691e957e7535f2 sdk/c/CMakeLists.txt +2633a2cf0ab1a73ffbb821f809913aeacfe876280507f08f793d342b91451057 sdk/c/include/nexting_device.h +254b4b25875f0a583b854916123593d154d5ef3e03cfbf531ac1a954e8fe0a6c sdk/c/README.md +6045f7e936ecda97e327708aacd52244f81acca7a4079c7f9fbe2364515bab08 sdk/c/src/nexting_device.c +fe112284db09e0d371b1c206a632bbe7bbfa1d5eb214b4cae65a1e64c09ed9ee sdk/c/tests/generate_interaction_vectors.mjs +d55675aee8a104b896aacb9def0a099ecead4216b63d0202e0e26bf12e693119 sdk/c/tests/generate_vectors.mjs 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 -01b50657531fba6d93a7d1594e5a9225820ad5c540850682bd4dffd876ef56c3 sdk/kotlin/build.gradle.kts -10aba029577bc5f16b48e3191af180771e6bdc65cb5818a9f1a0fd369e2b8d70 sdk/kotlin/README.md -d7ecbec58086eba607d42430e3615fb6737b93c8d92745a9a9038b0a76b883d4 sdk/kotlin/settings.gradle.kts -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 -7296bf9298d90177e93426c6453e88c3fcc8611bc381fb3e665c34382539ec32 sdk/kotlin/src/test/kotlin/ai/nexting/devices/ProtocolTest.kt -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 -35f2e01a5a51397a0ff4a337c9a861719a75cee00c3703db24be833fce24e2ab sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift -83d02fa9ee4e0fc66dcc2c414e1c8c0ceb01ffbfd21d0ceb2ccf6e655cc57aeb sdk/swift/Sources/NextingDeviceKit/Framing.swift -da5cd514c89109e673fcfc4f6a5e09442e111b214c3529e435be094c81ca827d sdk/swift/Sources/NextingDeviceKit/Protocol.swift -43fd49c269adb79fa36efb892d7eec327262e5cdfa50b1dc2ad905756a155126 sdk/swift/Sources/NextingDeviceKit/Relay.swift -4be04d4013f412e6770aaf462ea0c0357c668e4cb8005bf03eee368d373e8019 sdk/swift/Sources/NextingDeviceKit/State.swift -04ed84f7d1b05c4e4d21cbbe2c30eb6e5bdc261432509d0b54cc5f341b57d78e sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift -a505f114c02ad031aa1c80461a7a9006f108bbeaf12a549fdacbbb73412b11ce sdk/swift/Tests/NextingDeviceKitTests/IntegrationTests.swift -baefe1d85ee7317797d03c5b12851ee4ed73eadbda8a0cdbd9215e60ddbe0e44 sdk/swift/Tests/NextingDeviceKitTests/RelayTests.swift -13f09fd666e7871d2298fcf2669bfda2f7adb4b6edb815c637b64c688f30d71c sdk/swift/Tests/NextingDeviceKitTests/StatusTests.swift -aae4345e72b23ee5f64ab863406f88bd4b5af3544f1e23e7da0eaaac37fbff3a sdk/swift/Tests/NextingDeviceKitTests/VectorTests.swift -08e9f7804e570679cc89fca2d13d210f122ae5c05135777ccb1ae51f5751c5e8 SECURITY.md -773d0fbfc63173bdbdfb9d0d4d4682348c49f0e0286e1a59468bf87a890b5576 SPEC.md +5237d7e35cf872b9348db413bed8a500c7868f368be5fe9e5ccc8e27faffb961 SECURITY.md +85982451b0fe5b20e7e1b00539fec7c8b54a06a1df563ef08d30063f4bdb15ed SPEC.md 4196491d589dbc14ad631e03d48d6ee1d736e1a119960a0b0240f6e35c173f56 west.yml diff --git a/devices/SPEC.md b/devices/SPEC.md index 2ca99d6..87dea8c 100644 --- a/devices/SPEC.md +++ b/devices/SPEC.md @@ -1,6 +1,8 @@ # Nexting Device Protocol — Experimental 0.2 -This document is normative for wire major `1`, profile `approval/1`, and profile `status/1`. +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`. ## Product contract @@ -17,12 +19,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. @@ -33,27 +35,7 @@ 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.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 -} +{"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} ``` The required fields remain `protocol`, `spec`, `wire`, `profiles`, `model`, @@ -106,12 +88,7 @@ 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 @@ -135,7 +112,7 @@ Each logical message is one compact UTF-8 JSON object followed by a newline byte 7. never executes a partial message; 8. does not accept interleaved fragments from different logical messages. -The official JavaScript, Swift, Kotlin, and portable C99 implementations cap a complete Experimental 0.2 frame at 4096 bytes even when Device Info advertises a larger capacity. A host may negotiate a smaller ceiling. Transport integrations must feed bounded radio or socket chunks rather than an arbitrarily large aggregate input buffer. +The official JavaScript and portable C99 implementations cap a complete Experimental 0.2 frame at 4096 bytes even when Device Info advertises a larger capacity. The official Nexting App may negotiate a smaller ceiling. Transport integrations must feed bounded radio or socket chunks rather than an arbitrarily large aggregate input buffer. Downlink fragments are sent serially with Write With Response by default. Uplink answers use Notify and application-level retry: if a device does not receive `resolved` within one second, it may resend the exact same answer until resolution or local TTL expiry. It must not change the choice during retry. Answers are idempotent by request ID at the host. @@ -157,14 +134,7 @@ 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. @@ -178,7 +148,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. @@ -186,7 +156,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: @@ -201,7 +171,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: @@ -221,11 +191,7 @@ 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. @@ -239,6 +205,148 @@ 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. @@ -267,7 +375,6 @@ 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 -`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. +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. diff --git a/devices/docs/README.md b/devices/docs/README.md index 892a19c..79f0efe 100644 --- a/devices/docs/README.md +++ b/devices/docs/README.md @@ -2,32 +2,37 @@ Choose the task you are trying to complete. +Start with the root [Quickstart](../QUICKSTART.md). It explains the +device–App–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 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. +- [Migrate from Experimental 0.1 to 0.2](migration-0.1-to-0.2.md): compatibility, metadata, and device profile 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. +- [Public interface catalog](interfaces.md): BLE, wire messages, and the portable C device API. - [Protocol specification](../SPEC.md): normative wire, BLE, state, limits, and versioning. - [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. +- Protocol and firmware tests can run without a board; the official Nexting App is the Host used for a real device. ## Implement or extend -- [Implementation tracks](implementation-tracks.md): reference board, Host/App, MCU/RTOS, and new-language routes. +- [Implementation tracks](implementation-tracks.md): reference board, MCU/RTOS, and protocol-tooling routes. - [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. -- [First case: ILX MultiPad](cases/multipad-first-case.md): reproduce the upstream build, add the public adapter, map the first two keys, and verify the flash path. - [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 new file mode 100644 index 0000000..8c27662 --- /dev/null +++ b/devices/docs/availability.json @@ -0,0 +1,17 @@ +{ + "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": "firmware-tests" +} diff --git a/devices/docs/board-verification.md b/devices/docs/board-verification.md index 3d7f41e..93ba932 100644 --- a/devices/docs/board-verification.md +++ b/devices/docs/board-verification.md @@ -13,40 +13,40 @@ Use this checklist for each exact board, firmware commit, and App version. Attac ## Required behavior -| Case | Expected result | Pass / evidence | -| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------- | -| Device Info | All required fields are readable; wire/profile/bounds match the binary. | | -| Optional Device Info | Every advertised button/rotary/display/haptics/battery capability matches the physical unit; absent capabilities create no empty App rows. | | -| Vendor facts | Bounded inert facts render only in the vendor section and cannot replace system-owned rows or create actions. | | -| Account metadata | Name, optional user number, and notes edit and sync without changing immutable hardware identity. | | -| Cross-platform table | iOS and Android show the same ordered continuous key/value information table for the same Device Info. | | -| Encryption | Plain approval write and unencrypted notification subscription are rejected or trigger pairing. | | -| Bond required | A peer that requests an encrypted but non-bonding session is rejected; a completed bond survives reconnect and board reboot. | | -| Pairing failure recovery | A rejected or failed security upgrade disconnects that peer, then the board becomes discoverable again without rebooting. | | -| Unauthorized device | Release App does not connect or disclose a summary before explicit authorization. | | -| Present | One current summary appears and Pending output turns on. | | -| Device Allow | One Allow wins, App continues, matching `answered` clears the device. | | -| Device Deny | One Deny wins, App continues, matching `answered` clears the device. | | -| Phone first | Phone choice wins; device clears; late button press is ignored. | | -| Device first | Device choice wins; later phone tap cannot consume the prompt again. | | -| Repeat | Holding/bouncing the button never changes or double-consumes the first choice. | | -| Retry | Dropping the first notification causes the exact same answer to retry after one second. | | -| Answer fragmentation | At a small negotiated ATT MTU, a maximum-ID Answer arrives as one ordered newline frame with no truncation, duplication, or interleaving. | | -| Replacement | New Present replaces old state; old ID cannot answer. | | -| Expiry | At the TTL boundary, LED clears and a press cannot answer. | | -| Cancellation | Host cancellation clears Pending immediately. | | -| Disconnect | Connection loss clears current request, answer cache, LED, and partial frame. | | -| Reconnect | Device restores no approval; host may re-present with only remaining TTL. | | -| Reboot | Bond may persist; approval and partial frame never persist. | | -| Fragmentation | A valid message split through a multi-byte UTF-8 character decodes once. | | -| Oversize | Input above the advertised limit is discarded through newline and recovery works. | | -| Malformed input | Invalid UTF-8, JSON, version, profile, option, and ID fail closed. | | -| Short reset chord | Pressing both buttons for less than three seconds neither answers nor clears bonds. | | -| Local bond reset | Holding both buttons for three seconds clears volatile state and all board bonds, disconnects the phone, shows the one-second LED confirmation, and resumes advertising. | | -| Bond-reset failure | Force or instrument `bt_unpair` failure; the board logs the failure, does not show the success LED, and keeps connectable advertising stopped. | | -| Fresh pairing after reset | The old phone bond cannot silently resume; after forgetting it phone-side, a new encrypted bond succeeds. | | -| App revocation | Removing App authorization stops future summaries and answers independently of the board's local bond reset. | | -| Agent isolation | A Claude Code answer can reach only Claude Code; a Codex answer can reach only Codex; both race with phone input through one single-consumption gate. | | +| Case | Expected result | Pass / evidence | +| --- | --- | --- | +| Device Info | All required fields are readable; wire/profile/bounds match the binary. | | +| Optional Device Info | Every advertised button/rotary/display/haptics/battery capability matches the physical unit; absent capabilities create no empty App rows. | | +| Vendor facts | Bounded inert facts render only in the vendor section and cannot replace system-owned rows or create actions. | | +| Account metadata | Name, optional user number, and notes edit and sync without changing immutable hardware identity. | | +| Cross-platform table | iOS and Android show the same ordered continuous key/value information table for the same Device Info. | | +| Encryption | Plain approval write and unencrypted notification subscription are rejected or trigger pairing. | | +| Bond required | A peer that requests an encrypted but non-bonding session is rejected; a completed bond survives reconnect and board reboot. | | +| Pairing failure recovery | A rejected or failed security upgrade disconnects that peer, then the board becomes discoverable again without rebooting. | | +| Unauthorized device | Release App does not connect or disclose a summary before explicit authorization. | | +| Present | One current summary appears and Pending output turns on. | | +| Device Allow | One Allow wins, App continues, matching `answered` clears the device. | | +| Device Deny | One Deny wins, App continues, matching `answered` clears the device. | | +| Phone first | Phone choice wins; device clears; late button press is ignored. | | +| Device first | Device choice wins; later phone tap cannot consume the prompt again. | | +| Repeat | Holding/bouncing the button never changes or double-consumes the first choice. | | +| Retry | Dropping the first notification causes the exact same answer to retry after one second. | | +| Answer fragmentation | At a small negotiated ATT MTU, a maximum-ID Answer arrives as one ordered newline frame with no truncation, duplication, or interleaving. | | +| Replacement | New Present replaces old state; old ID cannot answer. | | +| Expiry | At the TTL boundary, LED clears and a press cannot answer. | | +| Cancellation | Host cancellation clears Pending immediately. | | +| Disconnect | Connection loss clears current request, answer cache, LED, and partial frame. | | +| Reconnect | Device restores no approval; host may re-present with only remaining TTL. | | +| Reboot | Bond may persist; approval and partial frame never persist. | | +| Fragmentation | A valid message split through a multi-byte UTF-8 character decodes once. | | +| Oversize | Input above the advertised limit is discarded through newline and recovery works. | | +| Malformed input | Invalid UTF-8, JSON, version, profile, option, and ID fail closed. | | +| Short reset chord | Pressing both buttons for less than three seconds neither answers nor clears bonds. | | +| Local bond reset | Holding both buttons for three seconds clears volatile state and all board bonds, disconnects the phone, shows the one-second LED confirmation, and resumes advertising. | | +| Bond-reset failure | Force or instrument `bt_unpair` failure; the board logs the failure, does not show the success LED, and keeps connectable advertising stopped. | | +| Fresh pairing after reset | The old phone bond cannot silently resume; after forgetting it phone-side, a new encrypted bond succeeds. | | +| App revocation | Removing App authorization stops future summaries and answers independently of the board's local bond reset. | | +| Agent isolation | A Claude Code answer can reach only Claude Code; a Codex answer can reach only Codex; both race with phone input through one single-consumption gate. | | ## Label decision diff --git a/devices/docs/cases/multipad-first-case.md b/devices/docs/cases/multipad-first-case.md deleted file mode 100644 index 417600d..0000000 --- a/devices/docs/cases/multipad-first-case.md +++ /dev/null @@ -1,147 +0,0 @@ -# 第一个案例:ILX MultiPad 接入 Nexting Devices - -ILX MultiPad 是 Nexting Devices 的第一个完整硬件案例。它不是一块 -Nexting 量产板,而是一块已经开源、已经能作为键盘使用的 STM32F103VET6 -USB HID + CDC 设备。这个案例的目标是说明:如何保留原作者的键盘功能, -只增加一层可验证的 Nexting 设备协议适配。 - -## 这个案例到底交付什么 - -| 层 | 案例交付物 | 证据等级 | -| -------------- | -------------------------------------------------------------------- | ------------------ | -| 上游固件 | `iLx11/multi-pad`,固定到 `78c1ee533a7f513e9f390741c4f5eed1e0aa91b3` | 源码可审计 | -| Nexting 设备核 | `sdk/c` 的固定缓冲 C99 协议、流式拼帧和审批状态机 | Core tested | -| MultiPad 适配 | `firmware/multipad/nexting_multipad_adapter.c/.h` | CMake 合约测试通过 | -| 兼容入口 | 原有 `AA BB xx` 保留,只有换行 JSON 帧进入 Nexting 适配器 | 主机测试通过 | -| 烧录路径 | Module 版走串口路径(仅当看到 SERIAL/BOOT/RESET);FPC 版走 SWD | 由拆机证据决定 | -| Host 边界 | Host 负责授权、Agent 路由和最终回答;固件不保存凭证 | 公开契约 | - -当前公开 SDK 不提供一个可以误称为“已经适配所有版本”的 HEX。上游工程中 -的 `leden.hex` 是原版固件(原作者固件);接入 Nexting 适配器后必须重新构建并单独保存 -新的 HEX。不能把原固件误当作 Nexting 固件烧进去。 - -## 上游工程与版本 - -使用下面两个公开仓库,不要把上位机仓库当成下位机固件: - -- 下位机固件:[iLx11/multi-pad](https://github.com/iLx11/multi-pad) -- 上位机:[iLx11/key-pad-application](https://github.com/iLx11/key-pad-application) - -本案例只接入下位机仓库。上游工程本身已经证明了这些事实: - -- `STM32F103VET6`、Cortex-M3; -- USB HID + USB CDC composite; -- 2 × 4 矩阵按键和 3 个旋钮模块; -- `PA13 = SWDIO`、`PA14 = SWCLK`; -- `PA9/PA10 = USART1_TX/RX`; -- 工程没有 DFU、IAP 或应用侧 USB bootloader; -- 上游许可证为 GPL-3.0。 - -烧录配置在上游的 `Config/stlink.cfg`,不是 Nexting App 的配置。普通 Type-C -枚举出来的 CDC 端口只能证明应用正在运行,不能证明它已经进入 bootloader。 -Type-C 不能直接烧录,除非先确认对应的 bootloader 路径。 - -## 第一个可运行映射 - -这个案例只承诺一个小而明确的映射,不把未来能力提前写成已支持: - -| MultiPad 控件 | Nexting 公共接口 | 案例行为 | -| ----------------- | ---------------------------------- | ----------------------------------------------- | -| 第一个矩阵键 | `approval/1` → Allow | 批准当前唯一待决请求 | -| 第二个矩阵键 | `approval/1` → Deny | 拒绝当前唯一待决请求 | -| 现有 CDC 接收回调 | `nexting_multipad_receive()` | 接收 `present`、`resolved`、`status` | -| 现有 CDC 发送函数 | `nexting_multipad_write_frame()` | 回传 `answer` | -| 板上屏幕/指示器 | `render_approval`、`render_status` | 只渲染回调收到的状态 | -| 其他按键和旋钮 | 保留原厂行为 | 等 `keys/1`、`rotary/1` 等 profile 发布后再映射 | - -适配器不会解析 Agent 会话,也不会把 Claude Code、Codex、账户或云地址写入 -设备。显示摘要、倒计时和状态的具体排版属于板级回调,不属于协议核。 - -## 从上游源码到案例固件 - -### 1. 固定上游源码并先构建原版 - -```sh -git clone https://github.com/iLx11/multi-pad.git -cd multi-pad -git checkout 78c1ee533a7f513e9f390741c4f5eed1e0aa91b3 - -# 上游工程要求 arm-none-eabi-gcc、CMake 和 STM32 工具链 -cmake -S . -B build -cmake --build build -sha256sum build/leden.hex -``` - -先保存这个原版产物和哈希。它只用于恢复原厂功能,不能标记为 Nexting 固件。 - -### 2. 加入公开 Nexting 源码 - -从 `Nexting-ai/nexting/devices/` 取下面的公开文件,复制到上游工程的独立 -目录(例如 `USER/Nexting/`),不要修改公共 SDK 的语义: - -```text -devices/sdk/c/include/nexting_device.h -devices/sdk/c/src/nexting_device.c -devices/firmware/multipad/nexting_multipad_adapter.h -devices/firmware/multipad/nexting_multipad_adapter.c -``` - -把这四个文件加入上游 CMake target,并把 `sdk/c/include` 加入 include path。 -上游 `USER/Usb/usb_user.c` 的 CDC 回调先判断 Nexting 帧,再落回原有分支: - -```c -if (nexting_multipad_accepts(&nexting_adapter, Buf, *Len)) { - (void)nexting_multipad_receive(&nexting_adapter, Buf, *Len); - return USBD_OK; -} - -/* 原有 AA BB CC / AA BB AA / AA BB DD / AA BB EE / AA BB FF 分支继续保留。 */ -``` - -初始化时提供四个板级回调:单调时钟、CDC 发送、审批渲染、状态渲染。主循环 -调用 `nexting_multipad_tick()`;USB 断连调用 -`nexting_multipad_disconnect()`;前两个审批键分别调用 -`nexting_multipad_choose(...ALLOW)` 和 -`nexting_multipad_choose(...DENY)`。回调失败时不要伪造成功 UI。 - -### 3. 构建、备份、烧录 - -重新构建后,对新的 `leden.hex` 计算哈希,并把原版备份、Nexting 版本和日志 -放在不同文件名下。根据硬件版本选择路径: - -| 版本 | 写入方式 | 关键条件 | -| ---------- | -------------------------- | ------------------------------------------ | -| Module PCB | 上游串口路径(待实板确认) | 必须看到 SERIAL、BOOT、RESET 路径 | -| FPC PCB | ST-Link/J-Link SWD | `3V3`、`GND`、`SWDIO(PA13)`、`SWCLK(PA14)` | - -在确认 bootloader 之后,才可以使用本目录的 -`firmware/multipad/tools/flash-multipad.sh`。脚本要求显式的端口、HEX、 -`--confirm --allow-write`,且不会覆盖原始备份。 - -### 4. 一次只验证一个行为 - -1. 先用 `multipad-cdc-smoke.py` 验证原有 `AA BB CC` 回显。 -2. 发送一个 `present`,确认屏幕或指示器只显示当前请求。 -3. 按 Allow,确认收到同一个 `id` 的 `answer`。 -4. 确认 Host 成功后发送 `resolved`,设备清掉待决状态。 -5. 单独测试超时和 USB 断连;两者都必须清空审批和状态,不能留下旧提示。 - -在没有这些逐项证据前,只能写“适配器测试通过”,不能写“MultiPad 真板已 -兼容”或“Type-C 可直接烧录”。 - -## 这个案例与 Nexting App 的关系 - -MultiPad USB 是公开的开发者案例,不是现有 BLE App 自动支持的 USB 变体。 -Host 必须自己拥有 USB 权限、端口选择、授权状态和 Agent 最终回答操作; -设备只接收有界的公共消息。未来如果 Nexting App 开放 USB transport,需要 -另行发布 Host 支持、授权测试和断连策略,不能由这份固件文档推断出来。 - -## 完成定义 - -- 上游工程能在固定 commit 上复现构建; -- 原版 HEX 已备份并有哈希; -- Nexting HEX 由明确的适配源码生成并有哈希; -- `npm run test:multipad`、C99 合约测试和 CDC smoke 通过; -- `present → answer → resolved`、超时、断连分别有记录; -- `Device Info` 只声明实际存在的按键、旋钮、显示和电量能力; -- GitHub README 只链接本案例,不把实验性 USB 路径写成量产承诺。 diff --git a/devices/docs/conformance.md b/devices/docs/conformance.md index cc22568..9dd5449 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. @@ -19,7 +19,6 @@ Run every shared vector and framing/state test for the implementation. The offic ```sh npm run test:reference -swift test --package-path sdk/swift cmake -S sdk/c -B /tmp/nexting-device-c -DNEXTING_DEVICE_SANITIZE=ON cmake --build /tmp/nexting-device-c ctest --test-dir /tmp/nexting-device-c --output-on-failure @@ -67,11 +66,11 @@ The checklist covers encrypted traffic, enrollment, authorization, Allow/Deny, p ## Allowed wording -| Evidence | Allowed public wording | -| -------------- | -------------------------------------------------------------------------------------------------------------------- | -| Protocol only | “Implements Nexting Device Protocol Experimental 0.2” with the tested version | -| Core tested | “Core tested against the Experimental 0.2 vectors” | -| Build verified | “Build verified for XIAO ESP32-C3 target `xiao_esp32c3/esp32c3` on Zephyr 4.3.0 / SDK 0.17.4” | +| Evidence | Allowed public wording | +| --- | --- | +| Protocol only | “Implements Nexting Device Protocol Experimental 0.2” with the tested version | +| Core tested | “Core tested against the Experimental 0.2 vectors” | +| Build verified | “Build verified for XIAO ESP32-C3 target `xiao_esp32c3/esp32c3` on Zephyr 4.3.0 / SDK 0.17.4” | | Board verified | “Board verified,” followed by a link to the dated evidence record that names firmware, App, iPhone, and iOS versions | Do not say “secure,” “certified,” “production ready,” or “Nexting Compatible” from these gates alone. diff --git a/devices/docs/development.md b/devices/docs/development.md index 6add5f9..a9e54f2 100644 --- a/devices/docs/development.md +++ b/devices/docs/development.md @@ -1,16 +1,15 @@ # Development workflow -This guide covers local work on the public Nexting Devices boundary. It does not require the private Nexting App unless you are running the real-iPhone integration path. +This guide covers local work on the public Nexting Devices boundary. The official Nexting App is only needed when you are checking a real-device enrollment path. For architecture and file ownership, start with [the foundation blueprint](foundation-development.md). For discoverable APIs use [the interface catalog](interfaces.md), and for goal-specific work use [the implementation tracks](implementation-tracks.md). ## Prerequisites - Node.js 22 or newer for the JavaScript reference and contract tests. -- Swift 6 with macOS 13 or newer for `NextingDeviceKit` and the simulator. - CMake and a C99 compiler for the portable SDK. - A Zephyr or nRF Connect SDK workspace for reference firmware builds. -- A real iPhone for Bluetooth host verification. The iOS Simulator cannot provide this BLE path. +- A supported phone and the official Nexting App for real-device enrollment. Local protocol and firmware checks do not require either. ## Fast protocol loop @@ -18,16 +17,7 @@ For architecture and file ownership, start with [the foundation blueprint](found npm run test:reference ``` -Current expected result: 33 tests pass, including the private JavaScript package-export contract. When changing protocol behavior, update `SPEC.md` and `protocol/vectors/approval-v1.json` before porting the behavior to Swift or C. - -## Swift SDK - -```sh -swift test --package-path sdk/swift -swift build --package-path sdk/swift -``` - -Current expected result: 41 tests pass and the `NextingDeviceKit` library builds. +Current expected result: 33 tests pass, including the public JavaScript package-export contract. When changing protocol behavior, update `SPEC.md` and `protocol/vectors/approval-v1.json` before porting the behavior to C. ## Portable C99 SDK @@ -68,12 +58,12 @@ npm run check:naming All four reference targets are Build verified in the pinned workflow: -| Board | Target | -| --------------------- | ----------------------------- | -| nRF52840 DK | `nrf52840dk/nrf52840` | -| XIAO nRF52840 / Sense | `xiao_ble/nrf52840/sense` | -| XIAO ESP32-C3 | `xiao_esp32c3/esp32c3` | -| XIAO ESP32-S3 | `xiao_esp32s3/esp32s3/procpu` | +| Board | Target | +| --- | --- | +| nRF52840 DK | `nrf52840dk/nrf52840` | +| XIAO nRF52840 / Sense | `xiao_ble/nrf52840/sense` | +| XIAO ESP32-C3 | `xiao_esp32c3/esp32c3` | +| XIAO ESP32-S3 | `xiao_esp32s3/esp32s3/procpu` | From a configured Zephyr workspace: @@ -95,13 +85,9 @@ The ordinary `.github/workflows/ci.yml` runs language, simulator, and public-bou 1. Change the normative spec if behavior changes. 2. Add a shared vector that fails. 3. Fix JavaScript. -4. Fix Swift and C against the same vector. +4. Fix C against the same vector. 5. Run the sanitizer build. -### Swift host transport - -Add a testable state transition before modifying CoreBluetooth delegate code. Verify authorization, Device Info negotiation, bounded queues, disconnect recovery, and stale-answer behavior. - ### Firmware transport Keep JSON, framing, TTL, and approval state in `sdk/c`. Zephyr code should own only BLE, GPIO, bond lifecycle, timers, and fixed transport buffers. @@ -116,7 +102,6 @@ Before a release candidate: ```sh npm run check -swift test --package-path sdk/swift cmake -S sdk/c -B /tmp/nexting-device-release -DNEXTING_DEVICE_SANITIZE=ON cmake --build /tmp/nexting-device-release ctest --test-dir /tmp/nexting-device-release --output-on-failure diff --git a/devices/docs/first-approval.md b/devices/docs/first-approval.md index 7770ae0..e04a193 100644 --- a/devices/docs/first-approval.md +++ b/devices/docs/first-approval.md @@ -1,20 +1,14 @@ # Run the first hardware approval -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. +This guide explains the device-side approval lifecycle after the public +[Quickstart](../QUICKSTART.md) has proved the board and protocol path. ## Before you start -You need: - -- 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. +You need one supported board and the physical controls declared by its +firmware. The official Nexting App is the Host: it discovers the board, checks +Device Info, authorizes the encrypted link, and maps the result to the selected +Agent. This public package does not ask you to build another Host. For firmware development, `debug-test-device.conf` still provides the reference advertised name. That name helps discovery only; it is never the @@ -24,12 +18,10 @@ from read-only hardware identity. ## Build nRF52840 -From a configured west workspace: +From `nexting/devices`, let the public bootstrap prepare the pinned workspace: ```sh -west build -p always -b xiao_ble/nrf52840/sense \ - /path/to/nexting-devices/firmware/zephyr \ - -- -DEXTRA_CONF_FILE=debug-test-device.conf +./scripts/bootstrap-zephyr.sh --board xiao-nrf52840-sense --build ``` For the nRF52840 DK, replace the board target with `nrf52840dk/nrf52840`. @@ -51,20 +43,19 @@ For XIAO ESP32-S3 use `xiao_esp32s3/esp32s3/procpu`. The public CI is the reprod ## Run the approval -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. +1. Open the official Nexting App and pair the board through its device flow. +2. Confirm the App shows the declared Device Info and negotiated profiles. +3. Trigger a one-time approval from the selected Agent. +4. Press the physical control mapped by your firmware to `allow` or `deny`. +5. Confirm the App resolves the request and the device clears its pending state. Then complete every case in `board-verification.md`. A successful happy path alone is not enough for the `Board verified` label. ## Clear a development bond -1. Hold Allow and Deny together for at least three seconds. A shorter two-button press is ignored and must not answer the approval. +1. Hold the two reset controls together for at least three seconds. A shorter two-button press is ignored and must not answer the approval. 2. Confirm the current approval disappears, the connection closes, and the Pending LED stays on for one second as reset confirmation. -3. On the iPhone, forget the old Bluetooth accessory or remove its App authorization. The board cannot erase the phone's copy of a bond. +3. In the Nexting App, forget the old Bluetooth accessory or remove its authorization. The board cannot erase the phone's copy of a bond. 4. Confirm the board advertises again, then perform a fresh encrypted pairing before sending another approval. The one-second LED is a success signal, not merely an acknowledgement of the button gesture. If the board logs `Bond reset failed`, keeps the LED off, and does not advertise, reboot it and retry the reset before pairing; do not treat the old bond as cleared. @@ -73,8 +64,7 @@ This is a local developer-reference recovery path, not product enrollment or acc ## If discovery does not happen -- Confirm the phone is real hardware and Bluetooth is enabled. -- Confirm the App has Bluetooth permission and the device has been explicitly enrolled. +- Confirm macOS Bluetooth is enabled and the terminal has Bluetooth permission. - 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 ce67372..cb492aa 100644 --- a/devices/docs/foundation-development.md +++ b/devices/docs/foundation-development.md @@ -42,53 +42,44 @@ SPEC.md + protocol/vectors/approval-v1.json | readable JS reference / \ - Swift Host SDK portable C99 core - | | - CoreBluetooth Zephyr adapters - | | - private/third-party reference and - Host products third-party devices + JavaScript reference portable C99 core + | | + protocol tooling Zephyr adapters + | + reference and third-party devices ``` Specifications and vectors flow down into implementations. Platform adapters never become a second protocol source. Public code never imports private App, Mac Bridge, cloud, production-firmware, identity, OTA, or manufacturing files. ## 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 +## Official App boundary -| 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. +The official Nexting App supplies enrollment UI, risk policy, internal prompt +context, Bluetooth transport, and the final Agent action. Those App and Host +implementations are not part of this public repository. Public code describes +the device-facing contract only: the device declares capabilities, receives +bounded state, and emits generic physical intent. ## Device core and adapter files -| File | Owns | -| -------------------------------------------- | ------------------------------------------------------------------------ | -| `sdk/c/include/nexting_device.h` | Portable C ABI, capacities, messages, stream, and approval state | -| `sdk/c/src/nexting_device.c` | Strict codec, fixed-buffer stream, and approval state machine | -| `firmware/zephyr/src/main.c` | BLE/GATT, bonding, bond reset, GPIO, timers, and fixed transport buffers | -| `firmware/zephyr/boards/*.overlay` | Board-specific Allow, Deny, and Pending pin aliases | -| `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 | 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,40 +91,43 @@ 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 | +| Official App behavior | private Nexting App | public SPEC only when the device-facing contract changes | product integration tests | +| Device codec or state | `nexting_device.h` / `nexting_device.c` | vectors and reference behavior | C ASan/UBSan + firmware contract | +| Zephyr BLE, bond, timer, or GPIO | `firmware/zephyr/src/main.c` | board overlay when pins change | firmware contract + affected board builds | +| New chip or RTOS | new platform adapter | hardware support and implementation track | core tests + exact target build | +| Compatibility claim | conformance evidence | project status and changelog | evidence required by the claimed level | -## Beyond Experimental 0.2: the capability roadmap +## Experimental 0.2: the capability set -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. +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. 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 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 | +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 | 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 1c88379..8f9aa78 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,15 +47,6 @@ 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 955e1a7..2b3af2d 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 @@ -35,48 +35,13 @@ Build only the target you own. Espressif targets require `hal_espressif` blobs a ### 3. Wire and use it -Follow [the firmware wiring table](../firmware/zephyr/README.md), flash the generated image, remove any stale phone-side bond, and enroll the device in the Host/App. Trigger one ordinary Allow/Deny request, verify Pending output, press each choice in separate requests, then hold both buttons for three seconds to verify local bond reset. +Follow [the firmware wiring table](../firmware/zephyr/README.md), flash the generated image, remove any stale phone-side bond, and enroll the device in the official Nexting App when developer enrollment is available. Trigger one ordinary Allow/Deny request, verify Pending output, press each choice in separate requests, then hold both buttons for three seconds to verify local bond reset. ### 4. Record the right claim A successful compiler artifact is Build verified. Use [the complete board checklist](board-verification.md) before recording Board verified. -## Track 2: Integrate a Host or App - -Choose this track when an App, desktop Host, or trusted gateway already owns an Agent approval and needs a physical control surface. - -### 1. Add the public package - -Consume `sdk/swift` as a Swift package and import `NextingDeviceKit`. Do not copy Swift files into the product target. - -### 2. Supply product-owned policy - -The Host owns: - -- enrollment and revocation UI; -- a stable authorization decision; -- internal prompt context; -- the minimum public summary; -- the final Agent answer operation; -- risk policy and phone-side UI. - -The SDK owns BLE transport, public request state, TTL, races, dedupe, framing, and fail-closed parsing. - -### 3. Create the transport and coordinator - -Create a `NextingDeviceAuthorizationPolicy` from the product's enrollment store. Pass `authorizationPolicy.isAuthorized` to both `NextingDeviceCentral` and `NextingDeviceRelayCoordinator`. Use the product's real prompt model as `Context`. The coordinator's `answerPrompt` closure must call the product's existing authoritative Agent answer path with that context and the returned `NextingDeviceChoice`. - -The product calls `present(context:summary:ttlMs:)` with its real prompt context, a UTF-8-safe minimum summary, and a TTL from 1 through 300000 milliseconds. It stores the optional request ID returned by that call beside the same internal prompt; a nil result means the public request was rejected and must not be shown as delivered. - -When the Agent operation succeeds, call `answerSucceeded(requestId:)`. When it fails, call `answerFailed(requestId:)`. A phone-first path calls `phoneAnswerStarted()` before the same success/failure completion. Cancellation calls `cancel()`. A monotonic timer calls `tick()` while work is pending. - -Never treat the `answerPrompt` callback as success by itself. The physical choice becomes authoritative only after the existing Agent answer path succeeds. - -### 4. Test without a board - -Run the [macOS BLE simulator](../examples/macos-device-simulator/README.md) with a real iPhone. The iOS Simulator cannot exercise the Bluetooth Host path. - -## Track 3: Port a new MCU or RTOS +## Track 2: Port a new MCU or RTOS Choose this track when your platform is not one of the four Zephyr references. @@ -108,42 +73,17 @@ 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: 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. - -The [first MultiPad case](cases/multipad-first-case.md) is the reproducible -walkthrough for this track. It pins the upstream commit, separates the original -HEX from the Nexting build, maps the first two keys to Allow/Deny, and records -the evidence required before calling the board compatible. - -## 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. +## Track 3: Maintain protocol tooling -Choose this track for Kotlin, Rust, TypeScript, Python, or another Host/device implementation. +Choose this track for Rust, TypeScript, Python, or another protocol +implementation or conformance tool. It does not create an Agent adapter or a +custom App. 1. Load `protocol/vectors/approval-v1.json` directly; do not copy the cases. 2. Make every valid vector decode and canonically re-encode. 3. Make every invalid vector fail closed. 4. Implement the newline-inclusive 4096-byte frame ceiling and bounded oversize discard. -5. Implement the one-prompt relay with monotonic TTL, replacement, duplicate rejection, choice locking, phone/device races, and two-phase Agent completion. +5. Keep product authorization and Agent actions in the official Nexting App; do not add them to the public package. 6. Add the language to public CI. 7. Update [public interfaces](interfaces.md), [conformance](conformance.md), and `CHANGELOG.md`. diff --git a/devices/docs/interfaces.md b/devices/docs/interfaces.md index 917f77c..361bda9 100644 --- a/devices/docs/interfaces.md +++ b/devices/docs/interfaces.md @@ -1,87 +1,83 @@ # Public interfaces -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. +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. ## What is public - one Bluetooth LE GATT service; -- 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; +- nine independently negotiated profiles over one newline-delimited Wire; - a portable fixed-buffer C99 device SDK; -- shared vectors, reference implementations, simulator, firmware, and verification rules. +- a JavaScript protocol reference for conformance and tooling; +- shared vectors, reference firmware, and verification rules. Experimental 0.2 does not expose a Nexting cloud API. There is no public TCP, UDP, HTTP, MQTT, or WebSocket endpoint, no device-to-Agent credential, and no account or session API in this repository. A device communicates with a user's authorized Host/App over BLE — and because the Host may reach the agent anywhere, every compatible device is remote-capable through its Host. +Agent adapters and the official App implementation remain outside this public +package. This repository does not provide an App SDK or an Agent connection +API. + ## 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 | official App: private; device: `firmware/zephyr/src/main.c` | +| Downlink | `6EADC0DE-0002-4A21-9C5E-1B7F3D9E42A0` | Host → device | Present and resolve requests | Encrypted write; with-response is the default | official App: private; device: `main.c` + C stream | +| Uplink | `6EADC0DE-0003-4A21-9C5E-1B7F3D9E42A0` | device → Host | Report physical answers or errors | Encrypted notification subscription | device: `main.c`; official App: private | +| Device Info | `6EADC0DE-0004-4A21-9C5E-1B7F3D9E42A0` | device → Host | Negotiate version, profiles, model, firmware, limits, and declared capabilities such as `statusSlots` | Read before approval traffic | device: `main.c`; official App: private | 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 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. +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. ## 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 | - -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` | -| `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. - -Production Hosts must supply enrollment and revocation. Peripheral-name matching is not a release identity mechanism. - -## Kotlin Host SDK - -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 -`approval/1`/`status/1` values and limits. Android owns Bluetooth permission, -GATT lifecycle, encrypted authorization storage, account metadata, and UI. +| Message | Direction | Product meaning | Fail-closed rule | Source and implementations | +| --- | --- | --- | --- | --- | +| `present` | Host → device | Show one bounded Allow/Deny request with a relative TTL | Invalid fields do not change current state | vectors; `reference/js/src/protocol.mjs`; `nexting_device.c` | +| `answer` | device → Host | Report the user's locked Allow or Deny choice | Unknown, stale, expired, unauthorized, or changed choices do not commit | vectors; JS relay test fixture; C state | +| `resolved` | Host → device | End the current request as answered, expired, cancelled, or replaced | A nonmatching ID does not clear another request | vectors; JS reference; C state | +| `error` | either direction | Report a bounded protocol failure when useful | An error never carries authority to approve | vectors; JS reference; C99 core | +| `status` (profile `status/1`) | Host → device | Replace the full rendered state of up to 8 anonymous agent slots (idle, thinking, working, complete, needs_input, error, plus an optional 64-byte label) | A malformed frame is discarded whole; the previous rendered state stays; never touches approval state | `status-v1.json` vectors; JS reference; C99 core | +| `nav_present` / `nav_resolved` (`navigation/1`) | Host → device | Present or close a bounded option list | Invalid lists or cursor values do not alter the current menu | `navigation-v1.json`; JS reference; C99 core | +| `nav_move` / `nav_select` (`navigation/1`) | device → Host | Move a cursor or select an index | Sequence gate rejects replay and reordering; Host validates current request | `navigation-v1.json`; JS reference; C99 core | +| `keymap` / `key_event` (`keys/1`) | both | Render Host-owned key labels/light state and report generic physical key activity | Device never chooses Agent semantics; sequence gate rejects duplicate input | `keys-v1.json`; JS reference; C99 core | +| `rotary_map` / `rotary_event` / `rotary_press` (`rotary/1`) | both | Render a dial label and report relative turns or presses | Bounded relative delta only; no model or session ID crosses the Wire | `rotary-v1.json`; JS reference; C99 core | +| `voice_event` / `voice_state` (`voice/1`) | both | Start, stop, cancel, and acknowledge push-to-talk control | No audio or transcript; capture and transcription remain in the official App | `voice-v1.json`; JS reference; C99 core | +| `text` (`text/1`) | Host → device | Render bounded plain text in a declared display region | No markup or secret payload; malformed content leaves the old display intact | `text-v1.json`; JS reference; C99 core | +| `usage` / `usage_clear` (`usage/1`) | Host → device | Render a model label and bounded counters | Counters are display data, not billing authority | `usage-v1.json`; JS reference; C99 core | +| `config` / `config_result` (`config/1`) | both | Apply a versioned set of bounded preferences and report the result | Atomic: any invalid entry rejects the whole update and preserves current config | `config-v1.json`; JS reference; C99 core | + +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. ## 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. @@ -89,40 +85,42 @@ The caller owns every buffer. The core does not initialize Bluetooth, allocate m ```sh npm run test:reference -swift test --package-path sdk/swift cmake -S sdk/c -B /tmp/nexting-device-c -DNEXTING_DEVICE_SANITIZE=ON cmake --build /tmp/nexting-device-c ctest --test-dir /tmp/nexting-device-c --output-on-failure npm run test:firmware ``` -The JavaScript reference verifies readable wire and relay behavior. Swift verifies Host APIs and transport policy. C sanitizers verify the fixed-buffer device API. The firmware contract verifies the Zephyr adapter's required security and transport structure. Physical behavior still requires the board checklist. +The JavaScript reference verifies readable wire behavior and test-only relay +fixtures. C sanitizers verify the fixed-buffer device API. The firmware +contract verifies the Zephyr adapter's required security and transport +structure. Physical behavior still requires the board checklist. ## Required limits and guarantees -| 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 | +| 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 | ## 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 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. +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. diff --git a/devices/docs/migration-0.1-to-0.2.md b/devices/docs/migration-0.1-to-0.2.md index b01de92..4611c3b 100644 --- a/devices/docs/migration-0.1-to-0.2.md +++ b/devices/docs/migration-0.1-to-0.2.md @@ -3,11 +3,11 @@ Experimental 0.2 keeps wire major `1` and the `approval/1` and `status/1` profiles. Existing 0.1 approval hardware remains protocol-compatible when its required Device Info fields are valid. The migration adds optional metadata and -new Host SDK surfaces; it does not grant new approval authority to a device. +device-side profiles; it does not grant new approval authority to a device. ## Device firmware -1. Change the Device Info `spec` string to `0.2.0-experimental.0`. +1. Change the Device Info `spec` string to `0.2.0-experimental.2`. 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, @@ -20,28 +20,8 @@ new Host SDK surfaces; it does not grant new approval authority to a device. single-consumption answer gate. Do not use `device_id`, advertised name, serial number, or vendor facts as an -authorization secret. Hosts bind authorization to their enrolled peripheral -record and revoke it explicitly. - -## Apple hosts - -- 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. -- Keep account-owned name, user number, and notes separate from immutable - hardware identity. -- Maintain separate Claude Code and Codex adapters. Both may use the shared - transport and claim gate, but one Agent's request must never be answered - through the other's action sink. - -## Android hosts - -Add the Kotlin SDK module in `devices/sdk/kotlin`, then use the same Device Info -row order and visibility rules as Apple hosts. Android's BLE integration must -complete service discovery, a valid bounded Device Info read, encrypted -notification subscription, and explicit user confirmation before persisting -authorization. Store authorization in encrypted platform storage. +authorization secret. The official Nexting App binds authorization to its +enrolled peripheral record and revokes it explicitly. ## User metadata @@ -57,12 +37,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 | +| --- | --- | +| Official App + valid 0.1 device | Approval/status continue; new metadata rows are absent | +| Older App + 0.2 device | Required fields continue; unknown bounded optional fields are ignored | +| Official App + malformed optional vendor block | Core Device Info remains; vendor block is dropped | +| Unsupported wire/profile | Connection fails closed as before | The 0.2 label describes the SDK and Device Info contract. It does not change wire major `1`, self-certify a board, or make production firmware public. diff --git a/devices/docs/multipad-usb.md b/devices/docs/multipad-usb.md deleted file mode 100644 index 8673287..0000000 --- a/devices/docs/multipad-usb.md +++ /dev/null @@ -1,118 +0,0 @@ -# MultiPad USB CDC 开发指南 - -这是一条给已经买到 ILX MultiPad 的开发者路径。它和 BLE 参考板是两种 -不同的传输:MultiPad 先作为普通 USB HID 键盘工作,再通过 USB CDC ACM -通道接收配置和 Nexting JSON。它不直接连接 Claude Code、Codex 或 Nexting -云;Host/App 仍然负责授权、会话和最终动作。 - -如果这是你第一次把公开硬件接入 Nexting,请先读[第一个案例:ILX MultiPad](cases/multipad-first-case.md)。 -本页是硬件路径和风险清单;案例页是从上游源码到新 HEX 的完整开发顺序。 - -## 先知道你手上的硬件是哪一版 - -先看[上游仓库](https://github.com/iLx11/multi-pad)与 -[上游固件](https://github.com/iLx11/key-pad-application),本目录只提供 -Nexting 适配层,不复制上游 GPL 固件。 - -| 事实 | 当前证据 | 对烧录的影响 | -| -------------- | ------------------------------------ | ----------------------------- | -| MCU | 上游工程的 STM32F103VET6 配置 | 使用 STM32 工具链,不使用 UF2 | -| 正常 USB | HID + CDC ACM composite | 普通 Type-C 不等于 bootloader | -| Module PCB | 有串口模块、SERIAL 开关、RESET/BOOT | 可按上游步骤进串口 bootloader | -| FPC PCB | 移除串口和按键 | 需要 SWD/J-Link | -| App bootloader | 上游源码没有 DFU/IAP/应用 bootloader | 烧错后不能指望 USB 自救 | - -上游工程的烧录配置是 `Config/stlink.cfg`:ST-Link 使用 SWD,目标是 STM32F1。 -`leden.ioc` 明确把 `PA13` 配为 `SWDIO`、`PA14` 配为 `SWCLK`,但上游仓库没有 -原理图或焊盘坐标文件,所以不能从仓库臆造物理焊盘顺序。看到焊盘后按丝印或 -万用表确认 `3V3/GND/SWDIO/SWCLK`,不要把 Type-C CDC 当作烧录器。 - -拆机前不要猜显示屏、电池、序列号或开关位置。请先拔掉 Type-C,拆下四个 -背面螺丝,拿起后盖时不要拉扯屏幕排线,并拍下 MCU、PCB 版本、BOOT/RESET -和 SERIAL 标记。本目录的 -`firmware/multipad/nexting-multipad-device-info.template.json` 只声明了 -上游源码能证明的按键/旋钮数量;未知字段保持缺省。 - -## 软件准备 - -```sh -git clone https://github.com/Nexting-ai/nexting.git -cd nexting/devices - -# 先编译与运行不接硬件的 C99 适配器测试 -npm run test:multipad - -# 先看安全检查,不会写设备 -sh firmware/multipad/tools/flash-multipad.sh --help -python3 firmware/multipad/tools/multipad-cdc-smoke.py --help -``` - -`nexting_multipad_adapter.c/.h` 是唯一需要移植到上游工程的薄层。它复用 -`sdk/c` 的 framing、审批 TTL、回答锁定、1 秒重发、resolved 清理、状态全量 -替换和断连清理;它不新增一套 JSON 解析器,也不携带 App/Agent 逻辑。 - -### 接到上游 USB 回调 - -把 adapter 源文件和 `sdk/c/src/nexting_device.c` 加入上游 CMake 工程,在 -`USER/Usb/usb_user.c` 的 CDC 接收入口先保留老协议,再加这一段: - -```c -if (nexting_multipad_accepts(&nexting_adapter, Buf, *Len)) { - (void)nexting_multipad_receive(&nexting_adapter, Buf, *Len); - return USBD_OK; -} -``` - -两个审批键调用 `nexting_multipad_choose()`;系统毫秒节拍调用 -`nexting_multipad_tick()`;USB 断连调用 `nexting_multipad_disconnect()`。显示 -和 USB 写入由你自己的板级回调实现。这样 HID 的键盘功能与原有 `AA BB xx` -配置命令仍然保留,Nexting 帧只在以 `{` 开始的 CDC 流上生效。 - -## 烧录前检查清单 - -1. **备份**:先让设备进入上游支持的串口 bootloader,再读取原始 flash;备份 - 文件必须不存在,脚本不会覆盖旧备份。 -2. **确认路径**:Module PCB 才能走串口;FPC PCB 直接停止,准备 SWD。 -3. **校验固件**:使用与你的适配器构建对应的 Intel HEX,不能把 `.bin`、UF2 - 或未知版本混用。 -4. **预览 CDC**:设备仍能被系统识别为 `MultiPad_Device` 时,先运行: - - ```sh - python3 firmware/multipad/tools/multipad-cdc-smoke.py /dev/cu.usbmodemXXXX - ``` - - 这只发送上游已知的 `AA BB CC` 回显,不写 flash。只有适配器固件已经烧入 - 后,才加 `--present` 发送 Nexting 测试帧。 - -5. **显式写入**:只有确认设备处于串口 bootloader、备份成功、端口和 HEX 都 - 正确时才执行: - - ```sh - sh firmware/multipad/tools/flash-multipad.sh write \ - --port /dev/cu.usbmodemXXXX \ - --hex /path/to/nexting-multipad.hex \ - --confirm --allow-write - ``` - -脚本没有自动搜索端口,也不会替你按 RESET/BOOT;这两个动作必须依据拆机后 -的 PCB 证据完成。这样可以避免把普通 CDC 误当作 bootloader。 - -## App 与 Host 边界 - -本路径完成的是**固件和公开 SDK 适配**。现有 Nexting App 的公开设备路径是 -加密绑定的 BLE;它不会因为你烧入 USB CDC 就自动显示 MultiPad。要让某个 -桌面 Host 或未来 App 使用 USB,需要该 Host 自己拥有 USB 权限、授权、端口 -选择和断连策略,并通过同一套 `approval/1`、`status/1` 向量验证。不要把 USB -端口、Agent 会话 ID、云地址或凭证写进固件。 - -## 完成标准 - -- `npm run test:multipad` 通过; -- 上游工程按其原许可证独立构建; -- 原始 flash 有可恢复备份; -- CDC 回显通过,Nexting `present` 能得到设备 `answer`; -- 断连后审批和状态都清空; -- Device Info 只声明拆机后确实存在的能力。 - -在最后一项证据拿到前,不要在 GitHub README 或 App 中写“已支持 MultiPad”或 -“可直接 Type-C 烧录”。 diff --git a/devices/docs/porting-guide.md b/devices/docs/porting-guide.md index fba195b..4b09a83 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 992d1aa..bfe46c0 100644 --- a/devices/docs/project-status.md +++ b/devices/docs/project-status.md @@ -1,34 +1,34 @@ # Project status -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. +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/`. ## Current evidence -| 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, first-case guide, and contract test | Adapter test passing; board integration and boot-path evidence pending | +| Area | Evidence | Status | +| --- | --- | --- | +| Wire and vectors | Nine negotiated profiles, bounded Device Info 0.2, JSON Schema, valid and hostile vectors | Passing; wire major remains `1` | +| JavaScript reference | Protocol, framing, relay, Device Info, documentation, and export tests | Passing | +| Portable C99 SDK | Fixed-buffer nine-profile protocol, approval/status state, Device Info, ASan/UBSan suites | Passing | +| Cloud metadata | Account-owned custom name, optional number, and notes by stable instance key; owner-only RLS | Route/service tests pass | +| Public export | Allowlisted deterministic `devices/` export, README marker block, SHA-256 manifest, hostile-path/content/symlink tests | Passing | +| nRF52840 DK | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | +| XIAO nRF52840 / Sense | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | +| XIAO ESP32-C3 | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | +| XIAO ESP32-S3 | Pinned Zephyr 4.3.0 / SDK 0.17.4 workflow artifact | Build verified; physical checklist pending | ## 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 so undeclared, replayed, or + out-of-order physical input is discarded. +- Official-App-only microphone capture for push-to-talk: `voice/1` carries control + events and acknowledgement, never audio or transcripts. +- 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. @@ -40,14 +40,13 @@ identify its module/FPC boot path before any write. - Multiple remembered accessories with exactly one active transport lease. - Separate Claude Code and Codex adapters sharing transport primitives and the one-answer race gate without sharing action sinks. -- A Kotlin/JVM SDK and Android integration matching the Apple contract. - A reproducible exporter into the established `Nexting-ai/nexting` repository, so Devices grows the existing project's history and stars instead of splitting them into a second repository. ## Remaining physical evidence -No Android or iPhone BLE device was attached to the 2026-07-27 verification +No Android or iPhone BLE device was attached to the 2026-07-29 verification environment. Therefore: - no real-radio, pairing, reconnect, battery, button, or cross-platform @@ -56,17 +55,17 @@ environment. Therefore: - `board-verification.md` must be completed on exact hardware before that label changes. -This does not block publishing the Experimental SDK, host contracts, Apps, or -build artifacts. It does block any claim that a physical third-party board has +This does not block publishing the Experimental device SDK or build artifacts. +It does block any claim that a physical third-party board has completed the interoperability checklist. ## Public/private boundary -The public repository contains specifications, SDKs, vectors, reference -firmware, simulator, tests, and documentation. It does not contain Nexting App +The public repository contains specifications, the device-side C99 SDK, vectors, +reference firmware, tests, and documentation. It does not contain Nexting App source, Agent session models, account/cloud implementation, production firmware, manufacturing material, credentials, provisioning, OTA signing, or -private Git history. The exporter rejects unknown roots, sensitive paths, +private Host/App SDKs, or private Git history. The exporter rejects unknown roots, sensitive paths, private-boundary content, signing material, and symlinks before replacing only the public `devices/` subtree. @@ -75,8 +74,9 @@ 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; they do not create unversioned - command, rotary-event, voice, text, or configuration profiles. +- Device Info capabilities describe hardware; versioned profile declarations + separately authorize behavior. Static button or dial counts never imply + `keys/1` or `rotary/1`. - 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 new file mode 100644 index 0000000..439a2bd --- /dev/null +++ b/devices/docs/reference-approval-controller.md @@ -0,0 +1,84 @@ +# 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 local 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 device path + +From `nexting/devices`, run the protocol and firmware checks: + +```sh +npm run test:reference +npm run test:firmware +``` + +These checks prove bounded framing, the `approval/1` state machine, and the +reference firmware build without requiring Agent credentials or a custom Host. +When developer-device enrollment is available, use the official Nexting App to +pair the board and perform the same physical interaction. + +## 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 new file mode 100644 index 0000000..d725d46 --- /dev/null +++ b/devices/docs/troubleshooting.md @@ -0,0 +1,56 @@ +# 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 2e81e50..c399218 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). -## Today: profiles `approval/1` and `status/1` +## Available profiles ### 1. Two-button approval pad @@ -37,22 +37,26 @@ 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. -## Roadmap scenarios +## Full control-surface scenarios -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). +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. -| 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 | +| 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 | ## 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 renders. A device with one LED declares `statusSlots: 1`, not 8; a device with no indicator omits the field and receives no status traffic. +- 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. diff --git a/devices/examples/macos-device-simulator/README.md b/devices/examples/macos-device-simulator/README.md deleted file mode 100644 index 3b50059..0000000 --- a/devices/examples/macos-device-simulator/README.md +++ /dev/null @@ -1,22 +0,0 @@ -# macOS device simulator - -Use a Mac as a real Bluetooth Low Energy peripheral when you do not have a development board nearby. The simulator advertises the complete Nexting Device Protocol Experimental 0.2 GATT service, including Device Info, and accepts the same fragmented downlink writes as hardware. - -## Build and run - -```sh -swiftc -warnings-as-errors -o /tmp/nexting-device-sim \ - main.swift ../../sdk/swift/Sources/NextingDeviceKit/Protocol.swift -/tmp/nexting-device-sim -``` - -macOS may ask for Bluetooth permission for your terminal. A real iPhone is required for the host side because the iOS Simulator does not provide CoreBluetooth device access. - -When the Nexting App presents an approval, the terminal prints the summary: - -- press `a` for Allow; -- press `d` for Deny. - -The first valid choice is locked, sent as lowercase protocol data, and retried once per second until the App resolves it or its TTL expires. Disconnecting clears all messages and partial frames. Nothing is persisted. - -This tool is for development, demos, and interoperability testing. It is not a production security device. diff --git a/devices/examples/macos-device-simulator/main.swift b/devices/examples/macos-device-simulator/main.swift deleted file mode 100644 index e664835..0000000 --- a/devices/examples/macos-device-simulator/main.swift +++ /dev/null @@ -1,367 +0,0 @@ -import Foundation -@preconcurrency import CoreBluetooth - -private let serviceUUID = CBUUID(string: "6EADC0DE-0001-4A21-9C5E-1B7F3D9E42A0") -private let downlinkUUID = CBUUID(string: "6EADC0DE-0002-4A21-9C5E-1B7F3D9E42A0") -private let uplinkUUID = CBUUID(string: "6EADC0DE-0003-4A21-9C5E-1B7F3D9E42A0") -private let deviceInfoUUID = CBUUID(string: "6EADC0DE-0004-4A21-9C5E-1B7F3D9E42A0") - -private let maximumMessageBytes = 4096 -private let answerRetryInterval = 1.0 -private let deviceInfoData = Data( - "{\"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 -) - -private struct PendingApproval { - let requestID: String - let summary: String - let deadlineMs: Int64 - var lockedChoice: NextingDeviceChoice? -} - -private func log(_ message: String) { - print(message) - fflush(stdout) -} - -private func monotonicNowMs() -> Int64 { - Int64((ProcessInfo.processInfo.systemUptime * 1_000).rounded(.down)) -} - -private func answerWire(requestID: String, choice: NextingDeviceChoice) -> Data? { - NextingDeviceCodec.encode(.answer(requestId: requestID, choice: choice)) -} - -private final class DeviceSimulator: NSObject, CBPeripheralManagerDelegate { - private var manager: CBPeripheralManager! - private var uplink: CBMutableCharacteristic? - private var subscribedCentral: CBCentral? - private var downlinkBuffers: [UUID: Data] = [:] - private var discardingOversize: Set = [] - private var pending: PendingApproval? - private var retryTimer: Timer? - private var outboundFrame: Data? - private var outboundFrameOffset = 0 - private var outboundIsRetry = false - - override init() { - super.init() - manager = CBPeripheralManager(delegate: self, queue: .main) - } - - func peripheralManagerDidUpdateState(_ peripheral: CBPeripheralManager) { - switch peripheral.state { - case .poweredOn: - publishService() - case .poweredOff: - resetConnectionState() - log("Bluetooth is off.") - case .unauthorized: - resetConnectionState() - log("Bluetooth access is not authorized. Allow your terminal in System Settings → Privacy & Security → Bluetooth.") - default: - log("Waiting for Bluetooth (state \(peripheral.state.rawValue))…") - } - } - - private func publishService() { - let downlink = CBMutableCharacteristic( - type: downlinkUUID, - properties: [.write, .writeWithoutResponse], - value: nil, - permissions: [.writeable, .writeEncryptionRequired] - ) - uplink = CBMutableCharacteristic( - type: uplinkUUID, - properties: [.notify, .notifyEncryptionRequired], - value: nil, - permissions: [] - ) - let deviceInfo = CBMutableCharacteristic( - type: deviceInfoUUID, - properties: [.read], - value: deviceInfoData, - permissions: [.readable] - ) - let service = CBMutableService(type: serviceUUID, primary: true) - service.characteristics = [downlink, uplink!, deviceInfo] - manager.removeAllServices() - manager.add(service) - } - - func peripheralManager(_ peripheral: CBPeripheralManager, - didAdd service: CBService, - error: Error?) { - guard error == nil else { - log("Could not publish the GATT service: \(error!.localizedDescription)") - return - } - peripheral.startAdvertising([ - CBAdvertisementDataServiceUUIDsKey: [serviceUUID], - CBAdvertisementDataLocalNameKey: "Nexting-Device-Sim", - ]) - log("Advertising Nexting Device Protocol Experimental 0.2 as Nexting-Device-Sim.") - log("After the App presents an approval, press a for Allow or d for Deny.") - } - - func peripheralManager(_ peripheral: CBPeripheralManager, - central: CBCentral, - didSubscribeTo characteristic: CBCharacteristic) { - guard characteristic.uuid == uplinkUUID else { return } - if subscribedCentral?.identifier != central.identifier { - resetConnectionState() - } - subscribedCentral = central - downlinkBuffers[central.identifier] = Data() - log("Nexting App subscribed to the answer channel.") - } - - func peripheralManager(_ peripheral: CBPeripheralManager, - central: CBCentral, - didUnsubscribeFrom characteristic: CBCharacteristic) { - guard characteristic.uuid == uplinkUUID else { return } - resetConnectionState(for: central) - log("Nexting App disconnected; volatile approval state was cleared.") - } - - func peripheralManager(_ peripheral: CBPeripheralManager, - didReceiveWrite requests: [CBATTRequest]) { - for request in requests { - guard request.characteristic.uuid == downlinkUUID else { - peripheral.respond(to: request, withResult: .requestNotSupported) - continue - } - guard let subscribedCentral, - subscribedCentral.identifier == request.central.identifier else { - peripheral.respond(to: request, withResult: .insufficientAuthorization) - continue - } - guard let value = request.value else { - peripheral.respond(to: request, withResult: .invalidAttributeValueLength) - continue - } - consumeDownlink(value, from: request.central) - peripheral.respond(to: request, withResult: .success) - } - } - - private func consumeDownlink(_ data: Data, from central: CBCentral) { - let identifier = central.identifier - var incoming = data - var buffer = downlinkBuffers[central.identifier] ?? Data() - - while !incoming.isEmpty { - if discardingOversize.contains(identifier) { - guard let newlineIndex = incoming.firstIndex(of: 0x0A) else { return } - incoming.removeSubrange(incoming.startIndex ... newlineIndex) - discardingOversize.remove(identifier) - continue - } - - if let newlineIndex = incoming.firstIndex(of: 0x0A) { - let fragment = incoming[..= maximumMessageBytes { - buffer.removeAll(keepingCapacity: true) - log("Dropped an oversized downlink message.") - } else { - buffer.append(contentsOf: fragment) - let frame = buffer - buffer.removeAll(keepingCapacity: true) - handleDownlinkFrame(frame) - } - incoming.removeSubrange(incoming.startIndex ... newlineIndex) - } else { - if buffer.count + incoming.count >= maximumMessageBytes { - buffer.removeAll(keepingCapacity: true) - discardingOversize.insert(identifier) - log("Dropping downlink bytes until the next frame boundary.") - } else { - buffer.append(incoming) - } - incoming.removeAll() - } - } - downlinkBuffers[central.identifier] = buffer - } - - private func handleDownlinkFrame(_ frame: Data) { - guard let message = NextingDeviceCodec.decode(frame) else { - log("Ignored an invalid downlink message.") - return - } - switch message { - case let .present(requestID, summary, ttlMilliseconds): - if let previous = pending { - log("Replacing local request \(previous.requestID.prefix(8)) with \(requestID.prefix(8)).") - } - retryTimer?.invalidate() - retryTimer = nil - clearOutbound() - pending = PendingApproval( - requestID: requestID, - summary: summary, - deadlineMs: monotonicNowMs() + Int64(ttlMilliseconds), - lockedChoice: nil - ) - log("🔴 \(summary) [id \(requestID.prefix(8))…] — press a/d") - case let .resolved(requestID, reason): - guard pending?.requestID == requestID else { - log("Ignored resolution for an unknown request.") - return - } - clearPending() - log("⚫️ Request resolved: \(reason.rawValue).") - default: - log("Ignored a downlink message type that devices do not consume.") - } - } - - func handleKey(_ character: Character) { - let choice: NextingDeviceChoice - switch character { - case "a", "A": choice = .allow - case "d", "D": choice = .deny - default: return - } - guard var approval = pending, approval.deadlineMs > monotonicNowMs() else { - clearPending() - log("No live approval is waiting; key ignored.") - return - } - guard approval.lockedChoice == nil else { - log("An answer is already waiting for host resolution; key ignored.") - return - } - approval.lockedChoice = choice - pending = approval - startLockedAnswer(isRetry: false) - } - - @objc private func retryLockedAnswer() { - guard let approval = pending, approval.deadlineMs > monotonicNowMs() else { - clearPending() - log("Local approval TTL expired.") - return - } - guard outboundFrame == nil else { return } - startLockedAnswer(isRetry: true) - } - - private func startLockedAnswer(isRetry: Bool) { - guard let approval = pending, - let choice = approval.lockedChoice, - subscribedCentral != nil, - uplink != nil, - outboundFrame == nil - else { return } - retryTimer?.invalidate() - retryTimer = nil - guard let wire = answerWire(requestID: approval.requestID, choice: choice) else { - clearPending() - return - } - outboundFrame = wire - outboundFrameOffset = 0 - outboundIsRetry = isRetry - flushOutboundFrame() - } - - private func flushOutboundFrame() { - guard let approval = pending, approval.deadlineMs > monotonicNowMs() else { - clearPending() - log("Local approval TTL expired before its answer was sent.") - return - } - guard let frame = outboundFrame, - let uplink, - let central = subscribedCentral - else { return } - - let maximumChunkBytes = central.maximumUpdateValueLength - guard maximumChunkBytes > 0 else { - log("Central reported an invalid notification limit; answer remains queued.") - return - } - - while outboundFrameOffset < frame.count { - let chunkEnd = min(outboundFrameOffset + maximumChunkBytes, frame.count) - let chunk = frame.subdata(in: outboundFrameOffset ..< chunkEnd) - guard manager.updateValue( - chunk, - for: uplink, - onSubscribedCentrals: [central] - ) else { - log("Answer notification queue is full; waiting to resume.") - return - } - outboundFrameOffset = chunkEnd - } - - let wasRetry = outboundIsRetry - let requestID = pending?.requestID - let choice = pending?.lockedChoice - outboundFrame = nil - outboundFrameOffset = 0 - outboundIsRetry = false - if let requestID, let choice { - log("\(wasRetry ? "Retried" : "Sent") \(choice.rawValue) for \(requestID.prefix(8))…") - retryTimer = Timer.scheduledTimer( - timeInterval: answerRetryInterval, - target: self, - selector: #selector(retryLockedAnswer), - userInfo: nil, - repeats: false - ) - } - } - - func peripheralManagerIsReady(toUpdateSubscribers peripheral: CBPeripheralManager) { - flushOutboundFrame() - } - - private func clearPending() { - retryTimer?.invalidate() - retryTimer = nil - pending = nil - clearOutbound() - } - - private func clearOutbound() { - outboundFrame = nil - outboundFrameOffset = 0 - outboundIsRetry = false - } - - private func resetConnectionState(for central: CBCentral? = nil) { - if let central { - downlinkBuffers.removeValue(forKey: central.identifier) - discardingOversize.remove(central.identifier) - if subscribedCentral?.identifier == central.identifier { - subscribedCentral = nil - clearPending() - } - } else { - subscribedCentral = nil - downlinkBuffers.removeAll() - discardingOversize.removeAll() - clearPending() - } - } -} - -private let simulator = DeviceSimulator() -private let standardInput = DispatchSource.makeReadSource( - fileDescriptor: FileHandle.standardInput.fileDescriptor, - queue: .main -) -standardInput.setEventHandler { - let data = FileHandle.standardInput.availableData - for byte in data { - simulator.handleKey(Character(UnicodeScalar(byte))) - } -} -standardInput.resume() - -log("Starting the Nexting device simulator…") -RunLoop.main.run() diff --git a/devices/firmware/multipad/CMakeLists.txt b/devices/firmware/multipad/CMakeLists.txt deleted file mode 100644 index 6e6262e..0000000 --- a/devices/firmware/multipad/CMakeLists.txt +++ /dev/null @@ -1,25 +0,0 @@ -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 deleted file mode 100644 index 5b6a933..0000000 --- a/devices/firmware/multipad/README.md +++ /dev/null @@ -1,111 +0,0 @@ -# ILX MultiPad USB CDC adapter - -This directory is the public, source-first integration layer for the open-source -[ILX MultiPad](https://github.com/iLx11/multi-pad). It is intentionally **not** -a copy of the vendor repository and it is not Nexting production firmware. - -The [first-case walkthrough](../../docs/cases/multipad-first-case.md) is the -canonical path for applying this adapter to the pinned upstream firmware. This -directory supplies the public protocol binding and tests; the upstream project -supplies the board build and remains separately licensed. - -## What is verified before opening the enclosure - -The upstream application is an STM32F103VET6 composite USB device: - -- USB HID (`STM32 HID CUSTOM`) for the normal keyboard surface; -- USB CDC ACM (`STM32 CDC ACM0`) for the configuration channel; -- 8 matrix keys (2 rows × 4 columns) and 3 rotary encoders in the upstream - source; -- no DFU, IAP, or application-side bootloader path in the upstream source; -- upstream reference commit `78c1ee533a7f513e9f390741c4f5eed1e0aa91b3`; -- upstream license: GPL-3.0. - -The purchased unit still needs one physical check before a write: whether it is -the **module PCB** (USB serial switch plus BOOT/RESET buttons) or the **FPC -PCB** (SWD/J-Link is required). Do not assume the external Type-C connector is -a bootloader. The included `nexting-multipad-device-info.template.json` keeps -unknown display, serial, and battery fields absent until that check is done. - -The upstream repository exposes firmware facts, not a PCB drawing. Its -`Config/stlink.cfg` selects ST-Link/SWD and its `leden.ioc` maps `PA13` to -`SWDIO` and `PA14` to `SWCLK`; physical pad order must be read from the actual -board before attaching a probe. - -## What this adapter does - -`nexting_multipad_adapter.c` reuses the public portable C99 SDK. It adds no -private App or Agent code. The board port supplies five things: - -1. a monotonic millisecond clock; -2. a USB CDC write function; -3. approval rendering (the two small displays, LEDs, or keys); -4. status rendering (up to the number of displays/indicators actually wired); -5. a call from the CDC receive callback. - -The adapter accepts newline-terminated Nexting JSON frames and leaves the -upstream `AA BB xx` legacy commands untouched. It owns stream framing, approval -expiry, answer retry, choice locking, resolved cleanup, status replacement, and -disconnect cleanup by delegating to `sdk/c`. It never receives a credential or -an Agent session identifier. - -Build the host-side contract test without an STM32 toolchain: - -```sh -cmake -S firmware/multipad -B firmware/multipad/.build -cmake --build firmware/multipad/.build --parallel -ctest --test-dir firmware/multipad/.build --output-on-failure -``` - -## Integrating with the upstream firmware - -Do not replace `USER/Usb/usb_user.c` wholesale. Add the adapter to the upstream -project and route only frames for which -`nexting_multipad_accepts(&adapter, Buf, *Len)` is true. Keep the existing -`AA BB CC`, `AA BB AA`, `AA BB DD`, `AA BB EE`, and `AA BB FF` branches for the -vendor configuration UI. A minimal receive hook is: - -```c -if (nexting_multipad_accepts(&nexting_adapter, Buf, *Len)) { - (void)nexting_multipad_receive(&nexting_adapter, Buf, *Len); - return USBD_OK; -} -``` - -Call `nexting_multipad_choose(&nexting_adapter, NEXTING_DEVICE_CHOICE_ALLOW)` -or `..._DENY` from the two approval keys, call -`nexting_multipad_tick()` from the existing millisecond loop, and call -`nexting_multipad_disconnect()` from the USB disconnect callback. The board -callbacks should render only the state they receive; they must not implement a -second JSON parser. - -The adapter is a protocol binding, not App enrollment. The public Nexting App -BLE path does not claim USB CDC enrollment. A Host that owns the USB permission -and authorization policy can use this binding over a local CDC port; a future -App USB transport must be released separately with its own Host tests. - -## Flash preparation (do not write yet) - -The checked-in upstream artifact is `leden.hex` from the commit above. Before -writing anything: - -1. open the back and photograph the PCB, MCU marking, and BOOT/RESET/SERIAL - switch labels; -2. save the original firmware with the vendor's supported tool; -3. confirm that the unit has the module serial path, or attach an ST-Link/J-Link - to the SWD pads; -4. run the dry checks in `tools/flash-multipad.sh --help` and the CDC smoke - check in `tools/multipad-cdc-smoke.py`; -5. only then use `--confirm --allow-write` with an explicit artifact path. - -There is no recovery promise over ordinary USB HID/CDC. If the board variant -does not expose the serial bootloader, stop and use SWD. A failed application -flash can otherwise leave the keyboard working as HID but unable to accept the -next write. - -## License and boundary - -The adapter and tests in this directory follow the Nexting Devices repository -license. The upstream MultiPad application remains GPL-3.0 and is not vendored -here. This directory contains no Nexting App, cloud, Agent bridge, production -firmware, signing key, or manufacturing file. diff --git a/devices/firmware/multipad/bringup-status.md b/devices/firmware/multipad/bringup-status.md deleted file mode 100644 index 1175306..0000000 --- a/devices/firmware/multipad/bringup-status.md +++ /dev/null @@ -1,40 +0,0 @@ -# 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 deleted file mode 100644 index 77efd4a..0000000 --- a/devices/firmware/multipad/hw-review-findings.md +++ /dev/null @@ -1,16 +0,0 @@ -# MultiPad hardware review findings - -Last updated: 2026-07-31 - -## Findings - -| ID | Finding | Impact | Next action | -| ----- | ---------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------- | -------------------------------------------------- | -| MP-01 | The outside photo does not identify Module PCB vs FPC PCB. | Type-C serial flashing cannot be selected safely. | Open the enclosure and photograph the PCB labels. | -| MP-02 | Upstream source contains USB HID + CDC application code but no DFU/IAP path. | Ordinary CDC enumeration cannot be used as recovery evidence. | Use the module serial boot sequence or SWD/J-Link. | -| MP-03 | Upstream source exposes matrix and encoder modules, but the purchased unit's exact display/connector revision is unverified. | Device Info must not claim battery/display details yet. | Map traces and connectors after opening. | -| MP-04 | The live device echoed `AA BB CC` over CDC before any write. | Confirms the application CDC endpoint, not a bootloader. | Keep this as the baseline regression check. | - -No electrical anomaly has been observed because the enclosure has not been -opened. Any mismatch with the source or schematic must be added here before -continuing. diff --git a/devices/firmware/multipad/nexting-multipad-device-info.template.json b/devices/firmware/multipad/nexting-multipad-device-info.template.json deleted file mode 100644 index 6dda37b..0000000 --- a/devices/firmware/multipad/nexting-multipad-device-info.template.json +++ /dev/null @@ -1,32 +0,0 @@ -{ - "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 deleted file mode 100644 index a89bf35..0000000 --- a/devices/firmware/multipad/nexting_multipad_adapter.c +++ /dev/null @@ -1,152 +0,0 @@ -#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 deleted file mode 100644 index a878568..0000000 --- a/devices/firmware/multipad/nexting_multipad_adapter.h +++ /dev/null @@ -1,72 +0,0 @@ -#ifndef NEXTING_MULTIPAD_ADAPTER_H -#define NEXTING_MULTIPAD_ADAPTER_H - -#include -#include -#include - -#include "nexting_device.h" - -#ifdef __cplusplus -extern "C" { -#endif - -/* - * The ILX MultiPad application speaks USB CDC. This adapter deliberately - * leaves USB, GPIO, displays, and clocks to the board port and only connects - * them to the portable Nexting C99 state machine. - */ -#define NEXTING_MULTIPAD_STREAM_CAPACITY \ - (NEXTING_DEVICE_DEFAULT_MAX_MESSAGE_BYTES + 1U) - -typedef uint64_t (*nexting_multipad_now_ms_fn)(void *context); -typedef void (*nexting_multipad_write_frame_fn)(const uint8_t *bytes, - size_t length, void *context); -typedef void (*nexting_multipad_render_approval_fn)( - const nexting_device_state_t *state, void *context); -typedef void (*nexting_multipad_render_status_fn)( - const nexting_device_status_state_t *state, void *context); - -typedef struct { - nexting_device_state_t approval; - nexting_device_status_state_t status; - nexting_device_stream_t stream; - uint8_t stream_storage[NEXTING_MULTIPAD_STREAM_CAPACITY]; - bool frame_active; - nexting_multipad_now_ms_fn now_ms; - nexting_multipad_write_frame_fn write_frame; - nexting_multipad_render_approval_fn render_approval; - nexting_multipad_render_status_fn render_status; - void *context; -} nexting_multipad_t; - -void nexting_multipad_init(nexting_multipad_t *adapter, - nexting_multipad_now_ms_fn now_ms, - nexting_multipad_write_frame_fn write_frame, - nexting_multipad_render_approval_fn render_approval, - nexting_multipad_render_status_fn render_status, - void *context); - -/* - * Return true only for a JSON frame or a continuation of one. The upstream - * MultiPad firmware retains its legacy AA BB xx commands; the USB callback - * can use this gate before handing a buffer to nexting_multipad_receive(). - */ -bool nexting_multipad_accepts(const nexting_multipad_t *adapter, - const uint8_t *bytes, size_t length); - -nexting_device_result_t nexting_multipad_receive(nexting_multipad_t *adapter, - const uint8_t *bytes, - size_t length); - -nexting_device_result_t nexting_multipad_choose( - nexting_multipad_t *adapter, nexting_device_choice_t choice); - -void nexting_multipad_tick(nexting_multipad_t *adapter); -void nexting_multipad_disconnect(nexting_multipad_t *adapter); - -#ifdef __cplusplus -} -#endif - -#endif diff --git a/devices/firmware/multipad/pin-function-map.md b/devices/firmware/multipad/pin-function-map.md deleted file mode 100644 index c757d0f..0000000 --- a/devices/firmware/multipad/pin-function-map.md +++ /dev/null @@ -1,43 +0,0 @@ -# MultiPad pin-function map - -Last updated: 2026-07-31 - -This map records only literal evidence from the upstream STM32 source. A blank -or `pending` cell is intentional; do not fill it from memory or from a similar -board. The Nexting adapter itself is pin-agnostic and does not add a pin claim. - -## Pin mappings visible in upstream source - -| Pin | MCU function | Hardware connection | Function | Driver/source | Verification | Notes | -| ----- | ------------ | ------------------- | ---------------------- | ---------------------- | ------------------- | --------------------------------- | -| PB10 | GPIO output | matrix row 0 | key matrix row | upstream `KEY` module | ⏳ physical pending | source-level only | -| PB11 | GPIO output | matrix row 1 | key matrix row | upstream `KEY` module | ⏳ physical pending | source-level only | -| PE12 | GPIO input | matrix column 0 | key matrix column | upstream `KEY` module | ⏳ physical pending | source-level only | -| PE13 | GPIO input | matrix column 1 | key matrix column | upstream `KEY` module | ⏳ physical pending | source-level only | -| PE14 | GPIO input | matrix column 2 | key matrix column | upstream `KEY` module | ⏳ physical pending | source-level only | -| PE15 | GPIO input | matrix column 3 | key matrix column | upstream `KEY` module | ⏳ physical pending | source-level only | -| PA9 | USART1 TX | serial module path | bootloader/UART TX | upstream `USART1` init | ⏳ physical pending | only module PCB exposes this path | -| PA10 | USART1 RX | serial module path | bootloader/UART RX | upstream `USART1` init | ⏳ physical pending | only module PCB exposes this path | -| SWDIO | debug pad | MCU SWD header/pads | recovery/program data | ST-Link/J-Link | ⏳ physical pending | pad location unknown | -| SWCLK | debug pad | MCU SWD header/pads | recovery/program clock | ST-Link/J-Link | ⏳ physical pending | pad location unknown | - -## Functions needing an enclosure photograph - -| Function | Expected source evidence | Physical evidence required | Status | -| ----------------- | ------------------------------------------- | ---------------------------------------- | --------------------- | -| 8 key matrix | 2 rows × 4 columns | trace/connector and switch orientation | ⏳ | -| 3 rotary encoders | `encoder1`, `encoder2`, `encoder3` modules | exact A/B/SW pins and pull-ups | ⏳ | -| Displays | upstream OLED/LCD modules | module/FPC variant, bus pins, dimensions | ⏳ | -| USB HID + CDC | STM32 USB device stack | connector wiring and stable enumeration | ✅ host CDC echo only | -| BOOT/RESET | module PCB documentation | buttons and switch labels | ⏳ | -| Battery | no battery service found in upstream source | battery/charger IC and ADC trace | ⏳ / do not declare | - -## Reverse index - -| Goal | Pins/peripheral | Preconditions | Status | -| --------------------- | ------------------------------- | --------------------------------- | --------------- | -| Preserve keyboard HID | USB device stack | device still enumerates | ✅ before flash | -| Read original flash | PA9/PA10 serial or SWD | confirmed PCB path | ⏳ | -| Nexting CDC frames | USB CDC RX/TX | adapter image + CDC callback hook | ⏳ | -| Approval keys | key matrix + encoder/button map | exact input wiring | ⏳ | -| Status rendering | displays/LEDs | exact display map | ⏳ | diff --git a/devices/firmware/multipad/tests/test_adapter.c b/devices/firmware/multipad/tests/test_adapter.c deleted file mode 100644 index 7889f27..0000000 --- a/devices/firmware/multipad/tests/test_adapter.c +++ /dev/null @@ -1,85 +0,0 @@ -#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 deleted file mode 100755 index 92bdff5..0000000 --- a/devices/firmware/multipad/tools/flash-multipad.sh +++ /dev/null @@ -1,65 +0,0 @@ -#!/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 deleted file mode 100755 index cb419b3..0000000 --- a/devices/firmware/multipad/tools/multipad-cdc-smoke.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/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 a05eee6..f2ad034 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 cc5b801..7c92bda 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.0", + "version": "0.2.0-experimental.2", "private": true, "type": "module", "scripts": { diff --git a/devices/firmware/zephyr/src/main.c b/devices/firmware/zephyr/src/main.c index f555666..d1aa5d7 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.0\"," \ + "{\"protocol\":\"nexting-device\",\"spec\":\"0.2.0-experimental.2\"," \ "\"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 5f23ca5..4a7931e 100644 --- a/devices/firmware/zephyr/tests/firmware-contract.test.mjs +++ b/devices/firmware/zephyr/tests/firmware-contract.test.mjs @@ -2,8 +2,7 @@ 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"); @@ -17,12 +16,8 @@ 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 () => { @@ -33,13 +28,10 @@ test("GATT service publishes the complete encrypted transport", async () => { assert.ok(main.includes(shortID), `missing GATT UUID component ${shortID}`); } assert.match(main, /BT_GATT_PERM_WRITE_ENCRYPT/); - assert.match( - main, - /BT_GATT_PERM_READ_ENCRYPT[\s\S]*BT_GATT_PERM_WRITE_ENCRYPT/, - ); + assert.match(main, /BT_GATT_PERM_READ_ENCRYPT[\s\S]*BT_GATT_PERM_WRITE_ENCRYPT/); assert.match(main, /max_message_bytes/); assert.match(main, /max_summary_bytes/); - assert.match(main, /0\.2\.0-experimental\.0/); + assert.match(main, /0\.2\.0-experimental\.2/); assert.match(main, /button_count/); assert.match(main, /approval_button_count/); assert.match(main, /bt_conn_set_security\([\s\S]*BT_SECURITY_L2/); @@ -60,32 +52,16 @@ 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{"), @@ -102,19 +78,10 @@ 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"), @@ -127,20 +94,11 @@ 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 () => { @@ -148,25 +106,13 @@ 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"), @@ -179,18 +125,9 @@ 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 () => { @@ -214,16 +151,10 @@ 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 93b8478..67cf95e 100644 --- a/devices/package.json +++ b/devices/package.json @@ -1,15 +1,14 @@ { "name": "@nexting-ai/devices", - "version": "0.2.0-experimental.0", + "version": "0.2.0-experimental.2", "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 && npm run test:multipad", + "test": "npm run test:reference && npm run test:scripts && npm run test:firmware", "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 1f7d9ba..e0c1a05 100644 --- a/devices/protocol/vectors/approval-v1.json +++ b/devices/protocol/vectors/approval-v1.json @@ -43,7 +43,10 @@ "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 new file mode 100644 index 0000000..2a20733 --- /dev/null +++ b/devices/protocol/vectors/config-v1.json @@ -0,0 +1,36 @@ +{ + "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 cfa2ac2..0196748 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.0", + "spec": "0.2.0-experimental.2", "wire": 1, "valid": [ { "name": "required core only", - "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}", + "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}", "decoded": { "model": "xiao-nrf52840-ref", "statusSlots": 0, @@ -16,7 +16,7 @@ }, { "name": "complete extensible macropad", - "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\"}]}}", + "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\"}]}}", "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.0\",\"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.2\",\"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,32 +88,28 @@ "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 new file mode 100644 index 0000000..5b0070d --- /dev/null +++ b/devices/protocol/vectors/keys-v1.json @@ -0,0 +1,31 @@ +{ + "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 new file mode 100644 index 0000000..4587bae --- /dev/null +++ b/devices/protocol/vectors/navigation-v1.json @@ -0,0 +1,37 @@ +{ + "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 new file mode 100644 index 0000000..6d83589 --- /dev/null +++ b/devices/protocol/vectors/rotary-v1.json @@ -0,0 +1,30 @@ +{ + "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 new file mode 100644 index 0000000..6533413 --- /dev/null +++ b/devices/protocol/vectors/text-v1.json @@ -0,0 +1,28 @@ +{ + "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 new file mode 100644 index 0000000..0fa735e --- /dev/null +++ b/devices/protocol/vectors/usage-v1.json @@ -0,0 +1,31 @@ +{ + "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 new file mode 100644 index 0000000..68ddc8a --- /dev/null +++ b/devices/protocol/vectors/voice-v1.json @@ -0,0 +1,34 @@ +{ + "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/README.md b/devices/reference/js/README.md index d6ac49b..e16b186 100644 --- a/devices/reference/js/README.md +++ b/devices/reference/js/README.md @@ -2,7 +2,7 @@ This package is the executable, human-readable reference for Nexting Device Protocol Experimental 0.2. Use it to understand the wire contract, generate conformance fixtures, and compare another SDK or firmware port with the canonical behavior. -It is deliberately marked `private`. Experimental 0.2 does not publish this package to npm, and a product should not make its runtime depend on the reference implementation. Product hosts should use `NextingDeviceKit` or implement the public specification; firmware should use the portable C99 core. +It is deliberately marked `private`. Experimental 0.2 does not publish this package to npm, and a product should not make its runtime depend on the reference implementation. Use it for protocol tests, fixture generation, and a portable implementation reference; firmware should use the C99 device core. The official Nexting App owns Bluetooth enrollment, authorization, and Agent actions. ## Explicit entry points diff --git a/devices/reference/js/package.json b/devices/reference/js/package.json index 5240a9a..d0f953e 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.0", + "version": "0.2.0-experimental.2", "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 f958d85..1eddd90 100644 --- a/devices/reference/js/src/device-info.mjs +++ b/devices/reference/js/src/device-info.mjs @@ -8,11 +8,20 @@ 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", @@ -234,8 +243,7 @@ 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; @@ -327,11 +335,7 @@ 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 ac29908..2c051bc 100644 --- a/devices/reference/js/src/framing.mjs +++ b/devices/reference/js/src/framing.mjs @@ -14,9 +14,7 @@ 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 a7b9696..65a98c2 100644 --- a/devices/reference/js/src/protocol.mjs +++ b/devices/reference/js/src/protocol.mjs @@ -4,11 +4,28 @@ 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", @@ -40,6 +57,22 @@ 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(); @@ -55,6 +88,45 @@ 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 @@ -107,16 +179,121 @@ function validStatusAgents(value) { if (!Array.isArray(value) || value.length > MAX_STATUS_AGENTS) return false; const seenSlots = new Set(); for (const agent of value) { - if (!agent || typeof agent !== "object" || Array.isArray(agent)) - return false; + if (!agent || typeof agent !== "object" || Array.isArray(agent)) return false; if (!Number.isInteger(agent.slot) || agent.slot < 0 || agent.slot > 7) { return false; } if (seenSlots.has(agent.slot)) return false; seenSlots.add(agent.slot); if (!STATUS_STATES.includes(agent.state)) return false; - if (agent.label !== undefined && !validStatusLabel(agent.label)) - return false; + if (agent.label !== undefined && !validStatusLabel(agent.label)) return false; + } + return true; +} + +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); } return true; } @@ -137,19 +314,17 @@ 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; } @@ -218,7 +393,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); @@ -235,10 +410,9 @@ function rawObjectIsSafe(text) { seenKnownFields.add(key); } if ( - (key === "v" || key === "ttl") && + canonicalUnsignedFields.has(key) && !hasCanonicalUnsignedIntegerValue(text, end) - ) - return false; + ) return false; expectingTopLevelKey = false; } index = end; @@ -290,8 +464,7 @@ 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", @@ -314,12 +487,8 @@ 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" }; @@ -340,6 +509,240 @@ 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; } @@ -381,13 +784,11 @@ 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, @@ -405,6 +806,241 @@ 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 8b85712..d207184 100644 --- a/devices/reference/js/src/relay.mjs +++ b/devices/reference/js/src/relay.mjs @@ -5,10 +5,7 @@ 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"); } @@ -55,14 +52,12 @@ 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" }; @@ -104,10 +99,7 @@ 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 70e5cb7..4a47e05 100644 --- a/devices/reference/js/test/device-info.test.mjs +++ b/devices/reference/js/test/device-info.test.mjs @@ -2,7 +2,11 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import test from "node:test"; -import { MAX_DEVICE_INFO_BYTES, decodeDeviceInfo } from "../src/protocol.mjs"; +import { + MAX_DEVICE_INFO_BYTES, + decodeDeviceInfo, + supportsProfile, +} from "../src/protocol.mjs"; const vectorFile = JSON.parse( await readFile( @@ -20,27 +24,15 @@ 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); } }); @@ -87,24 +79,41 @@ 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 b7b0594..fe8edfa 100644 --- a/devices/reference/js/test/framing.test.mjs +++ b/devices/reference/js/test/framing.test.mjs @@ -32,10 +32,7 @@ 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 e3924d9..2d5089c 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.0"); - assert.equal(packageJSON.version, "0.2.0-experimental.0"); - assert.match(changelog, /## 0\.2\.0-experimental\.0/); + 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.match(readme, /## Experimental 0\.2/); }); diff --git a/devices/reference/js/test/protocol.test.mjs b/devices/reference/js/test/protocol.test.mjs index 4a9b9d9..2a49120 100644 --- a/devices/reference/js/test/protocol.test.mjs +++ b/devices/reference/js/test/protocol.test.mjs @@ -55,20 +55,14 @@ 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); }); @@ -144,9 +138,7 @@ 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 0a116e9..dfa3fbd 100644 --- a/devices/reference/js/test/relay.test.mjs +++ b/devices/reference/js/test/relay.test.mjs @@ -46,11 +46,7 @@ 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"]); @@ -63,11 +59,7 @@ 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"]); @@ -83,11 +75,7 @@ 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"]); @@ -97,27 +85,15 @@ 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, []); @@ -135,11 +111,7 @@ 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" }, ); }); @@ -200,11 +172,7 @@ 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 0ccf7cd..bf2c4e0 100644 --- a/devices/reference/js/test/status.test.mjs +++ b/devices/reference/js/test/status.test.mjs @@ -41,9 +41,7 @@ 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( @@ -113,7 +111,10 @@ 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 677a182..89ff6f7 100644 --- a/devices/reference/js/test/vectors.test.mjs +++ b/devices/reference/js/test/vectors.test.mjs @@ -4,6 +4,7 @@ import test from "node:test"; import { CHOICES, + decode, ERROR_CODES, MAX_REQUEST_ID_BYTES, MAX_SUMMARY_BYTES, @@ -11,6 +12,34 @@ 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), @@ -26,27 +55,15 @@ 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 5a64b3b..54283e6 100644 --- a/devices/schemas/message.schema.json +++ b/devices/schemas/message.schema.json @@ -7,7 +7,23 @@ { "$ref": "#/$defs/answer" }, { "$ref": "#/$defs/resolved" }, { "$ref": "#/$defs/error" }, - { "$ref": "#/$defs/status" } + { "$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" } ], "$defs": { "deviceInfo": { @@ -257,6 +273,282 @@ } } } + }, + "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 new file mode 100755 index 0000000..5a7e0de --- /dev/null +++ b/devices/scripts/bootstrap-zephyr.sh @@ -0,0 +1,154 @@ +#!/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 new file mode 100644 index 0000000..1ea0e86 --- /dev/null +++ b/devices/scripts/bootstrap-zephyr.test.mjs @@ -0,0 +1,49 @@ +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 8a596bf..8fa98f7 100644 --- a/devices/scripts/check-naming.mjs +++ b/devices/scripts/check-naming.mjs @@ -3,12 +3,7 @@ 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(""), @@ -23,7 +18,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; @@ -35,9 +30,7 @@ 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 6816d97..c205335 100644 --- a/devices/scripts/check-public-boundary.mjs +++ b/devices/scripts/check-public-boundary.mjs @@ -4,12 +4,7 @@ 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("")), @@ -32,11 +27,9 @@ 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; @@ -55,9 +48,7 @@ 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 4d6beb3..ea0cf7d 100644 --- a/devices/scripts/documentation-contract.test.mjs +++ b/devices/scripts/documentation-contract.test.mjs @@ -1,224 +1,188 @@ import assert from "node:assert/strict"; +import { existsSync } from "node:fs"; import { readFile } from "node:fs/promises"; import test from "node:test"; const root = new URL("../", import.meta.url); const read = (path) => readFile(new URL(path, root), "utf8"); -test("foundation guide maps product behavior to real source files", async () => { +test("foundation guide maps device behavior to public source files", async () => { const foundation = await read("docs/foundation-development.md"); - for (const marker of [ "The product boundary", "One approval lifecycle", "Dependency direction", - "Protocol.swift", - "Coordinator.swift", "nexting_device.h", "firmware/zephyr/src/main.c", - "Fail closed", + "Official App boundary", "What to change", "capability declaration", - "capability roadmap", + "capability set", "agent anywhere", "needs-input/error states for LEDs or screens, full replacement, volatile", ]) { assert.match(foundation, new RegExp(marker.replaceAll(".", "\\."))); } - assert.match(foundation, /does not receive Agent credentials/); assert.match(foundation, /does not define a public cloud API/); + assert.doesNotMatch(foundation, /NextingDeviceKit|Protocol\.swift|Coordinator\.swift/); }); -test("interface catalog exposes only the supported public surfaces", async () => { +test("interface catalog exposes only the device-side public surfaces", async () => { const interfaces = await read("docs/interfaces.md"); - for (const suffix of ["0001", "0002", "0003", "0004"]) { assert.match(interfaces, new RegExp("6EADC0DE-" + suffix)); } for (const marker of [ - "present", - "answer", - "resolved", - "error", - "status/1", - "statusSlots", - "publishStatus", - "nexting_device_status_on_message", - "status-v1.json", - "NextingDeviceRelayCoordinator", - "NextingDeviceCentral", - "nexting_device_decode", - "nexting_device_stream_push", - "nexting_device_state_choose", - "reference/js/src/protocol.mjs", - "sdk/swift/Sources/NextingDeviceKit/Central.swift", - "sdk/c/src/nexting_device.c", - "npm run test:reference", - "ctest --test-dir", + "present", "answer", "resolved", "error", "status/1", "statusSlots", + "nexting_device_status_on_message", "status-v1.json", "navigation/1", + "keys/1", "rotary/1", "voice/1", "text/1", "usage/1", "config/1", + "navigation-v1.json", "key_event", "rotary_event", "voice_event", + "config_result", "nexting_device_decode", "nexting_device_stream_push", + "nexting_device_state_choose", "reference/js/src/protocol.mjs", + "sdk/c/src/nexting_device.c", "npm run test:reference", "ctest --test-dir", ]) { assert.match(interfaces, new RegExp(marker)); } - + assert.match(interfaces, /official (?:Nexting )?App/); assert.match(interfaces, /does not expose a Nexting cloud API/); - assert.match( - interfaces, - /no public TCP, UDP, HTTP, MQTT, or WebSocket endpoint/, - ); + assert.doesNotMatch(interfaces, /NextingDeviceKit|NextingDeviceCentral|sdk\/swift|sdk\/kotlin/); }); -test("implementation tracks cover every supported developer route", async () => { - const tracks = await read("docs/implementation-tracks.md"); +test("the public package keeps Agent adapters and custom App SDKs private", async () => { + const [overview, index, interfaces, manifest, availability] = await Promise.all([ + read("README.md"), read("docs/README.md"), read("docs/interfaces.md"), + read("scripts/export-manifest.json"), read("docs/availability.json").then(JSON.parse), + ]); + for (const source of [overview, index, interfaces]) { + assert.match(source, /official (?:Nexting )?App/i); + assert.doesNotMatch(source, /sdk\/swift|sdk\/kotlin|codex-app-server\.md/); + } + assert.doesNotMatch(manifest, /sdk\/swift|sdk\/kotlin|codex-app-server|macos-device-simulator/); + assert.equal(availability.fallback, "firmware-tests"); +}); +test("implementation tracks cover board, MCU, and protocol-tooling routes", async () => { + const tracks = await read("docs/implementation-tracks.md"); for (const marker of [ - "Track 1: Run a reference board", - "Track 2: Integrate a Host or App", - "Track 3: Port a new MCU or RTOS", - "Track 4: Maintain a new language SDK", - "nrf52840dk/nrf52840", - "xiao_ble/nrf52840/sense", - "xiao_esp32c3/esp32c3", - "xiao_esp32s3/esp32s3/procpu", - "answerSucceeded", - "answerFailed", - "nexting_device_state_disconnect", - "approval-v1.json", + "Track 1: Run a reference board", "Track 2: Port a new MCU or RTOS", + "Track 3: Maintain protocol tooling", "nrf52840dk/nrf52840", + "xiao_ble/nrf52840/sense", "xiao_esp32c3/esp32c3", "xiao_esp32s3/esp32s3/procpu", + "nexting_device_state_disconnect", "approval-v1.json", ]) { - assert.ok( - tracks.includes(marker), - "missing implementation marker: " + marker, - ); + assert.ok(tracks.includes(marker), "missing implementation marker: " + marker); } + assert.doesNotMatch(tracks, /Host SDK|Track 4/); }); test("conformance guide keeps evidence levels literal", async () => { const conformance = await read("docs/conformance.md"); - for (const marker of [ - "Protocol conformant", - "Core tested", - "Build verified", - "Board verified", - "Nexting Compatible", - "npm run test:reference", - "swift test --package-path sdk/swift", - "NEXTING_DEVICE_SANITIZE=ON", + "Protocol conformant", "Core tested", "Build verified", "Board verified", + "Nexting Compatible", "npm run test:reference", "NEXTING_DEVICE_SANITIZE=ON", "npm run test:firmware", - ]) { - assert.ok( - conformance.includes(marker), - "missing conformance marker: " + marker, - ); - } - + ]) assert.ok(conformance.includes(marker), "missing conformance marker: " + marker); + assert.doesNotMatch(conformance, /swift test --package-path sdk\/swift/); assert.match(conformance, /Build verified is not Board verified/); - assert.match(conformance, /not available during Experimental 0\.2/); }); test("reader entry points link the public documentation system", async () => { const [overview, index, development] = await Promise.all([ - read("README.md"), - read("docs/README.md"), - read("docs/development.md"), + read("README.md"), read("docs/README.md"), read("docs/development.md"), ]); - for (const link of [ - "docs/foundation-development.md", - "docs/interfaces.md", - "docs/implementation-tracks.md", - "docs/conformance.md", - "docs/use-cases.md", - ]) { - assert.ok(overview.includes(link), "README missing link: " + link); - } - + "docs/foundation-development.md", "docs/interfaces.md", "docs/implementation-tracks.md", + "docs/conformance.md", "docs/use-cases.md", + ]) assert.ok(overview.includes(link), "README missing link: " + link); for (const link of [ - "foundation-development.md", - "interfaces.md", - "implementation-tracks.md", - "conformance.md", - "use-cases.md", - ]) { - assert.ok(index.includes(link), "docs index missing link: " + link); - } - + "foundation-development.md", "interfaces.md", "implementation-tracks.md", + "conformance.md", "use-cases.md", + ]) assert.ok(index.includes(link), "docs index missing link: " + link); assert.match(development, /All four reference targets are Build verified/); - assert.doesNotMatch( - development, - /Espressif targets still require an upstream/, - ); - assert.doesNotMatch(development, /ESP32 builds.*pending/i); + assert.doesNotMatch(development, /swift|kotlin|Host SDK/i); }); -test("the first MultiPad case pins its source and keeps flash claims honest", async () => { - const [caseGuide, multipad] = await Promise.all([ - read("docs/cases/multipad-first-case.md"), - read("docs/multipad-usb.md"), +test("0.2 experimental.2 documents every frozen interaction profile", async () => { + const [overview, changelog, projectStatus, c, js] = await Promise.all([ + read("README.md"), read("CHANGELOG.md"), read("docs/project-status.md"), + read("sdk/c/README.md"), read("reference/js/README.md"), ]); - - for (const marker of [ - "iLx11/multi-pad", - "78c1ee533a7f513e9f390741c4f5eed1e0aa91b3", - "PA13 = SWDIO", - "PA14 = SWCLK", - "nexting_multipad_adapter.c/.h", - "AA BB xx", - "present → answer → resolved", - "Module PCB", - "FPC PCB", - "原版固件", - "Nexting HEX", - ]) { - assert.ok( - caseGuide.includes(marker), - "case guide missing marker: " + marker, - ); + for (const source of [overview, changelog, projectStatus, c]) { + assert.match(source, /0\.2\.0-experimental\.2/); + assert.doesNotMatch(source, /Swift 6|Kotlin SDK|NextingDeviceKit/); } - - assert.match(caseGuide, /不能把原固件误当作 Nexting 固件/); - assert.match(caseGuide, /不能.*直接 Type-C 烧录|Type-C.*bootloader/); - assert.match(multipad, /cases\/multipad-first-case\.md/); -}); - -test("use-case guide maps scenarios to real profiles and limits", async () => { - const useCases = await read("docs/use-cases.md"); - - for (const marker of [ - "approval/1", - "status/1", - "statusSlots", - "conformance.md", - "implementation-tracks.md", - ]) { - assert.ok(useCases.includes(marker), "use cases missing marker: " + marker); + assert.match(js, /Experimental 0\.2/); + for (const profile of ["navigation/1", "keys/1", "rotary/1", "voice/1", "text/1", "usage/1", "config/1"]) { + assert.match(overview, new RegExp(profile.replace("/", "\\/"))); + assert.match(projectStatus, new RegExp(profile.replace("/", "\\/"))); } - assert.match(useCases, /roadmap/i); + assert.match(overview, /Host microphone/i); + assert.match(changelog, /frozen interaction profiles/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"), - ], - ); - - for (const marker of [ - "docs/foundation-development.md", - "docs/interfaces.md", - "docs/implementation-tracks.md", - "docs/conformance.md", - ]) { +test("Agent and maintainer guides use the same public sources", async () => { + const [agents, contributing, c, firmware, porting] = await Promise.all([ + read("AGENTS.md"), read("CONTRIBUTING.md"), read("sdk/c/README.md"), + read("firmware/zephyr/README.md"), read("docs/porting-guide.md"), + ]); + for (const marker of ["docs/foundation-development.md", "docs/interfaces.md", "docs/implementation-tracks.md", "docs/conformance.md"]) { assert.ok(agents.includes(marker), "AGENTS missing source: " + marker); } - assert.ok(contributing.includes("docs/conformance.md")); - for (const component of [swift, c, firmware, porting]) { + for (const component of [c, firmware, porting]) { assert.match(component, /foundation-development\.md/); 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)); + assert.ok(existsSync(availabilityUrl)); + const [overview, index, quickstart, referenceController, availability, packageJson, security, troubleshooting, firstApproval] = await Promise.all([ + read("README.md"), read("docs/README.md"), read("QUICKSTART.md"), + read("docs/reference-approval-controller.md"), read("docs/availability.json").then(JSON.parse), + read("package.json").then(JSON.parse), read("SECURITY.md"), + read("docs/troubleshooting.md"), read("docs/first-approval.md"), + ]); + for (const source of [overview, index]) { + assert.match(source, /QUICKSTART\.md/); + assert.match(source, /troubleshooting\.md/); + } + for (const marker of ["Nexting device", "encrypted BLE", "trusted Host", "Agent integration", "Use a supported first-party Nexting product", "Build with the Nexting SDK", "First remote interaction", "local device proof", "third-party developer-device enrollment"]) { + assert.ok(quickstart.includes(marker), "Quickstart missing marker: " + marker); + } + assert.doesNotMatch(quickstart, /Build your first Nexting device|two-button Nexting device|D0 to GND|D1 to GND/); + for (const marker of ["Build the reference approval controller", "Developer Reference", "approval/1", "XIAO nRF52840", "D0", "D1", "bootstrap-zephyr.sh", "official Nexting App"]) { + assert.ok(referenceController.includes(marker), "reference controller missing marker: " + marker); + } + assert.equal(availability.sdkVersion, packageJson.version); + assert.equal(availability.wireMajor, 1); + assert.equal(availability.fallback, "firmware-tests"); + for (const marker of ["BLE LE Secure Connections", "encrypted GATT", "opaque request ID", "single consumption", "duplicate", "replay", "disconnect", "Just Works", "authenticated application identity", "voice/1", "never carries audio bytes or transcripts"]) { + assert.ok(security.includes(marker), "Security missing marker: " + marker); + } + assert.doesNotMatch(firstApproval, /swift|NextingDeviceKit|Host SDK/i); + assert.match(troubleshooting, /west: unknown command "build"/); + assert.match(troubleshooting, /Bluetooth/); + assert.match(troubleshooting, /Device Info/); +}); + +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 action majors and no Host SDK jobs", async () => { + const [ci, firmware] = await Promise.all([ + read("scripts/public-workflows/nexting-devices-ci.yml"), read("scripts/public-workflows/nexting-devices-firmware.yml"), + ]); + const workflows = `${ci}\n${firmware}`; + for (const marker of ["actions/checkout@v7", "actions/setup-node@v7", "actions/setup-python@v7", "actions/upload-artifact@v7"]) { + assert.ok(workflows.includes(marker), `public workflows missing ${marker}`); + } + assert.doesNotMatch(workflows, /NextingDeviceKit|gradle|swift test|sdk\/kotlin/); +}); diff --git a/devices/scripts/export-manifest.json b/devices/scripts/export-manifest.json index fff0455..17f7b7a 100644 --- a/devices/scripts/export-manifest.json +++ b/devices/scripts/export-manifest.json @@ -8,12 +8,13 @@ "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/cases/multipad-first-case.md", "docs/conformance.md", "docs/development.md", "docs/first-approval.md", @@ -22,12 +23,11 @@ "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", "firmware/zephyr/CMakeLists.txt", "firmware/zephyr/README.md", "firmware/zephyr/boards/xiao_ble.overlay", @@ -38,21 +38,17 @@ "firmware/zephyr/prj.conf", "firmware/zephyr/src/main.c", "firmware/zephyr/tests/firmware-contract.test.mjs", - "firmware/multipad/CMakeLists.txt", - "firmware/multipad/README.md", - "firmware/multipad/bringup-status.md", - "firmware/multipad/hw-review-findings.md", - "firmware/multipad/nexting-multipad-device-info.template.json", - "firmware/multipad/pin-function-map.md", - "firmware/multipad/nexting_multipad_adapter.c", - "firmware/multipad/nexting_multipad_adapter.h", - "firmware/multipad/tests/test_adapter.c", - "firmware/multipad/tools/flash-multipad.sh", - "firmware/multipad/tools/multipad-cdc-smoke.py", "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", @@ -70,6 +66,8 @@ "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", @@ -82,42 +80,22 @@ "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/kotlin/.gitignore", - "sdk/kotlin/README.md", - "sdk/kotlin/build.gradle.kts", - "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/ProtocolTest.kt", - "sdk/swift/Package.swift", - "sdk/swift/README.md", - "sdk/swift/Sources/NextingDeviceKit/Authorization.swift", - "sdk/swift/Sources/NextingDeviceKit/Battery.swift", - "sdk/swift/Sources/NextingDeviceKit/Central.swift", - "sdk/swift/Sources/NextingDeviceKit/Coordinator.swift", - "sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift", - "sdk/swift/Sources/NextingDeviceKit/Framing.swift", - "sdk/swift/Sources/NextingDeviceKit/Protocol.swift", - "sdk/swift/Sources/NextingDeviceKit/Relay.swift", - "sdk/swift/Sources/NextingDeviceKit/State.swift", - "sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift", - "sdk/swift/Tests/NextingDeviceKitTests/IntegrationTests.swift", - "sdk/swift/Tests/NextingDeviceKitTests/RelayTests.swift", - "sdk/swift/Tests/NextingDeviceKitTests/StatusTests.swift", - "sdk/swift/Tests/NextingDeviceKitTests/VectorTests.swift", + "sdk/c/tests/test_interactions.c", "west.yml" ], "optionalStagingFiles": [ ".github/workflows/ci.yml", ".github/workflows/firmware.yml" ], - "optionalGeneratedFiles": ["SHA256SUMS"], + "optionalGeneratedFiles": [ + "SHA256SUMS" + ], "workflowMappings": { "scripts/public-workflows/nexting-devices-ci.yml": ".github/workflows/nexting-devices-ci.yml", "scripts/public-workflows/nexting-devices-firmware.yml": ".github/workflows/nexting-devices-firmware.yml" diff --git a/devices/scripts/export-nexting-devices.mjs b/devices/scripts/export-nexting-devices.mjs index 10def8c..48932e1 100644 --- a/devices/scripts/export-nexting-devices.mjs +++ b/devices/scripts/export-nexting-devices.mjs @@ -11,6 +11,7 @@ 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"; @@ -20,15 +21,12 @@ 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("-")), @@ -50,9 +48,7 @@ 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; } @@ -92,11 +88,9 @@ 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) { @@ -108,8 +102,7 @@ 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) { @@ -132,8 +125,8 @@ function readmeBlock() { ## Build hardware for Nexting -The open hardware protocol, C99 device SDK, Swift and Kotlin host SDKs, -conformance vectors, and reference firmware live in [\`devices/\`](devices/). +The open hardware protocol, C99 device SDK, conformance vectors, and reference +firmware live in [\`devices/\`](devices/). - Start with the [Devices SDK overview](devices/README.md). - Read the [wire protocol](devices/SPEC.md). @@ -154,45 +147,11 @@ 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)}`; } -function rootPackage() { - return `${packageMarker} -// This root package makes the public host SDK installable directly from -// github.com/Nexting-ai/nexting. - -// swift-tools-version: 6.0 -import PackageDescription - -let package = Package( - name: "Nexting", - platforms: [ - .iOS(.v16), - .macOS(.v13), - ], - products: [ - .library(name: "NextingDeviceKit", targets: ["NextingDeviceKit"]), - ], - targets: [ - .target( - name: "NextingDeviceKit", - path: "devices/sdk/swift/Sources/NextingDeviceKit" - ), - .testTarget( - name: "NextingDeviceKitTests", - dependencies: ["NextingDeviceKit"], - path: "devices/sdk/swift/Tests/NextingDeviceKitTests" - ), - ] -) -`; -} - async function sha256(path) { const data = await readFile(path); return createHash("sha256").update(data).digest("hex"); @@ -203,10 +162,9 @@ 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 }); @@ -229,10 +187,7 @@ 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; } } @@ -240,8 +195,7 @@ 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}`); } @@ -253,7 +207,6 @@ async function desiredRootFiles(publicCheckout, output, manifest, source) { : "# Nexting\n"; const readme = mergeReadme(currentReadme); await writeFile(join(output, "README.md"), readme); - await writeFile(join(output, "Package.swift"), rootPackage()); for (const [from, to] of Object.entries(manifest.workflowMappings)) { const destination = join(output, to); await mkdir(dirname(destination), { recursive: true }); @@ -262,18 +215,10 @@ 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", ...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; } @@ -282,18 +227,15 @@ async function installExport(publicCheckout, built, manifest) { const existingPackage = join(publicCheckout, "Package.swift"); if (await exists(existingPackage)) { const content = await readFile(existingPackage, "utf8"); - if (!content.includes(packageMarker) && content !== rootPackage()) { + if (!content.includes(packageMarker)) { throw new Error("refusing to replace an unrelated public Package.swift"); } + await rm(existingPackage, { force: true }); } 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", ...Object.values(manifest.workflowMappings)]) { const from = join(built, path); const to = join(publicCheckout, path); await mkdir(dirname(to), { recursive: true }); @@ -317,15 +259,11 @@ 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); @@ -334,7 +272,10 @@ export async function exportNextingDevices(options) { } } -if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) { +if ( + process.argv[1] + && realpathSync(process.argv[1]) === realpathSync(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 52c3b47..5ceb5ba 100644 --- a/devices/scripts/export-nexting-devices.test.mjs +++ b/devices/scripts/export-nexting-devices.test.mjs @@ -1,32 +1,23 @@ import assert from "node:assert/strict"; -import { - cp, - lstat, - mkdir, - mkdtemp, - readFile, - rm, - symlink, - writeFile, -} from "node:fs/promises"; +import { execFile } from "node:child_process"; +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 }; @@ -36,10 +27,7 @@ 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; } @@ -48,39 +36,66 @@ test("exports a deterministic devices subtree without touching unrelated public const { root, publicCheckout } = await fixture(); try { await exportNextingDevices({ source, publicCheckout, check: false }); + assert.match(await readFile(join(publicCheckout, "README.md"), "utf8"), /Build hardware for Nexting/); + await assert.rejects(readFile(join(publicCheckout, "Package.swift")), { code: "ENOENT" }); + assert.match(await readFile(join(publicCheckout, "devices", "SHA256SUMS"), "utf8"), /SPEC\.md/); assert.match( - await readFile(join(publicCheckout, "README.md"), "utf8"), - /Build hardware for Nexting/, - ); - assert.match( - await readFile(join(publicCheckout, "Package.swift"), "utf8"), - /NextingDeviceKit/, + await readFile( + join(publicCheckout, "devices", "docs", "availability.json"), + "utf8", + ), + /"fallback": "firmware-tests"/, ); assert.match( - await readFile(join(publicCheckout, "devices", "SHA256SUMS"), "utf8"), - /SPEC\.md/, + await readFile( + join( + publicCheckout, + "devices", + "docs", + "reference-approval-controller.md", + ), + "utf8", + ), + /Developer Reference/, ); assert.match( await readFile( join(publicCheckout, ".github", "workflows", "nexting-devices-ci.yml"), "utf8", ), - /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", + /npm run check/, ); + assert.equal(await readFile(join(publicCheckout, "LICENSE"), "utf8"), "existing public license\n"); + assert.equal(await readFile(join(publicCheckout, "unrelated", "keep.txt"), "utf8"), "keep\n"); 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/, + ); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + test("rejects unknown roots and sensitive paths", async () => { const { root, publicCheckout } = await fixture(); try { @@ -91,10 +106,7 @@ 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/, @@ -115,19 +127,13 @@ 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/, @@ -140,20 +146,12 @@ 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 }); } @@ -174,14 +172,8 @@ 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 d403989..dee7f6d 100644 --- a/devices/scripts/public-workflows/nexting-devices-ci.yml +++ b/devices/scripts/public-workflows/nexting-devices-ci.yml @@ -4,12 +4,10 @@ on: pull_request: paths: - "devices/**" - - "Package.swift" - ".github/workflows/nexting-devices-ci.yml" push: paths: - "devices/**" - - "Package.swift" - ".github/workflows/nexting-devices-ci.yml" permissions: @@ -19,38 +17,16 @@ jobs: javascript-and-boundary: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 - - uses: actions/setup-node@v4 + - uses: actions/checkout@v7 + - uses: actions/setup-node@v7 with: node-version: 22 - run: npm run check working-directory: devices - swift: - runs-on: macos-latest - steps: - - uses: actions/checkout@v4 - - run: swift test - - run: >- - swiftc -warnings-as-errors -o /tmp/nexting-device-sim - main.swift ../../sdk/swift/Sources/NextingDeviceKit/Protocol.swift - working-directory: devices/examples/macos-device-simulator - - kotlin: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: 21 - - uses: gradle/actions/setup-gradle@v4 - - run: gradle test - working-directory: devices/sdk/kotlin - c99: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v7 - 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 1b10b3e..cab3880 100644 --- a/devices/scripts/public-workflows/nexting-devices-firmware.yml +++ b/devices/scripts/public-workflows/nexting-devices-firmware.yml @@ -8,6 +8,8 @@ on: - "devices/west.yml" - ".github/workflows/nexting-devices-firmware.yml" push: + tags: + - "devices-v*" paths: - "devices/firmware/**" - "devices/sdk/c/**" @@ -30,11 +32,13 @@ 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@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" - uses: zephyrproject-rtos/action-zephyr-setup@v1 @@ -44,14 +48,30 @@ jobs: sdk-version: 0.17.4 west-version: 1.5.0 - run: west build -p always -b ${{ matrix.board }} devices/firmware/zephyr - - uses: actions/upload-artifact@v4 + - 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 with: name: nexting-device-${{ matrix.artifact }}-${{ github.sha }} - path: | - build/zephyr/zephyr.elf - build/zephyr/zephyr.bin - build/zephyr/zephyr.hex - build/zephyr/zephyr.uf2 + path: nexting-device-${{ matrix.artifact }}-${{ github.sha }}.tar.gz if-no-files-found: error espressif: @@ -62,11 +82,13 @@ 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@v4 - - uses: actions/setup-python@v5 + - uses: actions/checkout@v7 + - uses: actions/setup-python@v7 with: python-version: "3.12" - uses: zephyrproject-rtos/action-zephyr-setup@v1 @@ -77,11 +99,52 @@ jobs: west-version: 1.5.0 - run: west blobs fetch hal_espressif - run: west build -p always -b ${{ matrix.board }} devices/firmware/zephyr - - uses: actions/upload-artifact@v4 + - 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 with: name: nexting-device-${{ matrix.artifact }}-${{ github.sha }} - path: | - build/zephyr/zephyr.elf - build/zephyr/zephyr.bin - build/zephyr/zephyr.hex + path: nexting-device-${{ matrix.artifact }}-${{ github.sha }}.tar.gz 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 e8b1d94..8c1da64 100644 --- a/devices/scripts/simulator-contract.test.mjs +++ b/devices/scripts/simulator-contract.test.mjs @@ -1,64 +1,12 @@ import assert from "node:assert/strict"; -import { readFile } from "node:fs/promises"; +import { access, readFile } from "node:fs/promises"; import test from "node:test"; const read = (path) => readFile(new URL(path, import.meta.url), "utf8"); -async function readPublicCIWorkflow() { - try { - return await read("../.github/workflows/ci.yml"); - } catch (error) { - if (error?.code !== "ENOENT") throw error; - return read("../../.github/workflows/nexting-devices-ci.yml"); - } -} - -function between(source, start, end) { - const startIndex = source.indexOf(start); - const endIndex = source.indexOf(end, startIndex + start.length); - assert.notEqual(startIndex, -1, `missing start marker: ${start}`); - assert.notEqual(endIndex, -1, `missing end marker: ${end}`); - return source.slice(startIndex, endIndex); -} - -test("public simulator keeps the protocol and BLE transport contracts", async () => { - const [simulator, workflow] = await Promise.all([ - read("../examples/macos-device-simulator/main.swift"), - readPublicCIWorkflow(), - ]); - for (const suffix of ["0001", "0002", "0003", "0004"]) { - assert.ok(simulator.includes(`6EADC0DE-${suffix}-4A21-9C5E-1B7F3D9E42A0`)); - } - assert.match(simulator, /NextingDeviceCodec\.decode/); - assert.match(simulator, /NextingDeviceCodec\.encode/); - assert.match(simulator, /0\.2\.0-experimental\.0/); - assert.match(simulator, /button_count/); - assert.match(simulator, /approval_button_count/); - assert.match(simulator, /systemUptime/); - assert.match(simulator, /deadlineMs/); - assert.ok(!simulator.includes("JSONSerialization")); - assert.match(simulator, /notifyEncryptionRequired/); - assert.match(simulator, /writeEncryptionRequired/); - assert.match( - simulator, - /subscribedCentral\.identifier == request\.central\.identifier/, - ); - assert.match(simulator, /insufficientAuthorization/); - assert.match(simulator, /maximumUpdateValueLength/); - assert.match(simulator, /outboundFrameOffset/); - assert.match(simulator, /peripheralManagerIsReady/); - assert.match(simulator, /lockedChoice/); - const completedOversize = between( - simulator, - "if buffer.count + fragment.count >= maximumMessageBytes {", - "} else {", - ); - assert.match(completedOversize, /buffer\.removeAll/); - const flush = between( - simulator, - "private func flushOutboundFrame()", - "func peripheralManagerIsReady(toUpdateSubscribers", - ); - assert.match(flush, /approval\.deadlineMs > monotonicNowMs\(\)/); - assert.match(workflow, /NextingDeviceKit\/Protocol\.swift/); +test("public package keeps the custom Host simulator private", async () => { + await assert.rejects(access(new URL("../examples/macos-device-simulator/", import.meta.url)), { code: "ENOENT" }); + const workflow = await read("../scripts/public-workflows/nexting-devices-ci.yml"); + assert.doesNotMatch(workflow, /NextingDeviceKit|macos-device-simulator|swift test/); + assert.match(workflow, /npm run check/); }); diff --git a/devices/sdk/c/CMakeLists.txt b/devices/sdk/c/CMakeLists.txt index ad72789..c63a491 100644 --- a/devices/sdk/c/CMakeLists.txt +++ b/devices/sdk/c/CMakeLists.txt @@ -53,6 +53,19 @@ 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) @@ -63,7 +76,7 @@ if(NEXTING_DEVICE_SANITIZE AND CMAKE_C_COMPILER_ID MATCHES "Clang|GNU") endif() enable_testing() -foreach(test_name IN ITEMS codec stream relay status device_info) +foreach(test_name IN ITEMS codec stream relay status device_info interactions) 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 6744681..d3dd44b 100644 --- a/devices/sdk/c/README.md +++ b/devices/sdk/c/README.md @@ -2,7 +2,9 @@ This SDK is the chip-neutral part of a Nexting-compatible device. It provides: -- deterministic Experimental 0.2 message encoding and decoding; +- 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`; - 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; @@ -36,3 +38,28 @@ 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 0d773cd..3e3f507 100644 --- a/devices/sdk/c/include/nexting_device.h +++ b/devices/sdk/c/include/nexting_device.h @@ -28,6 +28,18 @@ 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, @@ -42,7 +54,23 @@ typedef enum { NEXTING_DEVICE_MESSAGE_ANSWER, NEXTING_DEVICE_MESSAGE_RESOLVED, NEXTING_DEVICE_MESSAGE_ERROR, - NEXTING_DEVICE_MESSAGE_STATUS + 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_type_t; typedef enum { @@ -87,6 +115,165 @@ 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; @@ -98,6 +285,7 @@ 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, @@ -155,6 +343,13 @@ 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 @@ -221,6 +416,16 @@ 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 8664c0f..699280d 100644 --- a/devices/sdk/c/src/nexting_device.c +++ b/devices/sdk/c/src/nexting_device.c @@ -18,18 +18,41 @@ typedef struct { bool overflow; } writer_t; -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 -}; +#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) static bool is_space(char value) { return value == ' ' || value == '\t' || value == '\n' || value == '\r'; @@ -283,6 +306,27 @@ 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); @@ -610,7 +654,422 @@ static bool parse_agents(parser_t *parser, nexting_device_message_t *message) { } } -static bool claim_field(unsigned *fields, unsigned field) { +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) { if ((*fields & field) != 0U) return false; *fields |= field; @@ -618,7 +1077,7 @@ static bool claim_field(unsigned *fields, unsigned field) { } static bool parse_known_field(parser_t *parser, const char *key, - unsigned *fields, uint64_t *version, char *type, + uint64_t *fields, uint64_t *version, char *type, size_t type_capacity, nexting_device_message_t *message) { char value[32]; @@ -663,6 +1122,7 @@ 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) { @@ -670,16 +1130,161 @@ 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, unsigned fields, + const char *type, uint64_t fields, uint64_t version) { if ((fields & (FIELD_V | FIELD_T)) != (FIELD_V | FIELD_T) || version != 1) return false; @@ -720,6 +1325,172 @@ 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; } @@ -729,7 +1500,7 @@ nexting_device_decode(const char *wire, size_t wire_length, parser_t parser; nexting_device_message_t message = {0}; char type[16] = {0}; - unsigned fields = 0; + uint64_t fields = 0; uint64_t version = 0; if (wire == NULL || output == NULL || wire_length == 0) return NEXTING_DEVICE_BAD_MESSAGE; @@ -1226,6 +1997,21 @@ 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; } @@ -1233,7 +2019,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_field((unsigned *)fields, (unsigned)(bit)) +#define CLAIM_INFO_FIELD(bit) claim_info_field(fields, (uint32_t)(bit)) if (strcmp(key, "protocol") == 0) return CLAIM_INFO_FIELD(DI_FIELD_PROTOCOL) && parse_string(parser, info->protocol_name, @@ -1514,6 +2300,128 @@ 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; @@ -1536,6 +2444,154 @@ 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; } @@ -1545,7 +2601,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[16]; + char number[32]; if (output == NULL || output_length == NULL || !valid_message_for_encode(message)) return NEXTING_DEVICE_BAD_MESSAGE; @@ -1599,6 +2655,265 @@ 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; } @@ -1786,3 +3101,23 @@ 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 new file mode 100644 index 0000000..26a7b55 --- /dev/null +++ b/devices/sdk/c/tests/generate_interaction_vectors.mjs @@ -0,0 +1,42 @@ +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 19d5be6..0ed95ba 100644 --- a/devices/sdk/c/tests/generate_vectors.mjs +++ b/devices/sdk/c/tests/generate_vectors.mjs @@ -4,8 +4,7 @@ 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 = []; @@ -50,18 +49,12 @@ 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 new file mode 100644 index 0000000..c5e7db0 --- /dev/null +++ b/devices/sdk/c/tests/test_interactions.c @@ -0,0 +1,132 @@ +#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/.gitignore b/devices/sdk/kotlin/.gitignore deleted file mode 100644 index ac0e82c..0000000 --- a/devices/sdk/kotlin/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -.gradle/ -.kotlin/ -build/ diff --git a/devices/sdk/kotlin/README.md b/devices/sdk/kotlin/README.md deleted file mode 100644 index 5fac6e2..0000000 --- a/devices/sdk/kotlin/README.md +++ /dev/null @@ -1,23 +0,0 @@ -# Nexting Devices Kotlin SDK - -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 -- Gradle 9 or newer -- Kotlin 2.2 - -## Test - -```sh -gradle test -``` - -`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. diff --git a/devices/sdk/kotlin/build.gradle.kts b/devices/sdk/kotlin/build.gradle.kts deleted file mode 100644 index ad6ee63..0000000 --- a/devices/sdk/kotlin/build.gradle.kts +++ /dev/null @@ -1,26 +0,0 @@ -plugins { - kotlin("jvm") -} - -group = "ai.nexting.devices" -version = "0.2.0-experimental.0" - -kotlin { - compilerOptions { - jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17) - } -} - -java { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 -} - -dependencies { - implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.9.0") - testImplementation(kotlin("test")) -} - -tasks.test { - useJUnitPlatform() -} diff --git a/devices/sdk/kotlin/settings.gradle.kts b/devices/sdk/kotlin/settings.gradle.kts deleted file mode 100644 index 12e3a3a..0000000 --- a/devices/sdk/kotlin/settings.gradle.kts +++ /dev/null @@ -1,18 +0,0 @@ -pluginManagement { - plugins { - kotlin("jvm") version "2.2.10" - } - repositories { - mavenCentral() - gradlePluginPortal() - } -} - -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - mavenCentral() - } -} - -rootProject.name = "nexting-device-kotlin" 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 deleted file mode 100644 index 7561838..0000000 --- a/devices/sdk/kotlin/src/main/kotlin/ai/nexting/devices/DeviceInfo.kt +++ /dev/null @@ -1,338 +0,0 @@ -package ai.nexting.devices - -import java.nio.ByteBuffer -import java.nio.charset.CodingErrorAction -import java.nio.charset.StandardCharsets -import java.util.UUID -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonElement -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.booleanOrNull -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.longOrNull - -data class DeviceIdentity( - val deviceId: UUID?, - val manufacturer: String?, - val displayName: String?, - val serialNumber: String?, -) - -data class DeviceDisplay( - val type: String, - val width: Int, - val height: Int, -) - -data class DeviceCapabilities( - val buttonCount: Int?, - val approvalButtonCount: Int?, - val customButtonCount: Int?, - val rotaryCount: Int?, - val rotaryPressCount: Int?, - val statusSlots: Int, - val batteryService: Boolean, - val display: DeviceDisplay?, - val haptics: List, -) - -data class DeviceVendorFact( - val key: String, - val label: String, - val value: String, -) - -data class DeviceVendorInfo( - val namespace: String, - val facts: List, -) - -data class DeviceInfo( - val protocolName: String, - val spec: String, - val wireVersions: List, - val profiles: List, - val model: String, - val firmwareVersion: String, - val maxMessageBytes: Long, - val maxSummaryBytes: Int, - val identity: DeviceIdentity, - val capabilities: DeviceCapabilities, - val vendor: DeviceVendorInfo?, -) { - val supportsApprovalV1: Boolean - get() = protocolName == "nexting-device" && - wireVersions.contains(1) && - profiles.contains("approval/1") - - val supportsStatusV1: Boolean - get() = supportsApprovalV1 && - profiles.contains("status/1") && - capabilities.statusSlots > 0 -} - -object DeviceBattery { - const val SERVICE_UUID = "0000180f-0000-1000-8000-00805f9b34fb" - const val LEVEL_CHARACTERISTIC_UUID = "00002a19-0000-1000-8000-00805f9b34fb" - - fun decodeLevel(bytes: ByteArray): Int? { - if (bytes.size != 1) return null - return (bytes[0].toInt() and 0xff).coerceAtMost(100) - } -} - -object DeviceInfoCodec { - const val MAX_ENCODED_BYTES = 4_096 - private const val MAX_VENDOR_BYTES = 1_024 - private val json = Json { - ignoreUnknownKeys = true - isLenient = false - } - private val canonicalUnsigned = Regex("0|[1-9][0-9]*") - private val uuid = Regex( - "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-" + - "[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}", - ) - private val namespace = Regex( - "[a-z0-9](?:[a-z0-9-]{0,62}\\.)+[a-z0-9][a-z0-9-]{0,62}", - ) - private val factKey = Regex("[A-Za-z0-9][A-Za-z0-9._-]{0,31}") - private val inertMarkup = Regex( - "(?: MAX_ENCODED_BYTES) return null - val text = decodeUtf8(bytes) ?: return null - val root = runCatching { json.parseToJsonElement(text) }.getOrNull() - as? JsonObject ?: return null - if (!boundedDepth(root, 0)) return null - - val protocolName = root.requiredString("protocol") ?: return null - val spec = root.requiredString("spec") ?: return null - val model = root.requiredString("model") ?: return null - val firmware = root.requiredString("fw") ?: return null - if ( - protocolName != "nexting-device" || - !validText(spec) || - !validText(model) || - !validText(firmware) - ) return null - - val wire = root.requiredArray("wire") - ?.mapNotNull { canonicalInt(it, 1, 65_535) } - ?: return null - if (wire.isEmpty() || wire.size > 4 || wire.distinct().size != wire.size || 1 !in wire) { - return null - } - - val profiles = root.requiredArray("profiles") - ?.mapNotNull { - (it as? JsonPrimitive) - ?.takeIf(JsonPrimitive::isString) - ?.content - } - ?: return null - if ( - profiles.isEmpty() || - profiles.size > 16 || - profiles.distinct().size != profiles.size || - profiles.any { !validText(it, 32) } || - "approval/1" !in profiles - ) return null - - val maxMessageBytes = canonicalLong(root["max_message_bytes"], 512, UInt.MAX_VALUE.toLong()) - ?: return null - val maxSummaryBytes = canonicalInt(root["max_summary_bytes"], 1, 240) - ?: return null - - val statusSlots = optionalInt(root, "statusSlots", 0, 8) ?: return null - val buttonCount = optionalInt(root, "button_count", 0, 1_024) ?: return null - val approvalButtonCount = - optionalInt(root, "approval_button_count", 0, 1_024) ?: return null - val customButtonCount = - optionalInt(root, "custom_button_count", 0, 1_024) ?: return null - val rotaryCount = optionalInt(root, "rotary_count", 0, 64) ?: return null - val rotaryPressCount = - optionalInt(root, "rotary_press_count", 0, 64) ?: return null - if ( - (statusSlots.value ?: 0) > 0 && "status/1" !in profiles || - !fits(approvalButtonCount.value, buttonCount.value) || - !fits(customButtonCount.value, buttonCount.value) || - !fits(rotaryPressCount.value, rotaryCount.value) - ) return null - - val batteryService = when (val raw = root["battery_service"]) { - null -> false - is JsonPrimitive -> raw.booleanOrNull ?: return null - else -> return null - } - val display = decodeDisplay(root["display"]) ?: if ("display" in root) return null else null - val haptics = decodeHaptics(root["haptics"]) ?: if ("haptics" in root) return null else emptyList() - - val rawDeviceId = root.optionalString("device_id") ?: if ("device_id" in root) return null else null - val deviceId = if (rawDeviceId != null) { - if (!uuid.matches(rawDeviceId)) return null - runCatching { UUID.fromString(rawDeviceId) }.getOrNull() ?: return null - } else { - null - } - val manufacturer = validatedOptionalText(root, "manufacturer") ?: if ("manufacturer" in root) return null else null - val displayName = validatedOptionalText(root, "display_name") ?: if ("display_name" in root) return null else null - val serialNumber = validatedOptionalText(root, "serial_number") ?: if ("serial_number" in root) return null else null - - return DeviceInfo( - protocolName = protocolName, - spec = spec, - wireVersions = wire, - profiles = profiles, - model = model, - firmwareVersion = firmware, - maxMessageBytes = maxMessageBytes, - maxSummaryBytes = maxSummaryBytes, - identity = DeviceIdentity(deviceId, manufacturer, displayName, serialNumber), - capabilities = DeviceCapabilities( - buttonCount = buttonCount.value, - approvalButtonCount = approvalButtonCount.value, - customButtonCount = customButtonCount.value, - rotaryCount = rotaryCount.value, - rotaryPressCount = rotaryPressCount.value, - statusSlots = statusSlots.value ?: 0, - batteryService = batteryService, - display = display, - haptics = haptics, - ), - vendor = decodeVendor(root["vendor"]), - ) - } - - private fun decodeUtf8(bytes: ByteArray): String? = runCatching { - StandardCharsets.UTF_8.newDecoder() - .onMalformedInput(CodingErrorAction.REPORT) - .onUnmappableCharacter(CodingErrorAction.REPORT) - .decode(ByteBuffer.wrap(bytes)) - .toString() - }.getOrNull() - - private fun boundedDepth(value: JsonElement, depth: Int): Boolean { - if (depth > 8) return false - return when (value) { - is JsonObject -> value.values.all { boundedDepth(it, depth + 1) } - is JsonArray -> value.all { boundedDepth(it, depth + 1) } - else -> true - } - } - - private fun validText(value: String, maximumBytes: Int = 64): Boolean = - value.isNotEmpty() && - value.encodeToByteArray().size <= maximumBytes && - value.none { it.code < 0x20 || it.code == 0x7f } - - private fun validatedOptionalText(root: JsonObject, key: String): String? = - root.optionalString(key)?.takeIf(::validText) - - private fun canonicalInt(value: JsonElement?, minimum: Int, maximum: Int): Int? { - val primitive = value as? JsonPrimitive ?: return null - if (primitive.isString || !canonicalUnsigned.matches(primitive.content)) return null - return primitive.intOrNull?.takeIf { it in minimum..maximum } - } - - private fun canonicalLong(value: JsonElement?, minimum: Long, maximum: Long): Long? { - val primitive = value as? JsonPrimitive ?: return null - if (primitive.isString || !canonicalUnsigned.matches(primitive.content)) return null - return primitive.longOrNull?.takeIf { it in minimum..maximum } - } - - private data class OptionalInt(val value: Int?) - - private fun optionalInt( - root: JsonObject, - key: String, - minimum: Int, - maximum: Int, - ): OptionalInt? { - val raw = root[key] ?: return OptionalInt(null) - return canonicalInt(raw, minimum, maximum)?.let { OptionalInt(it) } - } - - private fun fits(value: Int?, total: Int?): Boolean = - value == null || total == null || value <= total - - private fun decodeDisplay(raw: JsonElement?): DeviceDisplay? { - val display = raw as? JsonObject ?: return null - val type = display.requiredString("type")?.takeIf { validText(it, 32) } ?: return null - val width = canonicalInt(display["width"], 1, 4_096) ?: return null - val height = canonicalInt(display["height"], 1, 4_096) ?: return null - return DeviceDisplay(type, width, height) - } - - private fun decodeHaptics(raw: JsonElement?): List? { - val values = raw as? JsonArray ?: return null - val decoded = values.mapNotNull { - (it as? JsonPrimitive) - ?.takeIf(JsonPrimitive::isString) - ?.content - } - if ( - decoded.isEmpty() || - decoded.size > 8 || - decoded.size != values.size || - decoded.distinct().size != decoded.size || - decoded.any { !validText(it, 32) } - ) return null - return decoded - } - - private fun decodeVendor(raw: JsonElement?): DeviceVendorInfo? { - val vendor = raw as? JsonObject ?: return null - if (vendor.toString().encodeToByteArray().size > MAX_VENDOR_BYTES) return null - val vendorNamespace = vendor.requiredString("namespace") - ?.takeIf { validText(it, 128) && namespace.matches(it) } - ?: return null - val rawFacts = vendor["facts"] as? JsonArray ?: return null - if (rawFacts.size !in 1..16) return null - val seen = mutableSetOf() - val facts = rawFacts.map { rawFact -> - val fact = rawFact as? JsonObject ?: return null - val key = fact.requiredString("key") - ?.takeIf { factKey.matches(it) && seen.add(it) } - ?: return null - val label = fact.requiredString("label") - ?.takeIf { validInertText(it, 64) } - ?: return null - val value = vendorValue(fact["value"]) - ?.takeIf { validInertText(it, 128) } - ?: return null - DeviceVendorFact(key, label, value) - } - return DeviceVendorInfo(vendorNamespace, facts) - } - - private fun vendorValue(raw: JsonElement?): String? { - val primitive = raw as? JsonPrimitive ?: return null - if (primitive == JsonNull || primitive.booleanOrNull != null) return null - if (primitive.isString) return primitive.content - if (!canonicalUnsigned.matches(primitive.content)) return null - return primitive.longOrNull?.toString() - } - - private fun validInertText(value: String, maximumBytes: Int): Boolean = - validText(value, maximumBytes) && !inertMarkup.containsMatchIn(value) - - private fun JsonObject.requiredString(key: String): String? { - val primitive = this[key] as? JsonPrimitive ?: return null - return primitive.contentOrNull.takeIf { primitive.isString } - } - - private fun JsonObject.optionalString(key: String): String? = - if (key !in this) null else requiredString(key) - - private fun JsonObject.requiredArray(key: String): JsonArray? = this[key] as? JsonArray - - private val JsonPrimitive.contentOrNull: String? - get() = if (this == JsonNull) null else content -} 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 deleted file mode 100644 index 771ad21..0000000 --- a/devices/sdk/kotlin/src/main/kotlin/ai/nexting/devices/Protocol.kt +++ /dev/null @@ -1,326 +0,0 @@ -package ai.nexting.devices - -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonArray -import kotlinx.serialization.json.JsonNull -import kotlinx.serialization.json.JsonObject -import kotlinx.serialization.json.JsonPrimitive -import kotlinx.serialization.json.buildJsonArray -import kotlinx.serialization.json.buildJsonObject -import kotlinx.serialization.json.intOrNull -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive -import kotlinx.serialization.json.put - -enum class DeviceChoice(val wireValue: String) { - ALLOW("allow"), - DENY("deny"), -} - -enum class DeviceResolutionReason(val wireValue: String) { - ANSWERED("answered"), - EXPIRED("expired"), - CANCELLED("cancelled"), - REPLACED("replaced"), -} - -enum class DeviceErrorCode(val wireValue: String) { - BAD_MESSAGE("bad_message"), - MESSAGE_TOO_LARGE("message_too_large"), - UNSUPPORTED_VERSION("unsupported_version"), - UNSUPPORTED_PROFILE("unsupported_profile"), - UNKNOWN_REQUEST("unknown_request"), - NOT_AUTHORIZED("not_authorized"), - BUSY("busy"), -} - -enum class DeviceAgentState(val wireValue: String) { - IDLE("idle"), - THINKING("thinking"), - WORKING("working"), - COMPLETE("complete"), - NEEDS_INPUT("needs_input"), - ERROR("error"), -} - -data class DeviceAgentStatus( - val slot: Int, - val state: DeviceAgentState, - val label: String? = null, -) - -sealed interface DeviceMessage { - data class Present( - val requestId: String, - val summary: String, - val ttlMilliseconds: Int, - ) : DeviceMessage - - data class Answer( - val requestId: String, - val choice: DeviceChoice, - ) : DeviceMessage - - data class Resolved( - val requestId: String, - val reason: DeviceResolutionReason, - ) : DeviceMessage - - data class Error( - val requestId: String?, - val code: DeviceErrorCode, - ) : DeviceMessage - - data class Status( - val agents: List, - ) : DeviceMessage -} - -object DeviceMessageCodec { - const val WIRE_VERSION = 1 - const val APPROVAL_PROFILE = "approval/1" - const val STATUS_PROFILE = "status/1" - const val MAX_REQUEST_ID_BYTES = 64 - const val MAX_SUMMARY_BYTES = 240 - const val MAX_TTL_MILLISECONDS = 300_000 - const val MAX_MESSAGE_BYTES = 4_096 - const val MAX_STATUS_AGENTS = 8 - const val MAX_STATUS_LABEL_BYTES = 64 - - private val json = Json { - ignoreUnknownKeys = false - isLenient = false - } - private val requestId = Regex("[A-Za-z0-9._:-]{1,64}") - - fun encode(message: DeviceMessage): ByteArray? { - val objectValue = when (message) { - is DeviceMessage.Present -> { - if ( - !validId(message.requestId) || - !validSummary(message.summary) || - message.ttlMilliseconds !in 1..MAX_TTL_MILLISECONDS - ) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "present") - put("id", message.requestId) - put("sum", message.summary) - put("opt", buildJsonArray { - add(JsonPrimitive("allow")) - add(JsonPrimitive("deny")) - }) - put("ttl", message.ttlMilliseconds) - } - } - is DeviceMessage.Answer -> { - if (!validId(message.requestId)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "answer") - put("id", message.requestId) - put("ch", message.choice.wireValue) - } - } - is DeviceMessage.Resolved -> { - if (!validId(message.requestId)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "resolved") - put("id", message.requestId) - put("r", message.reason.wireValue) - } - } - is DeviceMessage.Error -> { - if (message.requestId != null && !validId(message.requestId)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "error") - message.requestId?.let { put("id", it) } - put("code", message.code.wireValue) - } - } - is DeviceMessage.Status -> { - if (!validAgents(message.agents)) return null - buildJsonObject { - put("v", WIRE_VERSION) - put("t", "status") - put("agents", buildJsonArray { - message.agents.forEach { agent -> - add(buildJsonObject { - put("slot", agent.slot) - put("state", agent.state.wireValue) - agent.label?.let { put("label", it) } - }) - } - }) - } - } - } - val encoded = (objectValue.toString() + "\n").encodeToByteArray() - return encoded.takeIf { it.size <= MAX_MESSAGE_BYTES } - } - - fun decode(bytes: ByteArray): DeviceMessage? { - if (bytes.isEmpty() || bytes.size > MAX_MESSAGE_BYTES) return null - val text = runCatching { bytes.decodeToString(throwOnInvalidSequence = true) } - .getOrNull() ?: return null - val body = if (text.endsWith("\n")) text.dropLast(1) else text - if (body.isEmpty() || '\n' in body || '\r' in body || '\u0000' in body) return null - val root = runCatching { json.parseToJsonElement(body).jsonObject }.getOrNull() - ?: return null - if (root.canonicalInt("v") != WIRE_VERSION) return null - return when (root.string("t")) { - "present" -> decodePresent(root) - "answer" -> decodeAnswer(root) - "resolved" -> decodeResolved(root) - "error" -> decodeError(root) - "status" -> decodeStatus(root) - else -> null - } - } - - private fun decodePresent(root: JsonObject): DeviceMessage? { - val id = root.string("id")?.takeIf(::validId) ?: return null - val summary = root.string("sum")?.takeIf(::validSummary) ?: return null - val options = runCatching { - root["opt"]!!.jsonArray.map { it.jsonPrimitive.content } - }.getOrNull() ?: return null - if (options != listOf("allow", "deny")) return null - val ttl = root.canonicalInt("ttl") - ?.takeIf { it in 1..MAX_TTL_MILLISECONDS } ?: return null - return DeviceMessage.Present(id, summary, ttl) - } - - private fun decodeAnswer(root: JsonObject): DeviceMessage? { - val id = root.string("id")?.takeIf(::validId) ?: return null - val choice = DeviceChoice.entries.firstOrNull { - it.wireValue == root.string("ch") - } ?: return null - return DeviceMessage.Answer(id, choice) - } - - private fun decodeResolved(root: JsonObject): DeviceMessage? { - val id = root.string("id")?.takeIf(::validId) ?: return null - val reason = DeviceResolutionReason.entries.firstOrNull { - it.wireValue == root.string("r") - } ?: return null - return DeviceMessage.Resolved(id, reason) - } - - private fun decodeError(root: JsonObject): DeviceMessage? { - val id = if ("id" in root) { - root.string("id")?.takeIf(::validId) ?: return null - } else { - null - } - val code = DeviceErrorCode.entries.firstOrNull { - it.wireValue == root.string("code") - } ?: return null - return DeviceMessage.Error(id, code) - } - - private fun decodeStatus(root: JsonObject): DeviceMessage? { - val rawAgents = root["agents"] as? JsonArray ?: return null - val agents = rawAgents.map { raw -> - val item = raw as? JsonObject ?: return null - val slot = item.canonicalInt("slot") ?: return null - val state = DeviceAgentState.entries.firstOrNull { - it.wireValue == item.string("state") - } ?: return null - val label = if ("label" in item) item.string("label") ?: return null else null - DeviceAgentStatus(slot, state, label) - } - if (!validAgents(agents)) return null - return DeviceMessage.Status(agents) - } - - private fun validId(value: String): Boolean = - value.encodeToByteArray().size <= MAX_REQUEST_ID_BYTES && - requestId.matches(value) - - private fun validSummary(value: String): Boolean = - value.encodeToByteArray().size <= MAX_SUMMARY_BYTES && '\u0000' !in value - - private fun validAgents(agents: List): Boolean = - agents.size <= MAX_STATUS_AGENTS && - agents.map { it.slot }.distinct().size == agents.size && - agents.all { agent -> - agent.slot in 0 until MAX_STATUS_AGENTS && - (agent.label == null || ( - agent.label.isNotEmpty() && - agent.label.encodeToByteArray().size <= MAX_STATUS_LABEL_BYTES && - agent.label.none { it.code < 0x20 || it.code == 0x7f } - )) - } - - private fun JsonObject.string(key: String): String? { - val primitive = this[key] as? JsonPrimitive ?: return null - if (!primitive.isString || primitive == JsonNull) return null - return primitive.content - } - - private fun JsonObject.canonicalInt(key: String): Int? { - val primitive = this[key] as? JsonPrimitive ?: return null - if (primitive.isString || !Regex("0|[1-9][0-9]*").matches(primitive.content)) { - return null - } - return primitive.intOrNull - } -} - -/** - * Bounded newline framing for BLE notification fragments. - * - * The complete frame limit includes LF. Oversize input is discarded through - * its next LF, after which later frames in the same chunk can decode normally. - */ -class DeviceLineDecoder( - private val maximumMessageBytes: Int = DeviceMessageCodec.MAX_MESSAGE_BYTES, -) { - private val buffer = ArrayList(minOf(maximumMessageBytes, 512)) - private var discardingOversize = false - - init { - require(maximumMessageBytes in 1..DeviceMessageCodec.MAX_MESSAGE_BYTES) - } - - fun push(chunk: ByteArray): List { - val messages = mutableListOf() - for (byte in chunk) { - if (discardingOversize) { - if (byte == LF) { - discardingOversize = false - buffer.clear() - } - continue - } - if (byte == LF) { - val frame = ByteArray(buffer.size + 1) - buffer.forEachIndexed { index, value -> frame[index] = value } - frame[frame.lastIndex] = LF - buffer.clear() - DeviceMessageCodec.decode(frame)?.let(messages::add) - continue - } - // Reserve one byte for the required terminating LF. - if (buffer.size + 2 > maximumMessageBytes) { - buffer.clear() - discardingOversize = true - continue - } - buffer.add(byte) - } - return messages - } - - fun reset() { - buffer.clear() - discardingOversize = false - } - - private companion object { - const val LF: Byte = 0x0a - } -} diff --git a/devices/sdk/kotlin/src/test/kotlin/ai/nexting/devices/DeviceInfoTest.kt b/devices/sdk/kotlin/src/test/kotlin/ai/nexting/devices/DeviceInfoTest.kt deleted file mode 100644 index f0b5a56..0000000 --- a/devices/sdk/kotlin/src/test/kotlin/ai/nexting/devices/DeviceInfoTest.kt +++ /dev/null @@ -1,63 +0,0 @@ -package ai.nexting.devices - -import java.nio.file.Files -import java.nio.file.Path -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNotNull -import kotlin.test.assertNull -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive - -class DeviceInfoTest { - private val vectors = Json.parseToJsonElement( - Files.readString( - Path.of("../../protocol/vectors/device-info-v1.json").toAbsolutePath().normalize(), - ), - ).jsonObject - - @Test - fun `valid shared vectors normalize identically`() { - val valid = vectors.getValue("valid").jsonArray - valid.forEach { raw -> - val item = raw.jsonObject - val expected = item.getValue("decoded").jsonObject - val info = assertNotNull( - DeviceInfoCodec.decode(item.getValue("wire").jsonPrimitive.content.encodeToByteArray()), - item.getValue("name").jsonPrimitive.content, - ) - assertEquals(expected.getValue("model").jsonPrimitive.content, info.model) - assertEquals( - expected.getValue("statusSlots").jsonPrimitive.content.toInt(), - info.capabilities.statusSlots, - ) - assertEquals( - expected.getValue("batteryService").jsonPrimitive.content.toBoolean(), - info.capabilities.batteryService, - ) - } - } - - @Test - fun `invalid core is rejected`() { - vectors.getValue("invalidCore").jsonArray.forEach { raw -> - val item = raw.jsonObject - assertNull( - DeviceInfoCodec.decode(item.getValue("wire").jsonPrimitive.content.encodeToByteArray()), - item.getValue("name").jsonPrimitive.content, - ) - } - } - - @Test - fun `battery level is bounded and optional`() { - assertEquals(0, DeviceBattery.decodeLevel(byteArrayOf(0))) - assertEquals(55, DeviceBattery.decodeLevel(byteArrayOf(55))) - assertEquals(100, DeviceBattery.decodeLevel(byteArrayOf(100))) - assertEquals(100, DeviceBattery.decodeLevel(byteArrayOf(255.toByte()))) - assertNull(DeviceBattery.decodeLevel(byteArrayOf())) - assertNull(DeviceBattery.decodeLevel(byteArrayOf(1, 2))) - } -} diff --git a/devices/sdk/kotlin/src/test/kotlin/ai/nexting/devices/ProtocolTest.kt b/devices/sdk/kotlin/src/test/kotlin/ai/nexting/devices/ProtocolTest.kt deleted file mode 100644 index 30b26f8..0000000 --- a/devices/sdk/kotlin/src/test/kotlin/ai/nexting/devices/ProtocolTest.kt +++ /dev/null @@ -1,73 +0,0 @@ -package ai.nexting.devices - -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertNull - -class ProtocolTest { - @Test - fun `approval messages round trip`() { - val present = DeviceMessage.Present("cc:42", "Allow shell command?", 60_000) - assertEquals(present, DeviceMessageCodec.decode(DeviceMessageCodec.encode(present)!!)) - val answer = DeviceMessage.Answer("cc:42", DeviceChoice.ALLOW) - assertEquals(answer, DeviceMessageCodec.decode(DeviceMessageCodec.encode(answer)!!)) - } - - @Test - fun `status messages preserve slots and labels`() { - val status = DeviceMessage.Status(listOf( - DeviceAgentStatus(0, DeviceAgentState.WORKING, "Claude Code"), - DeviceAgentStatus(1, DeviceAgentState.NEEDS_INPUT), - )) - assertEquals(status, DeviceMessageCodec.decode(DeviceMessageCodec.encode(status)!!)) - } - - @Test - fun `invalid limits fail closed`() { - assertNull(DeviceMessageCodec.encode(DeviceMessage.Present("", "x", 1))) - assertNull(DeviceMessageCodec.encode(DeviceMessage.Present("x", "x", 0))) - assertNull(DeviceMessageCodec.decode( - """{"v":1,"t":"answer","id":"x","ch":"maybe"}""".encodeToByteArray(), - )) - assertNull(DeviceMessageCodec.decode( - """{"v":1,"t":"status","agents":[{"slot":0,"state":"idle"},{"slot":0,"state":"working"}]}""" - .encodeToByteArray(), - )) - } -} - -class DeviceLineDecoderTest { - @Test - fun `fragmented frame decodes once`() { - val decoder = DeviceLineDecoder() - val wire = DeviceMessageCodec.encode( - DeviceMessage.Answer("request-1", DeviceChoice.ALLOW), - )!! - assertEquals(emptyList(), decoder.push(wire.copyOfRange(0, 7))) - assertEquals( - listOf(DeviceMessage.Answer("request-1", DeviceChoice.ALLOW)), - decoder.push(wire.copyOfRange(7, wire.size)), - ) - } - - @Test - fun `oversize frame discards through newline and recovers in same chunk`() { - val decoder = DeviceLineDecoder(maximumMessageBytes = 64) - val valid = DeviceMessageCodec.encode( - DeviceMessage.Answer("r2", DeviceChoice.DENY), - )!! - val hostile = ByteArray(80) { 'x'.code.toByte() } + byteArrayOf('\n'.code.toByte()) + valid - assertEquals( - listOf(DeviceMessage.Answer("r2", DeviceChoice.DENY)), - decoder.push(hostile), - ) - } - - @Test - fun `reset drops a partial frame`() { - val decoder = DeviceLineDecoder() - decoder.push("""{"v":1,"t":"answer"""".encodeToByteArray()) - decoder.reset() - assertEquals(emptyList(), decoder.push(byteArrayOf('\n'.code.toByte()))) - } -} diff --git a/devices/sdk/swift/Package.swift b/devices/sdk/swift/Package.swift deleted file mode 100644 index b96a7f8..0000000 --- a/devices/sdk/swift/Package.swift +++ /dev/null @@ -1,17 +0,0 @@ -// swift-tools-version: 6.0 -import PackageDescription - -let package = Package( - name: "NextingDeviceKit", - platforms: [ - .iOS(.v16), - .macOS(.v13), - ], - products: [ - .library(name: "NextingDeviceKit", targets: ["NextingDeviceKit"]), - ], - targets: [ - .target(name: "NextingDeviceKit"), - .testTarget(name: "NextingDeviceKitTests", dependencies: ["NextingDeviceKit"]), - ] -) diff --git a/devices/sdk/swift/README.md b/devices/sdk/swift/README.md deleted file mode 100644 index c8c60d4..0000000 --- a/devices/sdk/swift/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# NextingDeviceKit - -`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. - -Start with [the public foundation](../../docs/foundation-development.md) and [interface catalog](../../docs/interfaces.md). Product integration steps are in [the Host/App track](../../docs/implementation-tracks.md#track-2-integrate-a-host-or-app). - -## Requirements - -- Swift 6 -- iOS 16 or newer -- macOS 13 or newer -- A real iPhone for BLE host testing - -## Build and test - -From the repository root: - -```sh -swift test --package-path sdk/swift -swift build --package-path sdk/swift -``` - -The test target reads the canonical vectors from -`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` | 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 - -```swift -import NextingDeviceKit - -let wire = NextingDeviceCodec.encode( - .present( - requestId: "request-1", - summary: "Allow git push?", - ttlMs: 30_000 - ) -) -``` - -`wire` is optional because invalid IDs, summaries, or TTL values fail closed. - -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 - -```swift -import NextingDeviceKit - -let store = NextingDeviceAuthorizationStore() -let policy = NextingDeviceAuthorizationPolicy( - store: store, - allowDebugTestDevice: false -) -let central = NextingDeviceCentral( - authorizationPredicate: policy.isAuthorized -) - -central.onMessage = { identity, message in - guard policy.isAuthorized(identity) else { return } - print(message) -} -central.onSendRejected = { reason in - print("Device send rejected: \(reason)") -} -central.startScan() -``` - -Production code must provide explicit enrollment and revocation before calling `store.authorize`. Never authorize by peripheral name in a release build. - -## Integration rules - -- Keep the host App authoritative. A device reports physical input; it does not directly authorize an Agent action. -- Present hardware only for an exact two-option, one-time permission route: Allow is source option `0`, Deny is source option `1`, and highlight state is irrelevant. -- Call `phoneAnswerStarted()` before a phone API attempt, then call request-scoped `answerSucceeded` or `answerFailed` only after the authoritative action result. Do the same completion step after a routed hardware answer. -- Send only an opaque request ID, bounded summary, fixed choices, and relative TTL. -- Share one claim gate between phone and hardware answers. -- Clear framing and approval state on disconnect. -- Respect Device Info limits and reject incompatible wire versions or profiles. -- Treat a `false` return from `send` as a delivery failure. `onSendRejected` also reports whether the cause was not-ready state, an oversized logical frame, or write-queue backpressure. -- Keep both pending bytes and pending frame count bounded. `NextingDeviceCentral` defaults to at most 64 retained logical frames in addition to its byte ceiling. -- Require an encrypted bonded link for approval traffic. - -See [the protocol specification](../../SPEC.md), [security model](../../SECURITY.md), and [current status](../../docs/project-status.md) before using the package in a product. diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/Authorization.swift b/devices/sdk/swift/Sources/NextingDeviceKit/Authorization.swift deleted file mode 100644 index b4d3ddb..0000000 --- a/devices/sdk/swift/Sources/NextingDeviceKit/Authorization.swift +++ /dev/null @@ -1,60 +0,0 @@ -import Foundation - -public struct NextingDevicePeripheralIdentity: Equatable, Hashable, Sendable { - public let id: UUID - public let name: String? - - public init(id: UUID, name: String?) { - self.id = id - self.name = name - } -} - -public final class NextingDeviceAuthorizationStore { - private static let authorizedIDsKey = "nexting_device_authorized_peripheral_uuids" - private let defaults: UserDefaults - - public init(defaults: UserDefaults = .standard) { - self.defaults = defaults - } - - public func isAuthorized(_ id: UUID) -> Bool { - authorizedIDs.contains(id.uuidString) - } - - public func authorize(_ id: UUID) { - var ids = authorizedIDs - ids.insert(id.uuidString) - defaults.set(ids.sorted(), forKey: Self.authorizedIDsKey) - } - - public func revoke(_ id: UUID) { - var ids = authorizedIDs - ids.remove(id.uuidString) - defaults.set(ids.sorted(), forKey: Self.authorizedIDsKey) - } - - private var authorizedIDs: Set { - Set(defaults.stringArray(forKey: Self.authorizedIDsKey) ?? []) - } -} - -public struct NextingDeviceAuthorizationPolicy { - public static let debugTestDeviceName = "Nexting-Device-Sim" - - private let store: NextingDeviceAuthorizationStore - private let allowDebugTestDevice: Bool - - public init( - store: NextingDeviceAuthorizationStore, - allowDebugTestDevice: Bool - ) { - self.store = store - self.allowDebugTestDevice = allowDebugTestDevice - } - - public func isAuthorized(_ identity: NextingDevicePeripheralIdentity) -> Bool { - store.isAuthorized(identity.id) - || (allowDebugTestDevice && identity.name == Self.debugTestDeviceName) - } -} diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/Battery.swift b/devices/sdk/swift/Sources/NextingDeviceKit/Battery.swift deleted file mode 100644 index 3f323d7..0000000 --- a/devices/sdk/swift/Sources/NextingDeviceKit/Battery.swift +++ /dev/null @@ -1,16 +0,0 @@ -import CoreBluetooth -import Foundation - -public enum NextingDeviceBattery { - public static let serviceUUIDString = "180F" - public static let levelCharacteristicUUIDString = "2A19" - public static var serviceUUID: CBUUID { CBUUID(string: serviceUUIDString) } - public static var levelCharacteristicUUID: CBUUID { - CBUUID(string: levelCharacteristicUUIDString) - } - - public static func decodeLevel(_ data: Data) -> Int? { - guard data.count == 1, let raw = data.first else { return nil } - return min(Int(raw), 100) - } -} diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/Central.swift b/devices/sdk/swift/Sources/NextingDeviceKit/Central.swift deleted file mode 100644 index 1aa53fa..0000000 --- a/devices/sdk/swift/Sources/NextingDeviceKit/Central.swift +++ /dev/null @@ -1,547 +0,0 @@ -@preconcurrency import CoreBluetooth -import Foundation - -public let NextingDeviceServiceUUID = CBUUID( - string: "6EADC0DE-0001-4A21-9C5E-1B7F3D9E42A0" -) -public let NextingDeviceDownlinkUUID = CBUUID( - string: "6EADC0DE-0002-4A21-9C5E-1B7F3D9E42A0" -) -public let NextingDeviceUplinkUUID = CBUUID( - string: "6EADC0DE-0003-4A21-9C5E-1B7F3D9E42A0" -) -public let NextingDeviceInfoUUID = CBUUID( - string: "6EADC0DE-0004-4A21-9C5E-1B7F3D9E42A0" -) - -enum NextingDeviceSetupDecision: Equatable { - case accept(NextingDeviceInfo) - case recover -} - -enum NextingDeviceSetupTransition { - static func deviceInfoRead( - data: Data?, - hadError: Bool - ) -> NextingDeviceSetupDecision { - guard !hadError, let data, - let info = NextingDeviceInfo.decode(data) else { return .recover } - return .accept(info) - } -} - -enum NextingDeviceTransportPolicy { - static let hostMaxMessageBytes = 4_096 - - static func accepts( - frameByteCount: Int, - deviceInfo: NextingDeviceInfo? - ) -> Bool { - let limit = min( - hostMaxMessageBytes, - deviceInfo?.maxMessageBytes ?? hostMaxMessageBytes - ) - return frameByteCount > 0 && frameByteCount <= limit - } - - static func boundedSummary( - _ summary: String, - deviceInfo: NextingDeviceInfo? - ) -> String { - let limit = min( - NextingDeviceCodec.maxSummaryBytes, - deviceInfo?.maxSummaryBytes ?? NextingDeviceCodec.maxSummaryBytes - ) - guard summary.utf8.count > limit else { return summary } - var result = "" - var byteCount = 0 - for character in summary { - let addition = String(character) - let additionBytes = addition.utf8.count - guard byteCount + additionBytes <= limit else { break } - result.append(character) - byteCount += additionBytes - } - return result - } - - static func boundedPresentSummary( - requestId: String, - summary: String, - ttlMs: Int, - deviceInfo: NextingDeviceInfo? - ) -> String? { - var candidate = boundedSummary(summary, deviceInfo: deviceInfo) - while let wire = NextingDeviceCodec.encode(.present( - requestId: requestId, - summary: candidate, - ttlMs: ttlMs - )) { - if accepts( - frameByteCount: wire.count, - deviceInfo: deviceInfo - ) { return candidate } - guard !candidate.isEmpty else { return nil } - candidate.removeLast() - } - return nil - } -} - -struct NextingDeviceWriteQueue { - let maxPendingBytes: Int - let maxPendingFrames: Int - private var frames: [Data] = [] - private var headIndex = 0 - private var frameOffset = 0 - private var inFlightLength: Int? - private(set) var pendingByteCount = 0 - - init(maxPendingBytes: Int, maxPendingFrames: Int = 64) { - precondition(maxPendingBytes > 0, "maxPendingBytes must be positive") - precondition(maxPendingFrames > 0, "maxPendingFrames must be positive") - self.maxPendingBytes = maxPendingBytes - self.maxPendingFrames = maxPendingFrames - } - - var hasInFlightChunk: Bool { inFlightLength != nil } - - mutating func enqueue(frame: Data) -> Bool { - guard !frame.isEmpty, - frames.count - headIndex < maxPendingFrames, - frame.count <= maxPendingBytes - pendingByteCount else { return false } - frames.append(frame) - pendingByteCount += frame.count - return true - } - - mutating func nextChunk(maxBytes: Int) -> Data? { - guard maxBytes > 0, inFlightLength == nil, - headIndex < frames.count else { return nil } - let frame = frames[headIndex] - let length = min(maxBytes, frame.count - frameOffset) - guard length > 0 else { return nil } - inFlightLength = length - return frame.subdata(in: frameOffset ..< frameOffset + length) - } - - mutating func acknowledgeInFlightChunk() -> Bool { - guard let length = inFlightLength, - headIndex < frames.count else { return false } - inFlightLength = nil - frameOffset += length - pendingByteCount -= length - if frameOffset == frames[headIndex].count { - headIndex += 1 - frameOffset = 0 - compactCompletedFrames() - } - return true - } - - mutating func removeAll() { - frames.removeAll(keepingCapacity: true) - headIndex = 0 - frameOffset = 0 - inFlightLength = nil - pendingByteCount = 0 - } - - private mutating func compactCompletedFrames() { - if headIndex == frames.count { - frames.removeAll(keepingCapacity: true) - headIndex = 0 - } else if headIndex >= 32, headIndex * 2 >= frames.count { - frames.removeFirst(headIndex) - headIndex = 0 - } - } -} - -public enum NextingDeviceSendRejection: Equatable, Sendable { - case notReady - case messageTooLarge(actualBytes: Int, limitBytes: Int) - case queueFull(limitBytes: Int) -} - -public final class NextingDeviceCentral: NSObject, NextingDeviceRelayTransport { - private var central: CBCentralManager! - private var peripheral: CBPeripheral? - private var downlink: CBCharacteristic? - private var uplink: CBCharacteristic? - private var deviceInfoCharacteristic: CBCharacteristic? - private var batteryLevelCharacteristic: CBCharacteristic? - private var decoder = NextingDeviceLineDecoder() - private var writeQueue: NextingDeviceWriteQueue - private var shouldScan = false - private var recoveryState = NextingDeviceDiscoveryRecoveryState() - private var recoveringPeripheralID: UUID? - private let authorizationPredicate: (NextingDevicePeripheralIdentity) -> Bool - - public var onMessage: ((NextingDevicePeripheralIdentity, NextingDeviceMessage) -> Void)? - public var onConnectedChange: ((Bool) -> Void)? - /// Discovery is informational only. A discovered peripheral is never - /// connected unless `authorizationPredicate` also accepts it. - public var onDiscovered: ((NextingDevicePeripheralIdentity, Int) -> Void)? - public var onBatteryLevel: ((Int?) -> Void)? - public var onSendRejected: ((NextingDeviceSendRejection) -> Void)? - - public private(set) var isReady = false { - didSet { - if isReady != oldValue { onConnectedChange?(isReady) } - } - } - - public private(set) var connectedIdentity: NextingDevicePeripheralIdentity? - public private(set) var connectedDeviceInfo: NextingDeviceInfo? - public private(set) var connectedBatteryLevel: Int? - - public init( - authorizationPredicate: @escaping (NextingDevicePeripheralIdentity) -> Bool, - maxPendingWriteBytes: Int = 16_384 - ) { - self.authorizationPredicate = authorizationPredicate - writeQueue = NextingDeviceWriteQueue( - maxPendingBytes: maxPendingWriteBytes - ) - super.init() - central = CBCentralManager(delegate: self, queue: nil) - } - - @discardableResult - public func send(_ data: Data) -> Bool { - guard isReady, peripheral != nil, downlink != nil, - let connectedDeviceInfo else { - onSendRejected?(.notReady) - return false - } - let messageLimit = min( - NextingDeviceTransportPolicy.hostMaxMessageBytes, - connectedDeviceInfo.maxMessageBytes - ) - guard NextingDeviceTransportPolicy.accepts( - frameByteCount: data.count, - deviceInfo: connectedDeviceInfo - ) else { - onSendRejected?(.messageTooLarge( - actualBytes: data.count, - limitBytes: messageLimit - )) - return false - } - guard writeQueue.enqueue(frame: data) else { - onSendRejected?(.queueFull(limitBytes: writeQueue.maxPendingBytes)) - return false - } - pumpWriteQueue() - return true - } - - public func startScan() { - shouldScan = true - recoveryState.beginExplicitScanCycle() - guard peripheral == nil, recoveringPeripheralID == nil else { return } - central.stopScan() - resumeScanning() - } - - public func disconnect() { - shouldScan = false - central.stopScan() - if let peripheral { central.cancelPeripheralConnection(peripheral) } - recoveringPeripheralID = nil - clearConnectionState() - } - - private func pumpWriteQueue() { - guard let peripheral, let downlink else { return } - let maximum = max(1, peripheral.maximumWriteValueLength(for: .withResponse)) - guard let next = writeQueue.nextChunk(maxBytes: maximum) else { return } - peripheral.writeValue(next, for: downlink, type: .withResponse) - } - - private func resumeScanning() { - guard shouldScan, central.state == .poweredOn, - peripheral == nil, recoveringPeripheralID == nil else { return } - central.scanForPeripherals(withServices: [NextingDeviceServiceUUID]) - } - - private func clearConnectionState() { - isReady = false - downlink = nil - uplink = nil - deviceInfoCharacteristic = nil - batteryLevelCharacteristic = nil - peripheral = nil - connectedIdentity = nil - connectedDeviceInfo = nil - connectedBatteryLevel = nil - onBatteryLevel?(nil) - decoder.reset() - writeQueue.removeAll() - } - - private func recoverFromSetupFailure(_ peripheral: CBPeripheral) { - guard self.peripheral === peripheral, - connectedIdentity?.id == peripheral.identifier, - recoveringPeripheralID == nil else { return } - recoveryState.quarantine(peripheral.identifier) - recoveringPeripheralID = peripheral.identifier - isReady = false - downlink = nil - uplink = nil - deviceInfoCharacteristic = nil - decoder.reset() - writeQueue.removeAll() - central.cancelPeripheralConnection(peripheral) - } -} - -extension NextingDeviceCentral: CBCentralManagerDelegate { - public func centralManagerDidUpdateState(_ central: CBCentralManager) { - if central.state == .poweredOn { - resumeScanning() - } else { - recoveringPeripheralID = nil - clearConnectionState() - } - } - - public func centralManager( - _ central: CBCentralManager, - didDiscover peripheral: CBPeripheral, - advertisementData: [String: Any], - rssi RSSI: NSNumber - ) { - let name = advertisementData[CBAdvertisementDataLocalNameKey] as? String - ?? peripheral.name - let identity = NextingDevicePeripheralIdentity( - id: peripheral.identifier, - name: name - ) - onDiscovered?(identity, RSSI.intValue) - guard self.peripheral == nil, shouldScan, - recoveryState.canAttempt(peripheral.identifier), - authorizationPredicate(identity) else { return } - central.stopScan() - self.peripheral = peripheral - connectedIdentity = identity - peripheral.delegate = self - central.connect(peripheral) - } - - public func centralManager( - _ central: CBCentralManager, - didConnect peripheral: CBPeripheral - ) { - guard shouldScan, self.peripheral === peripheral, - let identity = connectedIdentity, - identity.id == peripheral.identifier, - authorizationPredicate(identity) else { - central.cancelPeripheralConnection(peripheral) - return - } - peripheral.discoverServices([ - NextingDeviceServiceUUID, - NextingDeviceBattery.serviceUUID, - ]) - } - - public func centralManager( - _ central: CBCentralManager, - didDisconnectPeripheral peripheral: CBPeripheral, - error: Error? - ) { - guard self.peripheral === peripheral - || connectedIdentity?.id == peripheral.identifier - || recoveringPeripheralID == peripheral.identifier else { return } - let shouldResume = recoveryState.connectionDidEnd( - peripheral.identifier, - wasReady: isReady, - shouldScan: shouldScan - ) - recoveringPeripheralID = nil - clearConnectionState() - if shouldResume { resumeScanning() } - } - - public func centralManager( - _ central: CBCentralManager, - didFailToConnect peripheral: CBPeripheral, - error: Error? - ) { - guard self.peripheral === peripheral - || connectedIdentity?.id == peripheral.identifier else { return } - recoveryState.quarantine(peripheral.identifier) - recoveringPeripheralID = nil - clearConnectionState() - resumeScanning() - } -} - -extension NextingDeviceCentral: CBPeripheralDelegate { - public func peripheral( - _ peripheral: CBPeripheral, - didDiscoverServices error: Error? - ) { - guard self.peripheral === peripheral, - connectedIdentity?.id == peripheral.identifier else { return } - guard error == nil, - let service = peripheral.services?.first(where: { - $0.uuid == NextingDeviceServiceUUID - }) else { - recoverFromSetupFailure(peripheral) - return - } - peripheral.discoverCharacteristics( - [ - NextingDeviceDownlinkUUID, - NextingDeviceUplinkUUID, - NextingDeviceInfoUUID, - ], - for: service - ) - if let batteryService = peripheral.services?.first(where: { - $0.uuid == NextingDeviceBattery.serviceUUID - }) { - peripheral.discoverCharacteristics( - [NextingDeviceBattery.levelCharacteristicUUID], - for: batteryService - ) - } - } - - public func peripheral( - _ peripheral: CBPeripheral, - didDiscoverCharacteristicsFor service: CBService, - error: Error? - ) { - guard self.peripheral === peripheral, - connectedIdentity?.id == peripheral.identifier else { return } - - if service.uuid == NextingDeviceBattery.serviceUUID { - guard error == nil, - let batteryLevel = service.characteristics?.first(where: { - $0.uuid == NextingDeviceBattery.levelCharacteristicUUID - }), - batteryLevel.properties.contains(.read) else { return } - batteryLevelCharacteristic = batteryLevel - peripheral.readValue(for: batteryLevel) - if batteryLevel.properties.contains(.notify) { - peripheral.setNotifyValue(true, for: batteryLevel) - } - return - } - guard service.uuid == NextingDeviceServiceUUID else { return } - let characteristics = service.characteristics ?? [] - guard error == nil, - let downlink = characteristics.first(where: { - $0.uuid == NextingDeviceDownlinkUUID - }), - downlink.properties.contains(.write), - let uplink = characteristics.first(where: { - $0.uuid == NextingDeviceUplinkUUID - }), - uplink.properties.contains(.notify), - let deviceInfo = characteristics.first(where: { - $0.uuid == NextingDeviceInfoUUID - }), - deviceInfo.properties.contains(.read) else { - recoverFromSetupFailure(peripheral) - return - } - self.downlink = downlink - self.uplink = uplink - deviceInfoCharacteristic = deviceInfo - peripheral.readValue(for: deviceInfo) - } - - public func peripheral( - _ peripheral: CBPeripheral, - didUpdateValueFor characteristic: CBCharacteristic, - error: Error? - ) { - guard self.peripheral === peripheral, - connectedIdentity?.id == peripheral.identifier else { return } - - if characteristic.uuid == NextingDeviceBattery.levelCharacteristicUUID, - batteryLevelCharacteristic === characteristic { - let level = error == nil - ? characteristic.value.flatMap(NextingDeviceBattery.decodeLevel) - : nil - connectedBatteryLevel = level - onBatteryLevel?(level) - return - } - - if characteristic.uuid == NextingDeviceInfoUUID, - deviceInfoCharacteristic === characteristic { - switch NextingDeviceSetupTransition.deviceInfoRead( - data: characteristic.value, - hadError: error != nil - ) { - case .recover: - recoverFromSetupFailure(peripheral) - return - case let .accept(info): - connectedDeviceInfo = info - decoder = NextingDeviceLineDecoder( - maxMessageBytes: min( - info.maxMessageBytes, - NextingDeviceTransportPolicy.hostMaxMessageBytes - ) - ) - guard let uplink else { - recoverFromSetupFailure(peripheral) - return - } - peripheral.setNotifyValue(true, for: uplink) - return - } - } - - guard characteristic.uuid == NextingDeviceUplinkUUID, - uplink === characteristic, - error == nil, - let data = characteristic.value, - let identity = connectedIdentity else { return } - for message in decoder.push(data) { - onMessage?(identity, message) - } - } - - public func peripheral( - _ peripheral: CBPeripheral, - didUpdateNotificationStateFor characteristic: CBCharacteristic, - error: Error? - ) { - guard self.peripheral === peripheral, - connectedIdentity?.id == peripheral.identifier, - characteristic.uuid == NextingDeviceUplinkUUID, - uplink === characteristic else { return } - guard error == nil, downlink != nil, - connectedDeviceInfo != nil, - characteristic.isNotifying else { - recoverFromSetupFailure(peripheral) - return - } - isReady = true - } - - public func peripheral( - _ peripheral: CBPeripheral, - didWriteValueFor characteristic: CBCharacteristic, - error: Error? - ) { - guard self.peripheral === peripheral, - characteristic.uuid == NextingDeviceDownlinkUUID, - downlink === characteristic, - writeQueue.hasInFlightChunk else { return } - guard error == nil else { - recoverFromSetupFailure(peripheral) - return - } - _ = writeQueue.acknowledgeInFlightChunk() - pumpWriteQueue() - } -} diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/Coordinator.swift b/devices/sdk/swift/Sources/NextingDeviceKit/Coordinator.swift deleted file mode 100644 index 6bda0b7..0000000 --- a/devices/sdk/swift/Sources/NextingDeviceKit/Coordinator.swift +++ /dev/null @@ -1,259 +0,0 @@ -import Foundation - -public protocol NextingDeviceRelayTransport: AnyObject { - var onMessage: ((NextingDevicePeripheralIdentity, NextingDeviceMessage) -> Void)? { get set } - var onConnectedChange: ((Bool) -> Void)? { get set } - var connectedIdentity: NextingDevicePeripheralIdentity? { get } - var connectedDeviceInfo: NextingDeviceInfo? { get } - var isReady: Bool { get } - - @discardableResult - func send(_ data: Data) -> Bool - func startScan() - func disconnect() -} - -public enum NextingDeviceOptionMapping { - public static func index(for choice: NextingDeviceChoice) -> Int { - choice == .allow ? 0 : 1 - } -} - -public final class NextingDeviceRelayCoordinator { - private struct Pending { - let requestId: String - let context: Context - let summary: String - let deadlineMs: Int64 - var presented: Bool - var answerInFlight: Bool - var lockedDeviceChoice: NextingDeviceChoice? - } - - private let transport: NextingDeviceRelayTransport - private let isAuthorized: (NextingDevicePeripheralIdentity) -> Bool - private let nowMs: () -> Int64 - private let requestId: () -> String - private let answerPrompt: (Context, NextingDeviceChoice) -> Void - private var pending: Pending? - private var relay: NextingDevicePromptRelay! - - public init( - transport: NextingDeviceRelayTransport, - isAuthorized: @escaping (NextingDevicePeripheralIdentity) -> Bool, - nowMs: @escaping () -> Int64 = { - Int64((ProcessInfo.processInfo.systemUptime * 1_000).rounded(.down)) - }, - requestId: @escaping () -> String = { UUID().uuidString }, - answerPrompt: @escaping (Context, NextingDeviceChoice) -> Void - ) { - self.transport = transport - self.isAuthorized = isAuthorized - self.nowMs = nowMs - self.requestId = requestId - self.answerPrompt = answerPrompt - - relay = NextingDevicePromptRelay( - sendToDevice: { [weak self] data in - self?.sendIfAuthorized(data) ?? false - }, - answerPrompt: { [weak self] choice in self?.routeDeviceAnswer(choice) }, - nowMs: nowMs, - onResolve: { [weak self] id, reason in - guard reason != .answered else { return } - self?.clearPending(requestId: id) - } - ) - transport.onMessage = { [weak self] identity, message in - self?.receive(message, from: identity) - } - transport.onConnectedChange = { [weak self] ready in - self?.connectionChanged(ready: ready) - } - } - - public func start() { - transport.startScan() - presentIfPossible() - } - - public func resume() { - start() - } - - public func restartDiscovery() { - start() - } - - public func stop() { - cancel() - transport.disconnect() - } - - @discardableResult - public func present(context: Context, summary: String, ttlMs: Int) -> String? { - cancel() - let id = requestId() - guard NextingDeviceCodec.encode( - .present(requestId: id, summary: summary, ttlMs: ttlMs) - ) != nil else { return nil } - let (deadline, overflow) = nowMs().addingReportingOverflow(Int64(ttlMs)) - guard !overflow else { return nil } - - pending = Pending( - requestId: id, - context: context, - summary: summary, - deadlineMs: deadline, - presented: false, - answerInFlight: false, - lockedDeviceChoice: nil - ) - presentIfPossible() - return pending?.requestId == id ? id : nil - } - - @discardableResult - public func phoneAnswerStarted() -> String? { - guard var current = pending, !current.answerInFlight else { return nil } - if current.presented, - !relay.phoneAnswerStarted(requestId: current.requestId) { return nil } - current.answerInFlight = true - pending = current - return current.requestId - } - - public func answerSucceeded(requestId: String) { - guard let current = pending, - current.requestId == requestId, - current.answerInFlight else { return } - if current.presented { relay.answerSucceeded(requestId: requestId) } - pending = nil - } - - public func answerFailed(requestId: String) { - guard var current = pending, - current.requestId == requestId, - current.answerInFlight else { return } - current.answerInFlight = false - pending = current - if current.presented { relay.answerFailed(requestId: requestId) } - else if nowMs() >= current.deadlineMs { pending = nil } - else { presentIfPossible() } - } - - public func cancel() { - guard let current = pending else { return } - if current.presented { relay.cancel(requestId: current.requestId) } - pending = nil - } - - /// Sends a full-replacement status frame. Returns false without sending - /// when the connected device did not declare `statusSlots`, the frame is - /// invalid, or the link is not authorized and ready. - @discardableResult - public func publishStatus(_ agents: [NextingDeviceAgentStatus]) -> Bool { - guard let info = transport.connectedDeviceInfo, info.supportsStatusV1, - let wire = NextingDeviceCodec.encode(.status(agents: agents)) else { - return false - } - return sendIfAuthorized(wire) - } - - public func tick() { - guard let current = pending else { return } - if current.presented { - relay.tick() - } else if !current.answerInFlight && nowMs() >= current.deadlineMs { - pending = nil - } - } - - private func connectionChanged(ready: Bool) { - if ready { - presentIfPossible() - } else if var current = pending, current.presented { - relay.disconnect() - current.presented = false - pending = current - } - } - - private func presentIfPossible() { - guard var current = pending, !current.presented, !current.answerInFlight, - let identity = transport.connectedIdentity, - transport.isReady, isAuthorized(identity) else { return } - let remaining = current.deadlineMs - nowMs() - guard remaining > 0, - remaining <= Int64(NextingDeviceCodec.maxTTLMilliseconds), - let summary = NextingDeviceTransportPolicy.boundedPresentSummary( - requestId: current.requestId, - summary: current.summary, - ttlMs: Int(remaining), - deviceInfo: transport.connectedDeviceInfo - ), - let wire = NextingDeviceCodec.encode(.present( - requestId: current.requestId, - summary: summary, - ttlMs: Int(remaining) - )), - NextingDeviceTransportPolicy.accepts( - frameByteCount: wire.count, - deviceInfo: transport.connectedDeviceInfo - ) else { - pending = nil - return - } - - guard relay.present( - requestId: current.requestId, - summary: summary, - ttlMs: Int(remaining) - ) else { - pending = nil - return - } - current.presented = true - pending = current - } - - private func receive( - _ message: NextingDeviceMessage, - from identity: NextingDevicePeripheralIdentity - ) { - guard isAuthorized(identity), identity == transport.connectedIdentity, - case let .answer(id, choice) = message, - let current = pending, - current.requestId == id, - !current.answerInFlight else { return } - if let lockedChoice = current.lockedDeviceChoice, - lockedChoice != choice { return } - _ = relay.onDeviceAnswer( - requestId: id, - choice: choice, - deviceAuthorized: true - ) - } - - private func routeDeviceAnswer(_ choice: NextingDeviceChoice) { - guard var current = pending, !current.answerInFlight else { return } - if let lockedChoice = current.lockedDeviceChoice, - lockedChoice != choice { return } - current.lockedDeviceChoice = choice - current.answerInFlight = true - pending = current - answerPrompt(current.context, choice) - } - - private func clearPending(requestId: String) { - guard pending?.requestId == requestId else { return } - pending = nil - } - - private func sendIfAuthorized(_ data: Data) -> Bool { - guard transport.isReady, - let identity = transport.connectedIdentity, - isAuthorized(identity) else { return false } - return transport.send(data) - } -} diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift b/devices/sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift deleted file mode 100644 index fde89e4..0000000 --- a/devices/sdk/swift/Sources/NextingDeviceKit/DeviceInfo.swift +++ /dev/null @@ -1,310 +0,0 @@ -import Foundation - -public struct NextingDeviceIdentity: Equatable, Sendable { - public let deviceId: UUID? - public let manufacturer: String? - public let displayName: String? - public let serialNumber: String? -} - -public struct NextingDeviceDisplay: Equatable, Sendable { - public let type: String - public let width: Int - public let height: Int -} - -public struct NextingDeviceCapabilities: Equatable, Sendable { - public let buttonCount: Int? - public let approvalButtonCount: Int? - public let customButtonCount: Int? - public let rotaryCount: Int? - public let rotaryPressCount: Int? - public let statusSlots: Int - public let batteryService: Bool - public let display: NextingDeviceDisplay? - public let haptics: [String] -} - -public struct NextingDeviceVendorFact: Equatable, Sendable { - public let key: String - public let label: String - public let value: String -} - -public struct NextingDeviceVendorInfo: Equatable, Sendable { - public let namespace: String - public let facts: [NextingDeviceVendorFact] -} - -public struct NextingDeviceInfo: Equatable, Sendable { - public static let maxEncodedBytes = 4_096 - - public let protocolName: String - public let spec: String - public let wireVersions: [Int] - public let profiles: [String] - public let model: String - public let firmwareVersion: String - public let maxMessageBytes: Int - public let maxSummaryBytes: Int - public let identity: NextingDeviceIdentity - public let capabilities: NextingDeviceCapabilities - public let vendor: NextingDeviceVendorInfo? - - public var statusSlots: Int { capabilities.statusSlots } - - public var supportsApprovalV1: Bool { - protocolName == "nexting-device" - && wireVersions.contains(NextingDeviceCodec.wireVersion) - && profiles.contains(NextingDeviceCodec.profile) - } - - public var supportsStatusV1: Bool { - supportsApprovalV1 - && profiles.contains(NextingDeviceCodec.statusProfile) - && statusSlots >= 1 - } - - public static func decode(_ data: Data) -> NextingDeviceInfo? { - guard data.count <= maxEncodedBytes, - String(data: data, encoding: .utf8) != nil, - let payload = try? JSONDecoder().decode(Payload.self, from: data), - payload.protocolName == "nexting-device", - validDescriptor(payload.spec), - validUniqueDescriptors(payload.profiles, maximumCount: 16, maximumBytes: 32), - payload.profiles.contains(NextingDeviceCodec.profile), - !payload.wireVersions.isEmpty, - payload.wireVersions.count <= 4, - Set(payload.wireVersions).count == payload.wireVersions.count, - payload.wireVersions.allSatisfy({ (1 ... 65_535).contains($0) }), - payload.wireVersions.contains(NextingDeviceCodec.wireVersion), - validDescriptor(payload.model), - validDescriptor(payload.firmwareVersion), - (512 ... Int(UInt32.max)).contains(payload.maxMessageBytes), - (1 ... NextingDeviceCodec.maxSummaryBytes).contains(payload.maxSummaryBytes) - else { return nil } - - guard validOptionalDescriptor(payload.manufacturer), - validOptionalDescriptor(payload.displayName), - validOptionalDescriptor(payload.serialNumber) - else { return nil } - - let statusSlots = payload.statusSlots ?? 0 - guard (0 ... NextingDeviceCodec.maxStatusAgents).contains(statusSlots), - statusSlots == 0 || payload.profiles.contains(NextingDeviceCodec.statusProfile), - validCount(payload.buttonCount, maximum: 1_024), - validCount(payload.approvalButtonCount, maximum: 1_024), - validCount(payload.customButtonCount, maximum: 1_024), - validCount(payload.rotaryCount, maximum: 64), - validCount(payload.rotaryPressCount, maximum: 64), - specializedCount(payload.approvalButtonCount, fitsWithin: payload.buttonCount), - specializedCount(payload.customButtonCount, fitsWithin: payload.buttonCount), - specializedCount(payload.rotaryPressCount, fitsWithin: payload.rotaryCount), - validDisplay(payload.display), - validHaptics(payload.haptics) - else { return nil } - - let deviceId: UUID? - if let rawDeviceId = payload.deviceId { - guard rawDeviceId.utf8.count == 36, - let parsed = UUID(uuidString: rawDeviceId) - else { return nil } - deviceId = parsed - } else { - deviceId = nil - } - - let info = NextingDeviceInfo( - protocolName: payload.protocolName, - spec: payload.spec, - wireVersions: payload.wireVersions, - profiles: payload.profiles, - model: payload.model, - firmwareVersion: payload.firmwareVersion, - maxMessageBytes: payload.maxMessageBytes, - maxSummaryBytes: payload.maxSummaryBytes, - identity: NextingDeviceIdentity( - deviceId: deviceId, - manufacturer: payload.manufacturer, - displayName: payload.displayName, - serialNumber: payload.serialNumber - ), - capabilities: NextingDeviceCapabilities( - buttonCount: payload.buttonCount, - approvalButtonCount: payload.approvalButtonCount, - customButtonCount: payload.customButtonCount, - rotaryCount: payload.rotaryCount, - rotaryPressCount: payload.rotaryPressCount, - statusSlots: statusSlots, - batteryService: payload.batteryService ?? false, - display: payload.display.map { - NextingDeviceDisplay(type: $0.type, width: $0.width, height: $0.height) - }, - haptics: payload.haptics ?? [] - ), - vendor: decodeVendor(from: data) - ) - return info.supportsApprovalV1 ? info : nil - } - - private static func validDescriptor( - _ value: String, - maximumBytes: Int = 64 - ) -> Bool { - !value.isEmpty - && value.utf8.count <= maximumBytes - && value.unicodeScalars.allSatisfy { - !CharacterSet.controlCharacters.contains($0) - } - } - - private static func validOptionalDescriptor(_ value: String?) -> Bool { - value.map { validDescriptor($0) } ?? true - } - - private static func validUniqueDescriptors( - _ values: [String], - maximumCount: Int, - maximumBytes: Int - ) -> Bool { - !values.isEmpty - && values.count <= maximumCount - && Set(values).count == values.count - && values.allSatisfy { validDescriptor($0, maximumBytes: maximumBytes) } - } - - private static func validCount(_ value: Int?, maximum: Int) -> Bool { - value.map { (0 ... maximum).contains($0) } ?? true - } - - private static func specializedCount(_ value: Int?, fitsWithin total: Int?) -> Bool { - guard let value, let total else { return true } - return value <= total - } - - private static func validDisplay(_ display: Payload.Display?) -> Bool { - guard let display else { return true } - return validDescriptor(display.type, maximumBytes: 32) - && (1 ... 4_096).contains(display.width) - && (1 ... 4_096).contains(display.height) - } - - private static func validHaptics(_ values: [String]?) -> Bool { - guard let values else { return true } - return validUniqueDescriptors(values, maximumCount: 8, maximumBytes: 32) - } - - private static func decodeVendor(from data: Data) -> NextingDeviceVendorInfo? { - guard let root = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let rawVendor = root["vendor"] - else { return nil } - guard let vendor = rawVendor as? [String: Any], - let namespace = vendor["namespace"] as? String, - validDescriptor(namespace, maximumBytes: 128), - namespace.range( - of: #"^[a-z0-9](?:[a-z0-9-]{0,62}\.)+[a-z0-9][a-z0-9-]{0,62}$"#, - options: .regularExpression - ) != nil, - let rawFacts = vendor["facts"] as? [[String: Any]], - (1 ... 16).contains(rawFacts.count), - let encodedVendor = try? JSONSerialization.data( - withJSONObject: vendor, - options: [.sortedKeys] - ), - encodedVendor.count <= 1_024 - else { return nil } - - var keys = Set() - var facts: [NextingDeviceVendorFact] = [] - for rawFact in rawFacts { - guard let key = rawFact["key"] as? String, - key.range( - of: #"^[A-Za-z0-9][A-Za-z0-9._-]{0,31}$"#, - options: .regularExpression - ) != nil, - keys.insert(key).inserted, - let label = rawFact["label"] as? String, - validInertText(label, maximumBytes: 64), - let value = vendorValue(rawFact["value"]), - validInertText(value, maximumBytes: 128) - else { return nil } - facts.append(NextingDeviceVendorFact(key: key, label: label, value: value)) - } - return NextingDeviceVendorInfo(namespace: namespace, facts: facts) - } - - private static func vendorValue(_ raw: Any?) -> String? { - if let value = raw as? String { return value } - if raw is Bool { return nil } - if let value = raw as? NSNumber { - let double = value.doubleValue - guard double.isFinite, - double.rounded(.towardZero) == double - else { return nil } - return value.stringValue - } - return nil - } - - private static func validInertText(_ value: String, maximumBytes: Int) -> Bool { - validDescriptor(value, maximumBytes: maximumBytes) - && value.range( - of: #"(?:( - _ chunk: Bytes - ) -> [NextingDeviceMessage] { - var messages: [NextingDeviceMessage] = [] - - guard chunk.count <= maxMessageBytes else { - reset() - return [.error(requestId: nil, code: .messageTooLarge)] - } - - for byte in chunk { - if discardingOversizedMessage { - if byte == 0x0A { discardingOversizedMessage = false } - continue - } - - if byte == 0x0A { - let message = NextingDeviceCodec.decode(buffer) - ?? .error(requestId: nil, code: .badMessage) - messages.append(message) - buffer.removeAll(keepingCapacity: true) - continue - } - - if buffer.count >= maxMessageBytes - 1 { - buffer.removeAll(keepingCapacity: true) - discardingOversizedMessage = true - messages.append(.error(requestId: nil, code: .messageTooLarge)) - continue - } - - buffer.append(byte) - } - - return messages - } - - public mutating func reset() { - buffer.removeAll(keepingCapacity: true) - discardingOversizedMessage = false - } -} diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/Protocol.swift b/devices/sdk/swift/Sources/NextingDeviceKit/Protocol.swift deleted file mode 100644 index 13151b2..0000000 --- a/devices/sdk/swift/Sources/NextingDeviceKit/Protocol.swift +++ /dev/null @@ -1,328 +0,0 @@ -import CoreFoundation -import Foundation - -public enum NextingDeviceChoice: String, Codable, Equatable, Sendable { - case allow - case deny -} - -public enum NextingDeviceResolutionReason: String, Codable, Equatable, Sendable { - case answered - case expired - case cancelled - case replaced -} - -public enum NextingDeviceErrorCode: String, Codable, Equatable, Sendable { - case badMessage = "bad_message" - case messageTooLarge = "message_too_large" - case unsupportedVersion = "unsupported_version" - case unsupportedProfile = "unsupported_profile" - case unknownRequest = "unknown_request" - case notAuthorized = "not_authorized" - case busy -} - -public enum NextingDeviceAgentState: String, Codable, Equatable, Sendable { - case idle - case thinking - case working - case complete - case needsInput = "needs_input" - case error -} - -public struct NextingDeviceAgentStatus: Equatable, Sendable { - public let slot: Int - public let state: NextingDeviceAgentState - public let label: String? - - public init(slot: Int, state: NextingDeviceAgentState, label: String? = nil) { - self.slot = slot - self.state = state - self.label = label - } -} - -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]) -} - -public enum NextingDeviceCodec { - public static let wireVersion = 1 - public static let profile = "approval/1" - public static let statusProfile = "status/1" - public static let maxRequestIDBytes = 64 - public static let maxSummaryBytes = 240 - public static let maxTTLMilliseconds = 300_000 - public static let maxMessageBytes = 4_096 - public static let maxStatusAgents = 8 - public static let maxStatusLabelBytes = 64 - - private static let knownWireFields: Set = [ - "v", "t", "id", "sum", "opt", "ttl", "ch", "r", "code", "agents", - ] - - public static func encode(_ message: NextingDeviceMessage) -> Data? { - let text: String - switch message { - case let .present(requestId, summary, ttlMs): - guard validID(requestId), validSummary(summary), validTTL(ttlMs) else { - return nil - } - text = "{\"v\":1,\"t\":\"present\",\"id\":\(jsonString(requestId)),\"sum\":\(jsonString(summary)),\"opt\":[\"allow\",\"deny\"],\"ttl\":\(ttlMs)}\n" - case let .answer(requestId, choice): - guard validID(requestId) else { return nil } - text = "{\"v\":1,\"t\":\"answer\",\"id\":\(jsonString(requestId)),\"ch\":\(jsonString(choice.rawValue))}\n" - case let .resolved(requestId, reason): - guard validID(requestId) else { return nil } - text = "{\"v\":1,\"t\":\"resolved\",\"id\":\(jsonString(requestId)),\"r\":\(jsonString(reason.rawValue))}\n" - case let .error(requestId, code): - if let requestId { - guard validID(requestId) else { return nil } - text = "{\"v\":1,\"t\":\"error\",\"id\":\(jsonString(requestId)),\"code\":\(jsonString(code.rawValue))}\n" - } else { - text = "{\"v\":1,\"t\":\"error\",\"code\":\(jsonString(code.rawValue))}\n" - } - case let .status(agents): - guard validStatusAgents(agents) else { return nil } - let entries = agents.map { agent -> String in - var entry = "{\"slot\":\(agent.slot),\"state\":\(jsonString(agent.state.rawValue))" - if let label = agent.label { - entry += ",\"label\":\(jsonString(label))" - } - return entry + "}" - }.joined(separator: ",") - text = "{\"v\":1,\"t\":\"status\",\"agents\":[\(entries)]}\n" - } - return Data(text.utf8) - } - - public static func decode(_ data: Data) -> NextingDeviceMessage? { - guard data.count <= maxMessageBytes, - data.count < maxMessageBytes || data.last == 0x0A, - var text = String(data: data, encoding: .utf8) else { return nil } - if text.last == "\n" { text.removeLast() } - guard !text.isEmpty, !text.contains("\n"), !text.contains("\r"), - rawObjectIsSafe(text), - let object = try? JSONSerialization.jsonObject(with: Data(text.utf8)), - let message = object as? [String: Any], - integer(message["v"]) == wireVersion, - let type = message["t"] as? String else { return nil } - - switch type { - case "present": - guard let requestID = message["id"] as? String, validID(requestID), - let summary = message["sum"] as? String, validSummary(summary), - let options = message["opt"] as? [String], options == ["allow", "deny"], - let ttlMs = integer(message["ttl"]), validTTL(ttlMs) else { return nil } - return .present(requestId: requestID, summary: summary, ttlMs: ttlMs) - case "answer": - guard let requestID = message["id"] as? String, validID(requestID), - let rawChoice = message["ch"] as? String, - let choice = NextingDeviceChoice(rawValue: rawChoice) else { return nil } - return .answer(requestId: requestID, choice: choice) - case "resolved": - guard let requestID = message["id"] as? String, validID(requestID), - let rawReason = message["r"] as? String, - let reason = NextingDeviceResolutionReason(rawValue: rawReason) else { return nil } - return .resolved(requestId: requestID, reason: reason) - case "error": - let requestID: String? - if message.keys.contains("id") { - guard let value = message["id"] as? String, validID(value) else { return nil } - requestID = value - } else { - requestID = nil - } - guard let rawCode = message["code"] as? String, - let code = NextingDeviceErrorCode(rawValue: rawCode) else { return nil } - return .error(requestId: requestID, code: code) - case "status": - guard let rawAgents = message["agents"] as? [[String: Any]] else { return nil } - var agents: [NextingDeviceAgentStatus] = [] - agents.reserveCapacity(rawAgents.count) - for rawAgent in rawAgents { - guard let slot = integer(rawAgent["slot"]), - let rawState = rawAgent["state"] as? String, - let state = NextingDeviceAgentState(rawValue: rawState) else { - return nil - } - var label: String? - if rawAgent.keys.contains("label") { - guard let value = rawAgent["label"] as? String else { return nil } - label = value - } - agents.append(.init(slot: slot, state: state, label: label)) - } - guard validStatusAgents(agents) else { return nil } - return .status(agents: agents) - default: - return nil - } - } - - private static let allowedIDCharacters = CharacterSet( - charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789._:-" - ) - - private static func validID(_ value: String) -> Bool { - !value.isEmpty - && value.utf8.count <= maxRequestIDBytes - && value.unicodeScalars.allSatisfy { allowedIDCharacters.contains($0) } - } - - private static func validSummary(_ value: String) -> Bool { - value.utf8.count <= maxSummaryBytes - && value.unicodeScalars.allSatisfy { $0.value != 0 } - } - - private static func validTTL(_ value: Int) -> Bool { - (1 ... maxTTLMilliseconds).contains(value) - } - - private static func validStatusLabel(_ value: String) -> Bool { - !value.isEmpty - && value.utf8.count <= maxStatusLabelBytes - && value.unicodeScalars.allSatisfy { - $0.value > 0x1F && $0.value != 0x7F - } - } - - private static func validStatusAgents(_ agents: [NextingDeviceAgentStatus]) -> Bool { - guard agents.count <= maxStatusAgents else { return false } - var seenSlots = Set() - for agent in agents { - guard (0 ... maxStatusAgents - 1).contains(agent.slot), - seenSlots.insert(agent.slot).inserted else { return false } - if let label = agent.label, !validStatusLabel(label) { return false } - } - return true - } - - private static func integer(_ value: Any?) -> Int? { - guard let number = value as? NSNumber, - CFGetTypeID(number) != CFBooleanGetTypeID(), - number.doubleValue.rounded(.towardZero) == number.doubleValue, - number.doubleValue >= Double(Int.min), - number.doubleValue <= Double(Int.max) else { return nil } - return number.intValue - } - - private static func rawObjectIsSafe(_ text: String) -> Bool { - let bytes = Array(text.utf8) - var objectDepth = 0 - var arrayDepth = 0 - var expectingTopLevelKey = false - var rootStarted = false - var seenKnownFields = Set() - var index = 0 - - while index < bytes.count { - switch bytes[index] { - case 0x22: - guard let end = scanJSONString(bytes, from: index) else { return false } - if objectDepth == 1, arrayDepth == 0, expectingTopLevelKey { - guard let key = try? JSONDecoder().decode( - String.self, - from: Data(bytes[index ..< end]) - ) else { return false } - if knownWireFields.contains(key), !seenKnownFields.insert(key).inserted { - return false - } - if (key == "v" || key == "ttl"), - !hasCanonicalUnsignedIntegerValue(bytes, afterKey: end) { - return false - } - expectingTopLevelKey = false - } - index = end - continue - case 0x7B: - objectDepth += 1 - if !rootStarted { - rootStarted = true - expectingTopLevelKey = true - } - case 0x7D: - objectDepth -= 1 - case 0x5B: - arrayDepth += 1 - case 0x5D: - arrayDepth -= 1 - case 0x2C where objectDepth == 1 && arrayDepth == 0: - expectingTopLevelKey = true - default: - break - } - if objectDepth + arrayDepth > 9 { return false } - index += 1 - } - - return rootStarted && objectDepth == 0 && arrayDepth == 0 - } - - private static func hasCanonicalUnsignedIntegerValue( - _ bytes: [UInt8], - afterKey start: Int - ) -> Bool { - var index = start - while index < bytes.count, isJSONWhitespace(bytes[index]) { index += 1 } - guard index < bytes.count, bytes[index] == 0x3A else { return false } - index += 1 - while index < bytes.count, isJSONWhitespace(bytes[index]) { index += 1 } - guard index < bytes.count, (0x30 ... 0x39).contains(bytes[index]) else { - return false - } - if bytes[index] == 0x30 { - index += 1 - } else { - while index < bytes.count, (0x30 ... 0x39).contains(bytes[index]) { - index += 1 - } - } - while index < bytes.count, isJSONWhitespace(bytes[index]) { index += 1 } - return index < bytes.count && (bytes[index] == 0x2C || bytes[index] == 0x7D) - } - - private static func isJSONWhitespace(_ byte: UInt8) -> Bool { - byte == 0x20 || byte == 0x09 || byte == 0x0A || byte == 0x0D - } - - private static func scanJSONString(_ bytes: [UInt8], from start: Int) -> Int? { - var index = start + 1 - while index < bytes.count { - switch bytes[index] { - case 0x22: - return index + 1 - case 0x00: - return nil - case 0x5C: - guard index + 1 < bytes.count else { return nil } - if bytes[index + 1] == 0x75 { - guard index + 5 < bytes.count else { return nil } - if bytes[(index + 2) ... (index + 5)].allSatisfy({ $0 == 0x30 }) { - return nil - } - index += 6 - } else { - index += 2 - } - continue - default: - index += 1 - } - } - return nil - } - - private static func jsonString(_ value: String) -> String { - let data = try! JSONSerialization.data(withJSONObject: [value]) - let array = String(decoding: data, as: UTF8.self) - return String(array.dropFirst().dropLast()) - } -} diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/Relay.swift b/devices/sdk/swift/Sources/NextingDeviceKit/Relay.swift deleted file mode 100644 index 86cb329..0000000 --- a/devices/sdk/swift/Sources/NextingDeviceKit/Relay.swift +++ /dev/null @@ -1,155 +0,0 @@ -import Foundation - -public struct NextingDeviceAnswerResult: Equatable, Sendable { - public let accepted: Bool - public let reason: String? - public let choice: NextingDeviceChoice? - - public init( - accepted: Bool, - reason: String?, - choice: NextingDeviceChoice? - ) { - self.accepted = accepted - self.reason = reason - self.choice = choice - } -} - -public final class NextingDevicePromptRelay { - private struct Pending { - let requestId: String - let deadlineMs: Int64 - var lockedDeviceChoice: NextingDeviceChoice? - var answerInFlight: Bool - } - - private let sendToDevice: (Data) -> Bool - private let answerPrompt: (NextingDeviceChoice) -> Void - private let nowMs: () -> Int64 - private let onResolve: ((String, NextingDeviceResolutionReason) -> Void)? - private var pending: Pending? - - public init( - sendToDevice: @escaping (Data) -> Bool, - answerPrompt: @escaping (NextingDeviceChoice) -> Void, - nowMs: @escaping () -> Int64 = { - Int64((ProcessInfo.processInfo.systemUptime * 1_000).rounded(.down)) - }, - onResolve: ((String, NextingDeviceResolutionReason) -> Void)? = nil - ) { - self.sendToDevice = sendToDevice - self.answerPrompt = answerPrompt - self.nowMs = nowMs - self.onResolve = onResolve - } - - public var hasPending: Bool { pending != nil } - public var pendingRequestId: String? { pending?.requestId } - - @discardableResult - public func present(requestId: String, summary: String, ttlMs: Int) -> Bool { - let message = NextingDeviceMessage.present( - requestId: requestId, - summary: summary, - ttlMs: ttlMs - ) - guard let wire = NextingDeviceCodec.encode(message) else { return false } - let (deadline, overflow) = nowMs().addingReportingOverflow(Int64(ttlMs)) - guard !overflow else { return false } - - if pending != nil { finish(.replaced) } - guard sendToDevice(wire) else { return false } - pending = Pending( - requestId: requestId, - deadlineMs: deadline, - lockedDeviceChoice: nil, - answerInFlight: false - ) - return true - } - - public func onDeviceAnswer( - requestId: String, - choice: NextingDeviceChoice, - deviceAuthorized: Bool - ) -> NextingDeviceAnswerResult { - guard deviceAuthorized else { - return .init(accepted: false, reason: "unauthorized", choice: nil) - } - guard var pending else { - return .init(accepted: false, reason: "no_pending", choice: nil) - } - guard requestId == pending.requestId else { - return .init(accepted: false, reason: "stale_or_unknown", choice: nil) - } - guard nowMs() < pending.deadlineMs else { - finish(.expired) - return .init(accepted: false, reason: "expired", choice: nil) - } - guard !pending.answerInFlight else { - return .init(accepted: false, reason: "answer_in_flight", choice: nil) - } - if let locked = pending.lockedDeviceChoice, locked != choice { - return .init(accepted: false, reason: "choice_locked", choice: nil) - } - - if pending.lockedDeviceChoice == nil { pending.lockedDeviceChoice = choice } - pending.answerInFlight = true - self.pending = pending - answerPrompt(choice) - return .init(accepted: true, reason: nil, choice: choice) - } - - @discardableResult - public func phoneAnswerStarted(requestId: String) -> Bool { - guard var pending, pending.requestId == requestId else { return false } - guard nowMs() < pending.deadlineMs else { - finish(.expired) - return false - } - guard !pending.answerInFlight else { return false } - pending.answerInFlight = true - self.pending = pending - return true - } - - public func answerSucceeded(requestId: String) { - guard pending?.requestId == requestId, - pending?.answerInFlight == true else { return } - finish(.answered) - } - - public func answerFailed(requestId: String) { - guard var pending, pending.requestId == requestId, - pending.answerInFlight else { return } - pending.answerInFlight = false - self.pending = pending - if nowMs() >= pending.deadlineMs { finish(.expired) } - } - - public func cancel(requestId: String? = nil) { - guard let pending else { return } - if requestId == nil || requestId == pending.requestId { finish(.cancelled) } - } - - public func tick() { - if let pending, !pending.answerInFlight, - nowMs() >= pending.deadlineMs { finish(.expired) } - } - - public func disconnect() { - pending = nil - } - - private func finish(_ reason: NextingDeviceResolutionReason) { - guard let pending else { return } - self.pending = nil - if let wire = NextingDeviceCodec.encode( - .resolved(requestId: pending.requestId, reason: reason) - ) { - _ = sendToDevice(wire) - } - onResolve?(pending.requestId, reason) - } -} diff --git a/devices/sdk/swift/Sources/NextingDeviceKit/State.swift b/devices/sdk/swift/Sources/NextingDeviceKit/State.swift deleted file mode 100644 index d980399..0000000 --- a/devices/sdk/swift/Sources/NextingDeviceKit/State.swift +++ /dev/null @@ -1,78 +0,0 @@ -import Foundation - -public struct NextingDeviceAnswerClaimGate: Sendable { - public private(set) var currentPromptID: String? - private var answerInFlight = false - private var hardwareSuppressed = false - - public init() {} - - @discardableResult - public mutating func observePrompt(_ promptID: String) -> Bool { - guard currentPromptID != promptID else { return false } - currentPromptID = promptID - answerInFlight = false - hardwareSuppressed = false - return true - } - - public func mayPresentHardware(for promptID: String) -> Bool { - currentPromptID == promptID && !hardwareSuppressed - } - - public mutating func claimPhoneAnswer(for promptID: String) -> Bool { - claimAnswer(for: promptID) - } - - public mutating func claimDeviceAnswer(for promptID: String) -> Bool { - claimAnswer(for: promptID) - } - - public mutating func answerFailed(for promptID: String) { - guard currentPromptID == promptID else { return } - answerInFlight = false - } - - public mutating func suppressHardware(for promptID: String) { - guard currentPromptID == promptID else { return } - hardwareSuppressed = true - } - - public mutating func clearPrompt(_ promptID: String) { - guard currentPromptID == promptID else { return } - self = NextingDeviceAnswerClaimGate() - } - - private mutating func claimAnswer(for promptID: String) -> Bool { - guard currentPromptID == promptID, !answerInFlight else { return false } - answerInFlight = true - hardwareSuppressed = true - return true - } -} - -struct NextingDeviceDiscoveryRecoveryState { - private var quarantinedIDs: Set = [] - - func canAttempt(_ id: UUID) -> Bool { - !quarantinedIDs.contains(id) - } - - mutating func quarantine(_ id: UUID) { - quarantinedIDs.insert(id) - } - - mutating func beginExplicitScanCycle() { - quarantinedIDs.removeAll() - } - - mutating func connectionDidEnd( - _ id: UUID, - wasReady: Bool, - shouldScan: Bool - ) -> Bool { - guard shouldScan else { return false } - if !wasReady { quarantine(id) } - return true - } -} diff --git a/devices/sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift b/devices/sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift deleted file mode 100644 index 2022740..0000000 --- a/devices/sdk/swift/Tests/NextingDeviceKitTests/DeviceInfoTests.swift +++ /dev/null @@ -1,145 +0,0 @@ -import Foundation -import Testing -@testable import NextingDeviceKit - -private struct DeviceInfoVectors: Decodable { - struct Valid: Decodable { - struct Expected: Decodable { - let model: String - let statusSlots: Int - let deviceId: String? - let buttonCount: Int? - let batteryService: Bool - let vendorNamespace: String? - } - - let name: String - let wire: String - let decoded: Expected - } - - struct InvalidCore: Decodable { - let name: String - let wire: String - } - - struct InvalidVendor: Decodable { - let name: String - let vendor: JSONValue - } - - let spec: String - let wire: Int - let valid: [Valid] - let invalidCore: [InvalidCore] - let invalidVendor: [InvalidVendor] -} - -private enum JSONValue: Codable { - case string(String) - case integer(Int) - case array([JSONValue]) - case object([String: JSONValue]) - case bool(Bool) - case null - - init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - } else if let value = try? container.decode(Bool.self) { - self = .bool(value) - } else if let value = try? container.decode(Int.self) { - self = .integer(value) - } else if let value = try? container.decode(String.self) { - self = .string(value) - } else if let value = try? container.decode([JSONValue].self) { - self = .array(value) - } else { - self = .object(try container.decode([String: JSONValue].self)) - } - } - - func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case let .string(value): try container.encode(value) - case let .integer(value): try container.encode(value) - case let .array(value): try container.encode(value) - case let .object(value): try container.encode(value) - case let .bool(value): try container.encode(value) - case .null: try container.encodeNil() - } - } -} - -private func loadDeviceInfoVectors() throws -> DeviceInfoVectors { - let testFile = URL(fileURLWithPath: #filePath) - let repository = testFile - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - .deletingLastPathComponent() - return try JSONDecoder().decode( - DeviceInfoVectors.self, - from: Data(contentsOf: repository.appendingPathComponent( - "protocol/vectors/device-info-v1.json" - )) - ) -} - -@Test("Device Info 0.2 shared vectors normalize identically") -func deviceInfoSharedVectors() throws { - let vectors = try loadDeviceInfoVectors() - #expect(vectors.spec == "0.2.0-experimental.0") - #expect(vectors.wire == 1) - - for item in vectors.valid { - let info = try #require( - NextingDeviceInfo.decode(Data(item.wire.utf8)), - Comment(rawValue: item.name) - ) - #expect(info.model == item.decoded.model) - #expect(info.statusSlots == item.decoded.statusSlots) - #expect(info.identity.deviceId?.uuidString.lowercased() == item.decoded.deviceId) - #expect(info.capabilities.buttonCount == item.decoded.buttonCount) - #expect(info.capabilities.batteryService == item.decoded.batteryService) - #expect(info.vendor?.namespace == item.decoded.vendorNamespace) - } - - for item in vectors.invalidCore { - #expect(NextingDeviceInfo.decode(Data(item.wire.utf8)) == nil, Comment(rawValue: item.name)) - } -} - -@Test("invalid vendor facts are dropped without rejecting valid core") -func invalidVendorIsLossy() throws { - let vectors = try loadDeviceInfoVectors() - let core = try #require( - JSONSerialization.jsonObject(with: Data(vectors.valid[0].wire.utf8)) - as? [String: Any] - ) - - for item in vectors.invalidVendor { - let encodedVendor = try JSONEncoder().encode(item.vendor) - var payload = core - payload["vendor"] = try JSONSerialization.jsonObject(with: encodedVendor) - let wire = try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) - let info = try #require( - NextingDeviceInfo.decode(wire), - Comment(rawValue: item.name) - ) - #expect(info.vendor == nil) - } -} - -@Test("standard Battery Level parsing is bounded and optional") -func batteryLevelParsing() { - #expect(NextingDeviceBattery.decodeLevel(Data([0])) == 0) - #expect(NextingDeviceBattery.decodeLevel(Data([55])) == 55) - #expect(NextingDeviceBattery.decodeLevel(Data([100])) == 100) - #expect(NextingDeviceBattery.decodeLevel(Data([101])) == 100) - #expect(NextingDeviceBattery.decodeLevel(Data()) == nil) - #expect(NextingDeviceBattery.decodeLevel(Data([1, 2])) == nil) -} diff --git a/devices/sdk/swift/Tests/NextingDeviceKitTests/IntegrationTests.swift b/devices/sdk/swift/Tests/NextingDeviceKitTests/IntegrationTests.swift deleted file mode 100644 index d6f68e0..0000000 --- a/devices/sdk/swift/Tests/NextingDeviceKitTests/IntegrationTests.swift +++ /dev/null @@ -1,552 +0,0 @@ -import Foundation -import Testing -@testable import NextingDeviceKit - -@Test("authorization defaults to deny and persists authorize revoke") -func authorizationPersistence() throws { - let suite = "NextingDeviceAuthorizationTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - defaults.removePersistentDomain(forName: suite) - let store = NextingDeviceAuthorizationStore(defaults: defaults) - let id = UUID() - - #expect(!store.isAuthorized(id)) - store.authorize(id) - #expect(store.isAuthorized(id)) - #expect(NextingDeviceAuthorizationStore(defaults: defaults).isAuthorized(id)) - store.revoke(id) - #expect(!store.isAuthorized(id)) -} - -@Test("release policy never trusts a peripheral name") -func authorizationNamePolicy() throws { - let suite = "NextingDeviceAuthorizationTests.\(UUID().uuidString)" - let defaults = try #require(UserDefaults(suiteName: suite)) - defer { defaults.removePersistentDomain(forName: suite) } - let store = NextingDeviceAuthorizationStore(defaults: defaults) - let simulator = NextingDevicePeripheralIdentity(id: UUID(), name: "Nexting-Device-Sim") - - #expect( - !NextingDeviceAuthorizationPolicy( - store: store, - allowDebugTestDevice: false - ).isAuthorized(simulator) - ) - #expect( - NextingDeviceAuthorizationPolicy( - store: store, - allowDebugTestDevice: true - ).isAuthorized(simulator) - ) - #expect( - !NextingDeviceAuthorizationPolicy( - store: store, - allowDebugTestDevice: true - ).isAuthorized(.init(id: UUID(), name: "Nearby-Device")) - ) -} - -@Test("phone and device share a single claim gate") -func answerClaimGate() { - var gate = NextingDeviceAnswerClaimGate() - let observed = gate.observePrompt("p1") - #expect(observed) - #expect(gate.mayPresentHardware(for: "p1")) - let deviceClaim = gate.claimDeviceAnswer(for: "p1") - #expect(deviceClaim) - let blockedPhoneClaim = gate.claimPhoneAnswer(for: "p1") - #expect(!blockedPhoneClaim) - gate.answerFailed(for: "p1") - #expect(!gate.mayPresentHardware(for: "p1")) - let phoneRetry = gate.claimPhoneAnswer(for: "p1") - #expect(phoneRetry) - gate.clearPrompt("p1") - let staleClaim = gate.claimPhoneAnswer(for: "p1") - #expect(!staleClaim) -} - -@Test("discovery recovery quarantines malformed setup only for one scan cycle") -func discoveryRecovery() { - let malformed = UUID() - let ready = UUID() - var state = NextingDeviceDiscoveryRecoveryState() - - let resumesAfterMalformed = state.connectionDidEnd( - malformed, - wasReady: false, - shouldScan: true - ) - #expect(resumesAfterMalformed) - #expect(!state.canAttempt(malformed)) - let resumesAfterReady = state.connectionDidEnd( - ready, - wasReady: true, - shouldScan: true - ) - #expect(resumesAfterReady) - #expect(state.canAttempt(ready)) - state.beginExplicitScanCycle() - #expect(state.canAttempt(malformed)) -} - -@Test("device info read errors and malformed values require setup recovery") -func deviceInfoReadTransition() throws { - let valid = Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":512,\"max_summary_bytes\":64}".utf8 - ) - - #expect( - NextingDeviceSetupTransition.deviceInfoRead( - data: valid, - hadError: true - ) == .recover - ) - #expect( - NextingDeviceSetupTransition.deviceInfoRead( - data: Data("not-json".utf8), - hadError: false - ) == .recover - ) - let accepted = NextingDeviceSetupTransition.deviceInfoRead( - data: valid, - hadError: false - ) - guard case let .accept(info) = accepted else { - Issue.record("valid Device Info should be accepted") - return - } - #expect(info.maxMessageBytes == 512) - #expect(info.maxSummaryBytes == 64) -} - -@Test("discovery disconnect resumes only when scanning is still requested") -func discoveryDisconnectTransition() { - let id = UUID() - var state = NextingDeviceDiscoveryRecoveryState() - let stopped = state.connectionDidEnd(id, wasReady: false, shouldScan: false) - #expect(!stopped) - let resumed = state.connectionDidEnd(id, wasReady: false, shouldScan: true) - #expect(resumed) - #expect(!state.canAttempt(id)) -} - -@Test("transport policy enforces negotiated message and UTF-8 summary limits") -func transportPolicyLimits() throws { - let info = try #require(NextingDeviceInfo.decode(Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":512,\"max_summary_bytes\":64}".utf8 - ))) - let summary = NextingDeviceTransportPolicy.boundedSummary( - String(repeating: "你", count: 30), - deviceInfo: info - ) - - #expect(summary.utf8.count == 63) - #expect(NextingDeviceTransportPolicy.accepts(frameByteCount: 512, deviceInfo: info)) - #expect(!NextingDeviceTransportPolicy.accepts(frameByteCount: 513, deviceInfo: info)) -} - -@Test("write queue is byte bounded and advances only after acknowledgements") -func boundedWriteQueue() { - var queue = NextingDeviceWriteQueue(maxPendingBytes: 8) - let firstEnqueue = queue.enqueue(frame: Data([1, 2, 3, 4])) - #expect(firstEnqueue) - let secondEnqueue = queue.enqueue(frame: Data([5, 6, 7, 8])) - #expect(secondEnqueue) - let overflowEnqueue = queue.enqueue(frame: Data([9])) - #expect(!overflowEnqueue) - #expect(queue.pendingByteCount == 8) - - let firstChunk = queue.nextChunk(maxBytes: 3) - #expect(firstChunk == Data([1, 2, 3])) - let blockedChunk = queue.nextChunk(maxBytes: 3) - #expect(blockedChunk == nil) - let firstAcknowledgement = queue.acknowledgeInFlightChunk() - #expect(firstAcknowledgement) - #expect(queue.pendingByteCount == 5) - let firstFrameRemainder = queue.nextChunk(maxBytes: 3) - #expect(firstFrameRemainder == Data([4])) - let firstFrameComplete = queue.acknowledgeInFlightChunk() - #expect(firstFrameComplete) - let secondFrameStart = queue.nextChunk(maxBytes: 3) - #expect(secondFrameStart == Data([5, 6, 7])) - let secondFrameStartAcknowledged = queue.acknowledgeInFlightChunk() - #expect(secondFrameStartAcknowledged) - let secondFrameRemainder = queue.nextChunk(maxBytes: 3) - #expect(secondFrameRemainder == Data([8])) - let secondFrameComplete = queue.acknowledgeInFlightChunk() - #expect(secondFrameComplete) - #expect(queue.pendingByteCount == 0) - let emptyChunk = queue.nextChunk(maxBytes: 3) - #expect(emptyChunk == nil) - let staleAcknowledgement = queue.acknowledgeInFlightChunk() - #expect(!staleAcknowledgement) -} - -@Test("write queue also bounds retained frame objects") -func writeQueueBoundsFrameCount() { - var queue = NextingDeviceWriteQueue( - maxPendingBytes: 128, - maxPendingFrames: 2 - ) - let first = queue.enqueue(frame: Data([1])) - let second = queue.enqueue(frame: Data([2])) - let overflow = queue.enqueue(frame: Data([3])) - #expect(first) - #expect(second) - #expect(!overflow) - #expect(queue.pendingByteCount == 2) -} - -@Test("device info requires compatible wire profile and safe bounds") -func deviceInfoValidation() { - let valid = Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240}".utf8 - ) - let info = NextingDeviceInfo.decode(valid) - #expect(info?.model == "xiao-ref") - #expect(info?.supportsApprovalV1 == true) - #expect(info?.maxMessageBytes == 4096) - - let tooSmall = Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":128,\"max_summary_bytes\":240}".utf8 - ) - #expect(NextingDeviceInfo.decode(tooSmall) == nil) - - let highCapacity = Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":1000000,\"max_summary_bytes\":240}".utf8 - ) - #expect(NextingDeviceInfo.decode(highCapacity)?.maxMessageBytes == 1_000_000) - - for controlValue in [ - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao\\u0000ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240}", - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\\nunsafe\",\"max_message_bytes\":4096,\"max_summary_bytes\":240}", - ] { - #expect(NextingDeviceInfo.decode(Data(controlValue.utf8)) == nil) - } - - for incompatible in [ - "{\"protocol\":\"other\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240}", - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[2],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240}", - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"future/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240}", - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":0}", - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":241}", - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096}", - ] { - #expect(NextingDeviceInfo.decode(Data(incompatible.utf8)) == nil) - } -} - -private final class FakeTransport: NextingDeviceRelayTransport { - var onMessage: ((NextingDevicePeripheralIdentity, NextingDeviceMessage) -> Void)? - var onConnectedChange: ((Bool) -> Void)? - var connectedIdentity: NextingDevicePeripheralIdentity? - var connectedDeviceInfo: NextingDeviceInfo? - var isReady = false - var sent: [NextingDeviceMessage] = [] - var scanCount = 0 - var disconnectCount = 0 - var acceptsSend = true - - func send(_ data: Data) -> Bool { - guard acceptsSend else { return false } - if let message = NextingDeviceCodec.decode(data) { sent.append(message) } - return true - } - - func startScan() { scanCount += 1 } - - func disconnect() { - disconnectCount += 1 - isReady = false - connectedIdentity = nil - onConnectedChange?(false) - } - - func connect(_ identity: NextingDevicePeripheralIdentity) { - connectedIdentity = identity - isReady = true - onConnectedChange?(true) - } - - func loseConnection() { - isReady = false - connectedIdentity = nil - onConnectedChange?(false) - } - - func receive( - _ message: NextingDeviceMessage, - from identity: NextingDevicePeripheralIdentity - ) { - onMessage?(identity, message) - } -} - -@Test("coordinator reports a rejected Present as not presented") -func coordinatorRollsBackRejectedPresent() { - let transport = FakeTransport() - let trusted = NextingDevicePeripheralIdentity(id: UUID(), name: "Trusted") - let coordinator = NextingDeviceRelayCoordinator( - transport: transport, - isAuthorized: { $0.id == trusted.id }, - nowMs: { 1_000 }, - requestId: { "opaque-1" }, - answerPrompt: { _, _ in } - ) - transport.connect(trusted) - transport.acceptsSend = false - - #expect( - coordinator.present( - context: .init(prompt: "private"), - summary: "Allow?", - ttlMs: 30_000 - ) == nil - ) - #expect(transport.sent.isEmpty) -} - -private struct PromptContext: Equatable, Sendable { - let prompt: String -} - -@Test("coordinator never sends summary to unauthorized transport") -func coordinatorAuthorization() { - let transport = FakeTransport() - let trusted = NextingDevicePeripheralIdentity(id: UUID(), name: "Trusted") - let attacker = NextingDevicePeripheralIdentity(id: UUID(), name: "Attacker") - var answers: [(PromptContext, NextingDeviceChoice)] = [] - let coordinator = NextingDeviceRelayCoordinator( - transport: transport, - isAuthorized: { $0.id == trusted.id }, - nowMs: { 1_000 }, - requestId: { "opaque-1" }, - answerPrompt: { answers.append(($0, $1)) } - ) - - transport.connect(attacker) - coordinator.present(context: .init(prompt: "private"), summary: "secret", ttlMs: 30_000) - transport.receive(.answer(requestId: "opaque-1", choice: .allow), from: attacker) - #expect(transport.sent.isEmpty) - #expect(answers.isEmpty) -} - -@Test("coordinator truncates Present summary to the connected device limit") -func coordinatorUsesNegotiatedSummaryLimit() throws { - let transport = FakeTransport() - let info = try #require(NextingDeviceInfo.decode(Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":512,\"max_summary_bytes\":64}".utf8 - ))) - transport.connectedDeviceInfo = info - let trusted = NextingDevicePeripheralIdentity(id: UUID(), name: "Trusted") - let coordinator = NextingDeviceRelayCoordinator( - transport: transport, - isAuthorized: { $0.id == trusted.id }, - nowMs: { 1_000 }, - requestId: { "opaque-1" }, - answerPrompt: { _, _ in } - ) - transport.connect(trusted) - - coordinator.present( - context: .init(prompt: "private"), - summary: String(repeating: "你", count: 30), - ttlMs: 30_000 - ) - - guard case let .present(_, summary, _)? = transport.sent.last else { - Issue.record("expected a bounded Present") - return - } - #expect(summary.utf8.count == 63) -} - -@Test("coordinator fits Present within the negotiated logical message size") -func coordinatorFitsPresentWithinLogicalLimit() throws { - let transport = FakeTransport() - let info = try #require(NextingDeviceInfo.decode(Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":512,\"max_summary_bytes\":240}".utf8 - ))) - transport.connectedDeviceInfo = info - let trusted = NextingDevicePeripheralIdentity(id: UUID(), name: "Trusted") - let coordinator = NextingDeviceRelayCoordinator( - transport: transport, - isAuthorized: { $0.id == trusted.id }, - nowMs: { 1_000 }, - requestId: { String(repeating: "r", count: 64) }, - answerPrompt: { _, _ in } - ) - transport.connect(trusted) - - let requestID = coordinator.present( - context: .init(prompt: "private"), - summary: String(repeating: "\"", count: 240), - ttlMs: 30_000 - ) - - #expect(requestID != nil) - guard case let .present(_, summary, _)? = transport.sent.last, - let wire = NextingDeviceCodec.encode(transport.sent.last!) else { - Issue.record("expected a fitted Present") - return - } - #expect(summary.utf8.count < 240) - #expect(wire.count <= 512) -} - -@Test("coordinator resolves an opaque answer only after API success") -func coordinatorAnswerUsesTwoPhaseCommit() { - let transport = FakeTransport() - let trusted = NextingDevicePeripheralIdentity(id: UUID(), name: "Trusted") - var answers: [(PromptContext, NextingDeviceChoice)] = [] - let coordinator = NextingDeviceRelayCoordinator( - transport: transport, - isAuthorized: { $0.id == trusted.id }, - nowMs: { 1_000 }, - requestId: { "opaque-1" }, - answerPrompt: { answers.append(($0, $1)) } - ) - transport.connect(trusted) - - coordinator.present( - context: .init(prompt: "internal-prompt-id"), - summary: "Allow?", - ttlMs: 30_000 - ) - transport.receive(.answer(requestId: "opaque-1", choice: .allow), from: trusted) - - #expect(answers.map(\.0) == [.init(prompt: "internal-prompt-id")]) - #expect(answers.map(\.1) == [.allow]) - #expect(transport.sent.last == .present(requestId: "opaque-1", summary: "Allow?", ttlMs: 30_000)) - - coordinator.answerFailed(requestId: "opaque-1") - transport.receive(.answer(requestId: "opaque-1", choice: .allow), from: trusted) - #expect(answers.map(\.1) == [.allow, .allow]) - coordinator.answerSucceeded(requestId: "stale") - #expect(transport.sent.last == .present(requestId: "opaque-1", summary: "Allow?", ttlMs: 30_000)) - coordinator.answerSucceeded(requestId: "opaque-1") - #expect(transport.sent.last == .resolved(requestId: "opaque-1", reason: .answered)) -} - -@Test("coordinator phone claim blocks hardware until API success") -func coordinatorPhoneAnswerUsesTwoPhaseCommit() { - let transport = FakeTransport() - let trusted = NextingDevicePeripheralIdentity(id: UUID(), name: "Trusted") - var deviceAnswers: [NextingDeviceChoice] = [] - let coordinator = NextingDeviceRelayCoordinator( - transport: transport, - isAuthorized: { $0.id == trusted.id }, - nowMs: { 1_000 }, - requestId: { "opaque-1" }, - answerPrompt: { _, choice in deviceAnswers.append(choice) } - ) - transport.connect(trusted) - coordinator.present( - context: .init(prompt: "internal-prompt-id"), - summary: "Allow?", - ttlMs: 30_000 - ) - - #expect(coordinator.phoneAnswerStarted() == "opaque-1") - transport.receive(.answer(requestId: "opaque-1", choice: .deny), from: trusted) - #expect(deviceAnswers.isEmpty) - #expect(transport.sent.count == 1) - coordinator.answerSucceeded(requestId: "opaque-1") - #expect(transport.sent.last == .resolved(requestId: "opaque-1", reason: .answered)) -} - -@Test("coordinator ignores completion without an answer claim") -func coordinatorRequiresAnswerClaimBeforeCompletion() { - let transport = FakeTransport() - let trusted = NextingDevicePeripheralIdentity(id: UUID(), name: "Trusted") - var answers: [NextingDeviceChoice] = [] - let coordinator = NextingDeviceRelayCoordinator( - transport: transport, - isAuthorized: { $0.id == trusted.id }, - nowMs: { 1_000 }, - requestId: { "opaque-1" }, - answerPrompt: { _, choice in answers.append(choice) } - ) - transport.connect(trusted) - coordinator.present( - context: .init(prompt: "internal-prompt-id"), - summary: "Allow?", - ttlMs: 30_000 - ) - - coordinator.answerSucceeded(requestId: "opaque-1") - transport.receive(.answer(requestId: "opaque-1", choice: .allow), from: trusted) - - #expect(answers == [.allow]) - coordinator.answerSucceeded(requestId: "opaque-1") - #expect(transport.sent.last == .resolved(requestId: "opaque-1", reason: .answered)) -} - -@Test("coordinator does not re-present while Agent commit is in flight") -func coordinatorHoldsInFlightAnswerAcrossReconnect() { - let transport = FakeTransport() - let trusted = NextingDevicePeripheralIdentity(id: UUID(), name: "Trusted") - var answers: [NextingDeviceChoice] = [] - let coordinator = NextingDeviceRelayCoordinator( - transport: transport, - isAuthorized: { $0.id == trusted.id }, - nowMs: { 1_000 }, - requestId: { "opaque-1" }, - answerPrompt: { _, choice in answers.append(choice) } - ) - transport.connect(trusted) - coordinator.present( - context: .init(prompt: "internal-prompt-id"), - summary: "Allow?", - ttlMs: 30_000 - ) - transport.receive(.answer(requestId: "opaque-1", choice: .allow), from: trusted) - - transport.loseConnection() - transport.connect(trusted) - #expect(transport.sent.filter { message in - if case .present = message { return true } - return false - }.count == 1) - - coordinator.answerFailed(requestId: "opaque-1") - #expect(transport.sent.filter { message in - if case .present = message { return true } - return false - }.count == 2) - transport.receive(.answer(requestId: "opaque-1", choice: .deny), from: trusted) - #expect(answers == [.allow]) - transport.receive(.answer(requestId: "opaque-1", choice: .allow), from: trusted) - #expect(answers == [.allow, .allow]) -} - -@Test("coordinator defers and re-presents only the remaining TTL after reconnect") -func coordinatorReconnect() { - let transport = FakeTransport() - let trusted = NextingDevicePeripheralIdentity(id: UUID(), name: "Trusted") - var nowMs: Int64 = 1_000 - let coordinator = NextingDeviceRelayCoordinator( - transport: transport, - isAuthorized: { $0.id == trusted.id }, - nowMs: { nowMs }, - requestId: { "opaque-1" }, - answerPrompt: { _, _ in } - ) - - coordinator.present(context: .init(prompt: "p1"), summary: "Allow?", ttlMs: 30_000) - nowMs = 6_000 - transport.connect(trusted) - #expect(transport.sent.last == .present(requestId: "opaque-1", summary: "Allow?", ttlMs: 25_000)) - - transport.loseConnection() - nowMs = 7_000 - transport.connect(trusted) - #expect(transport.sent.last == .present(requestId: "opaque-1", summary: "Allow?", ttlMs: 24_000)) -} - -@Test("physical choices always map to explicit one-time allow and deny") -func optionMapping() { - #expect(NextingDeviceOptionMapping.index(for: .allow) == 0) - #expect(NextingDeviceOptionMapping.index(for: .deny) == 1) -} diff --git a/devices/sdk/swift/Tests/NextingDeviceKitTests/RelayTests.swift b/devices/sdk/swift/Tests/NextingDeviceKitTests/RelayTests.swift deleted file mode 100644 index 0ff6b90..0000000 --- a/devices/sdk/swift/Tests/NextingDeviceKitTests/RelayTests.swift +++ /dev/null @@ -1,224 +0,0 @@ -import Foundation -import Testing -@testable import NextingDeviceKit - -private final class RelayHarness { - var nowMs: Int64 = 1_000 - var sent: [NextingDeviceMessage] = [] - var answers: [NextingDeviceChoice] = [] - lazy var relay = NextingDevicePromptRelay( - sendToDevice: { [weak self] data in - if let message = NextingDeviceCodec.decode(data) { - self?.sent.append(message) - } - return true - }, - answerPrompt: { [weak self] choice in self?.answers.append(choice) }, - nowMs: { [weak self] in self?.nowMs ?? 0 } - ) -} - -@Test("present tracks and sends one approval") -func relayPresent() { - let harness = RelayHarness() - #expect( - harness.relay.present( - requestId: "r1", - summary: "Allow push?", - ttlMs: 30_000 - ) - ) - #expect(harness.relay.hasPending) - #expect(harness.relay.pendingRequestId == "r1") - #expect( - harness.sent == [ - .present(requestId: "r1", summary: "Allow push?", ttlMs: 30_000), - ] - ) -} - -@Test("authorized answer resolves only after commit and retries after failure") -func relayDeviceAnswerUsesTwoPhaseCommit() { - let harness = RelayHarness() - harness.relay.present(requestId: "r1", summary: "Allow?", ttlMs: 30_000) - - #expect( - harness.relay.onDeviceAnswer( - requestId: "r1", - choice: .allow, - deviceAuthorized: true - ) == .init(accepted: true, reason: nil, choice: .allow) - ) - #expect(harness.answers == [.allow]) - #expect(harness.sent.count == 1) - #expect(harness.relay.hasPending) - - harness.relay.answerFailed(requestId: "r1") - #expect( - harness.relay.onDeviceAnswer( - requestId: "r1", - choice: .deny, - deviceAuthorized: true - ).reason == "choice_locked" - ) - #expect( - harness.relay.onDeviceAnswer( - requestId: "r1", - choice: .allow, - deviceAuthorized: true - ).accepted - ) - #expect(harness.answers == [.allow, .allow]) - #expect(harness.sent.count == 1) - - harness.relay.answerSucceeded(requestId: "stale") - #expect(harness.relay.hasPending) - harness.relay.answerSucceeded(requestId: "r1") - #expect(harness.sent.last == .resolved(requestId: "r1", reason: .answered)) - #expect(!harness.relay.hasPending) -} - -@Test("unauthorized and stale answers fail closed") -func relayRejectsUnauthorizedAndStale() { - let harness = RelayHarness() - harness.relay.present(requestId: "r1", summary: "Allow?", ttlMs: 30_000) - - #expect( - harness.relay.onDeviceAnswer( - requestId: "r1", - choice: .allow, - deviceAuthorized: false - ).reason == "unauthorized" - ) - #expect( - harness.relay.onDeviceAnswer( - requestId: "stale", - choice: .allow, - deviceAuthorized: true - ).reason == "stale_or_unknown" - ) - #expect(harness.answers.isEmpty) - #expect(harness.relay.hasPending) -} - -@Test("replacement resolves old request and keeps new request") -func relayReplacement() { - let harness = RelayHarness() - harness.relay.present(requestId: "r1", summary: "first", ttlMs: 30_000) - harness.relay.present(requestId: "r2", summary: "second", ttlMs: 30_000) - - #expect(harness.sent[1] == .resolved(requestId: "r1", reason: .replaced)) - #expect(harness.relay.pendingRequestId == "r2") -} - -@Test("phone-first race resolves hardware only after API success") -func relayPhoneFirst() { - let harness = RelayHarness() - harness.relay.present(requestId: "r1", summary: "Allow?", ttlMs: 30_000) - #expect(harness.relay.phoneAnswerStarted(requestId: "r1")) - - #expect(harness.sent.count == 1) - #expect( - harness.relay.onDeviceAnswer( - requestId: "r1", - choice: .deny, - deviceAuthorized: true - ).reason == "answer_in_flight" - ) - #expect(harness.answers.isEmpty) - - harness.relay.answerFailed(requestId: "r1") - #expect( - harness.relay.onDeviceAnswer( - requestId: "r1", - choice: .deny, - deviceAuthorized: true - ).accepted - ) - harness.relay.answerSucceeded(requestId: "r1") - #expect(harness.sent.last == .resolved(requestId: "r1", reason: .answered)) -} - -@Test("expiry uses the boundary millisecond and fails closed") -func relayExpiry() { - let harness = RelayHarness() - harness.relay.present(requestId: "r1", summary: "Allow?", ttlMs: 10) - harness.nowMs = 1_010 - - #expect( - harness.relay.onDeviceAnswer( - requestId: "r1", - choice: .allow, - deviceAuthorized: true - ).reason == "expired" - ) - #expect(harness.sent.last == .resolved(requestId: "r1", reason: .expired)) - #expect(harness.answers.isEmpty) -} - -@Test("an in-flight answer may succeed after its original deadline") -func relayInFlightSuccessAfterDeadline() { - let harness = RelayHarness() - harness.relay.present(requestId: "r1", summary: "Allow?", ttlMs: 10) - #expect( - harness.relay.onDeviceAnswer( - requestId: "r1", - choice: .allow, - deviceAuthorized: true - ).accepted - ) - harness.nowMs = 1_010 - harness.relay.tick() - #expect(harness.sent.count == 1) - harness.relay.answerSucceeded(requestId: "r1") - #expect(harness.sent.last == .resolved(requestId: "r1", reason: .answered)) -} - -@Test("a failed in-flight answer expires when its deadline has passed") -func relayInFlightFailureAfterDeadline() { - let harness = RelayHarness() - harness.relay.present(requestId: "r1", summary: "Allow?", ttlMs: 10) - _ = harness.relay.onDeviceAnswer( - requestId: "r1", - choice: .allow, - deviceAuthorized: true - ) - harness.nowMs = 1_010 - harness.relay.answerFailed(requestId: "r1") - #expect(harness.sent.last == .resolved(requestId: "r1", reason: .expired)) -} - -@Test("tick cancel and disconnect clean up deterministically") -func relayCleanup() { - let expiry = RelayHarness() - expiry.relay.present(requestId: "r1", summary: "Allow?", ttlMs: 10) - expiry.nowMs = 1_010 - expiry.relay.tick() - #expect(expiry.sent.last == .resolved(requestId: "r1", reason: .expired)) - - let cancel = RelayHarness() - cancel.relay.present(requestId: "r2", summary: "Allow?", ttlMs: 10) - cancel.relay.cancel(requestId: "r2") - #expect(cancel.sent.last == .resolved(requestId: "r2", reason: .cancelled)) - - let disconnect = RelayHarness() - disconnect.relay.present(requestId: "r3", summary: "Allow?", ttlMs: 10) - disconnect.relay.disconnect() - #expect(!disconnect.relay.hasPending) - #expect(disconnect.sent.count == 1) -} - -@Test("invalid replacement leaves current request untouched") -func relayInvalidPresent() { - let harness = RelayHarness() - harness.relay.present(requestId: "r1", summary: "valid", ttlMs: 30_000) - #expect( - !harness.relay.present( - requestId: "bad id", - summary: "invalid", - ttlMs: 30_000 - ) - ) - #expect(harness.relay.pendingRequestId == "r1") - #expect(harness.sent.count == 1) -} diff --git a/devices/sdk/swift/Tests/NextingDeviceKitTests/StatusTests.swift b/devices/sdk/swift/Tests/NextingDeviceKitTests/StatusTests.swift deleted file mode 100644 index a7ad8ef..0000000 --- a/devices/sdk/swift/Tests/NextingDeviceKitTests/StatusTests.swift +++ /dev/null @@ -1,232 +0,0 @@ -import Foundation -import Testing -@testable import NextingDeviceKit - -private struct StatusVectorDocument: Decodable { - let spec: String - let wire: Int - let profile: String - let valid: [StatusValidVector] - let invalid: [String] -} - -private struct StatusValidVector: Decodable { - let wire: String - let decoded: StatusDecodedVector -} - -private struct StatusDecodedVector: Decodable { - struct Agent: Decodable { - let slot: Int - let state: String - let label: String? - } - - let type: String - let agents: [Agent] - - var message: NextingDeviceMessage { - get throws { - #expect(type == "status") - return .status(agents: try agents.map { agent in - NextingDeviceAgentStatus( - slot: agent.slot, - state: try #require(NextingDeviceAgentState(rawValue: agent.state)), - label: agent.label - ) - }) - } - } -} - -private func loadStatusVectors() throws -> StatusVectorDocument { - let testFile = URL(fileURLWithPath: #filePath) - let repository = testFile - .deletingLastPathComponent() // NextingDeviceKitTests - .deletingLastPathComponent() // Tests - .deletingLastPathComponent() // swift - .deletingLastPathComponent() // sdk - .deletingLastPathComponent() // nexting-devices - let url = repository.appendingPathComponent( - "protocol/vectors/status-v1.json", - isDirectory: false - ) - return try JSONDecoder().decode( - StatusVectorDocument.self, - from: Data(contentsOf: url) - ) -} - -@Test("status/1 shared vectors round-trip canonically") -func statusVectorsRoundTrip() throws { - let vectors = try loadStatusVectors() - #expect(vectors.spec == "0.1.0-experimental") - #expect(vectors.wire == 1) - #expect(vectors.profile == "status/1") - - for item in vectors.valid { - let expected = try item.decoded.message - #expect(NextingDeviceCodec.decode(Data(item.wire.utf8)) == expected) - #expect(NextingDeviceCodec.encode(expected) == Data(item.wire.utf8)) - } - for wire in vectors.invalid { - #expect(NextingDeviceCodec.decode(Data(wire.utf8)) == nil) - } -} - -@Test("status/1 vectors cover every state and both boundaries") -func statusVectorCoverage() throws { - let vectors = try loadStatusVectors() - let states = Set(vectors.valid.flatMap { $0.decoded.agents.map(\.state) }) - #expect( - states == ["idle", "thinking", "working", "complete", "needs_input", "error"] - ) - #expect(vectors.valid.contains { $0.decoded.agents.isEmpty }) - #expect( - vectors.valid.contains { - $0.decoded.agents.count == NextingDeviceCodec.maxStatusAgents - } - ) - #expect( - vectors.valid.contains { item in - item.decoded.agents.contains { - $0.label.map { $0.utf8.count == NextingDeviceCodec.maxStatusLabelBytes } - ?? false - } - } - ) - #expect(vectors.invalid.count >= 8) -} - -@Test("status encode rejects bad shapes") -func statusEncodeRejectsBadShapes() { - #expect( - NextingDeviceCodec.encode( - .status(agents: [.init(slot: 8, state: .idle)]) - ) == nil - ) - #expect( - NextingDeviceCodec.encode( - .status(agents: [ - .init(slot: 0, state: .idle), - .init(slot: 0, state: .working), - ]) - ) == nil - ) - #expect( - NextingDeviceCodec.encode( - .status(agents: [ - .init(slot: 0, state: .idle, label: String(repeating: "x", count: 65)), - ]) - ) == nil - ) - #expect( - NextingDeviceCodec.encode( - .status(agents: [.init(slot: 0, state: .idle, label: "bad\u{7}bell")]) - ) == nil - ) - #expect( - NextingDeviceCodec.encode( - .status(agents: [.init(slot: 0, state: .idle, label: "")]) - ) == nil - ) -} - -@Test("device info parses the optional statusSlots capability") -func deviceInfoStatusSlots() throws { - let capable = try #require(NextingDeviceInfo.decode(Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\",\"status/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240,\"statusSlots\":4}".utf8 - ))) - #expect(capable.statusSlots == 4) - #expect(capable.supportsStatusV1) - - let plain = try #require(NextingDeviceInfo.decode(Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240}".utf8 - ))) - #expect(plain.statusSlots == 0) - #expect(!plain.supportsStatusV1) - - let outOfRange = NextingDeviceInfo.decode(Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\",\"status/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240,\"statusSlots\":9}".utf8 - )) - #expect(outOfRange == nil) - - let zeroSlots = try #require(NextingDeviceInfo.decode(Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\",\"status/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240,\"statusSlots\":0}".utf8 - ))) - #expect(!zeroSlots.supportsStatusV1) -} - -@Test("coordinator publishes status only to capable authorized devices") -func coordinatorStatusGating() throws { - let transport = StatusFakeTransport() - let trusted = NextingDevicePeripheralIdentity(id: UUID(), name: "Trusted") - let attacker = NextingDevicePeripheralIdentity(id: UUID(), name: "Attacker") - let coordinator = NextingDeviceRelayCoordinator( - transport: transport, - isAuthorized: { $0.id == trusted.id }, - nowMs: { 1_000 }, - requestId: { "opaque-1" }, - answerPrompt: { _, _ in } - ) - let agents: [NextingDeviceAgentStatus] = [ - .init(slot: 0, state: .thinking, label: "fix login bug"), - ] - - transport.connect(attacker) - #expect(!coordinator.publishStatus(agents)) - #expect(transport.sent.isEmpty) - - transport.connect(trusted) - #expect(!coordinator.publishStatus(agents)) - #expect(transport.sent.isEmpty) - - transport.connectedDeviceInfo = try #require(NextingDeviceInfo.decode(Data( - "{\"protocol\":\"nexting-device\",\"spec\":\"0.1.0-experimental\",\"wire\":[1],\"profiles\":[\"approval/1\",\"status/1\"],\"model\":\"xiao-ref\",\"fw\":\"0.1.0\",\"max_message_bytes\":4096,\"max_summary_bytes\":240,\"statusSlots\":2}".utf8 - ))) - #expect(coordinator.publishStatus(agents)) - #expect(transport.sent == [.status(agents: agents)]) - - transport.sent = [] - #expect(coordinator.publishStatus([])) - #expect(transport.sent == [.status(agents: [])]) - - transport.sent = [] - #expect( - !coordinator.publishStatus([.init(slot: 9, state: .idle)]) - ) - #expect(transport.sent.isEmpty) -} - -private final class StatusFakeTransport: NextingDeviceRelayTransport { - var onMessage: ((NextingDevicePeripheralIdentity, NextingDeviceMessage) -> Void)? - var onConnectedChange: ((Bool) -> Void)? - var connectedIdentity: NextingDevicePeripheralIdentity? - var connectedDeviceInfo: NextingDeviceInfo? - var isReady = false - var sent: [NextingDeviceMessage] = [] - - func send(_ data: Data) -> Bool { - guard let message = NextingDeviceCodec.decode(data) else { return false } - sent.append(message) - return true - } - - func startScan() {} - - func disconnect() { - isReady = false - connectedIdentity = nil - onConnectedChange?(false) - } - - func connect(_ identity: NextingDevicePeripheralIdentity) { - connectedIdentity = identity - isReady = true - onConnectedChange?(true) - } -} - -private struct PromptContext: Equatable, Sendable { - let prompt: String -} diff --git a/devices/sdk/swift/Tests/NextingDeviceKitTests/VectorTests.swift b/devices/sdk/swift/Tests/NextingDeviceKitTests/VectorTests.swift deleted file mode 100644 index a96dfe5..0000000 --- a/devices/sdk/swift/Tests/NextingDeviceKitTests/VectorTests.swift +++ /dev/null @@ -1,243 +0,0 @@ -import Foundation -import Testing -@testable import NextingDeviceKit - -private struct VectorDocument: Decodable { - let spec: String - let wire: Int - let profile: String - let valid: [ValidVector] - let invalid: [String] -} - -private struct ValidVector: Decodable { - let wire: String - let decoded: DecodedVector -} - -private struct DecodedVector: Decodable { - let type: String - let requestId: String? - let summary: String? - let options: [String]? - let ttlMs: Int? - let choice: String? - let reason: String? - let code: String? - - var message: NextingDeviceMessage { - get throws { - switch type { - case "present": - return .present( - requestId: try #require(requestId), - summary: try #require(summary), - ttlMs: try #require(ttlMs) - ) - case "answer": - let rawChoice = try #require(choice) - return .answer( - requestId: try #require(requestId), - choice: try #require(NextingDeviceChoice(rawValue: rawChoice)) - ) - case "resolved": - let rawReason = try #require(reason) - return .resolved( - requestId: try #require(requestId), - reason: try #require(NextingDeviceResolutionReason(rawValue: rawReason)) - ) - case "error": - let rawCode = try #require(code) - return .error( - requestId: requestId, - code: try #require(NextingDeviceErrorCode(rawValue: rawCode)) - ) - default: - Issue.record("unknown vector type \(type)") - return .error(requestId: nil, code: .badMessage) - } - } - } -} - -private func loadVectors() throws -> VectorDocument { - let testFile = URL(fileURLWithPath: #filePath) - let repository = testFile - .deletingLastPathComponent() // NextingDeviceKitTests - .deletingLastPathComponent() // Tests - .deletingLastPathComponent() // swift - .deletingLastPathComponent() // sdk - .deletingLastPathComponent() // nexting-devices - let url = repository.appendingPathComponent( - "protocol/vectors/approval-v1.json", - isDirectory: false - ) - return try JSONDecoder().decode( - VectorDocument.self, - from: Data(contentsOf: url) - ) -} - -@Test("shared vectors round-trip canonically") -func sharedVectorsRoundTrip() throws { - let vectors = try loadVectors() - #expect(vectors.spec == "0.1.0-experimental") - #expect(vectors.wire == 1) - #expect(vectors.profile == "approval/1") - - for item in vectors.valid { - let expected = try item.decoded.message - #expect(NextingDeviceCodec.decode(Data(item.wire.utf8)) == expected) - #expect(NextingDeviceCodec.encode(expected) == Data(item.wire.utf8)) - } - for wire in vectors.invalid { - #expect(NextingDeviceCodec.decode(Data(wire.utf8)) == nil) - } -} - -@Test("summary limit counts UTF-8 bytes") -func summaryLimitCountsBytes() { - let oversized = String(repeating: "批", count: 81) - #expect(Data(oversized.utf8).count == 243) - #expect( - NextingDeviceCodec.encode( - .present(requestId: "r1", summary: oversized, ttlMs: 30_000) - ) == nil - ) -} - -@Test("summary rejects NUL on encode and decode") -func summaryRejectsNUL() { - #expect( - NextingDeviceCodec.encode( - .present(requestId: "r1", summary: "approve\0deny", ttlMs: 30_000) - ) == nil - ) - #expect( - NextingDeviceCodec.decode( - Data( - #"{"v":1,"t":"present","id":"r1","sum":"approve\u0000deny","opt":["allow","deny"],"ttl":30000}"#.utf8 - ) - ) == nil - ) -} - -@Test("direct codec rejects a complete frame above the host limit") -func codecRejectsOversizedFrame() { - let wire = Data( - "{\"v\":1,\"t\":\"answer\",\"id\":\"r1\",\"ch\":\"allow\",\"future\":\"\(String(repeating: "x", count: 4096))\"}\n".utf8 - ) - #expect(wire.count > 4096) - #expect(NextingDeviceCodec.decode(wire) == nil) -} - -@Test("direct codec reserves one byte for the required newline") -func codecCountsImplicitNewline() { - let prefix = #"{"v":1,"t":"answer","id":"r1","ch":"allow","future":""# - let suffix = #""}"# - let payloadBytes = NextingDeviceCodec.maxMessageBytes - 1 - let exactPayload = prefix - + String(repeating: "x", count: payloadBytes - prefix.utf8.count - suffix.utf8.count) - + suffix - let exactWire = Data((exactPayload + "\n").utf8) - #expect(exactWire.count == NextingDeviceCodec.maxMessageBytes) - #expect(NextingDeviceCodec.decode(exactWire) != nil) - #expect(NextingDeviceCodec.decode(Data(exactPayload.utf8)) != nil) - - let tooLargeWithoutNewline = prefix - + String(repeating: "x", count: payloadBytes + 1 - prefix.utf8.count - suffix.utf8.count) - + suffix - let tooLargeData = Data(tooLargeWithoutNewline.utf8) - #expect(tooLargeData.count == NextingDeviceCodec.maxMessageBytes) - #expect(NextingDeviceCodec.decode(tooLargeData) == nil) -} - -@Test("error can omit id but rejects explicit null id on the wire") -func errorIDRules() { - let message = NextingDeviceMessage.error(requestId: nil, code: .badMessage) - let wire = Data("{\"v\":1,\"t\":\"error\",\"code\":\"bad_message\"}\n".utf8) - #expect(NextingDeviceCodec.encode(message) == wire) - #expect(NextingDeviceCodec.decode(wire) == message) - #expect( - NextingDeviceCodec.decode( - Data("{\"v\":1,\"t\":\"error\",\"id\":null,\"code\":\"bad_message\"}\n".utf8) - ) == nil - ) -} - -@Test("decoder ignores one unknown optional field") -func decoderIgnoresUnknownOptionalField() { - let wire = Data( - #"{"v":1,"t":"answer","id":"r1","ch":"deny","future":{"note":"comma, brace } and quote \" remain data"}}"#.utf8 - ) + Data([0x0A]) - #expect( - NextingDeviceCodec.decode(wire) == .answer(requestId: "r1", choice: .deny) - ) - let longUnknownKey = String(repeating: "k", count: 241) - let longKeyWire = Data( - "{\"v\":1,\"t\":\"answer\",\"id\":\"r1\",\"ch\":\"allow\",\"\(longUnknownKey)\":true}".utf8 - ) - #expect( - NextingDeviceCodec.decode(longKeyWire) == .answer(requestId: "r1", choice: .allow) - ) -} - -@Test("line decoder buffers bytes, bounds messages, and resets") -func lineDecoderBehavior() { - let wire = Data("{\"v\":1,\"t\":\"answer\",\"id\":\"r1\",\"ch\":\"allow\"}\n".utf8) - var decoder = NextingDeviceLineDecoder(maxMessageBytes: 64) - #expect(decoder.push(wire.prefix(8)) == []) - #expect( - decoder.push(wire.dropFirst(8)) == [ - .answer(requestId: "r1", choice: .allow), - ] - ) - - #expect( - decoder.push(Data(repeating: 0x78, count: 64)) == [ - .error(requestId: nil, code: .messageTooLarge), - ] - ) - #expect(decoder.push(Data("discard\n".utf8)) == []) - decoder.reset() - #expect(decoder.push(wire) == [.answer(requestId: "r1", choice: .allow)]) -} - -@Test("line decoder preserves multibyte UTF-8 split across chunks") -func lineDecoderMultibyteSplit() { - let wire = Data( - "{\"v\":1,\"t\":\"present\",\"id\":\"r1\",\"sum\":\"批准?\",\"opt\":[\"allow\",\"deny\"],\"ttl\":1000}\n".utf8 - ) - let split = try! #require(wire.firstIndex(of: 0xE6)) + 1 - var decoder = NextingDeviceLineDecoder(maxMessageBytes: 512) - #expect(decoder.push(wire.prefix(upTo: split)) == []) - #expect( - decoder.push(wire.suffix(from: split)) == [ - .present(requestId: "r1", summary: "批准?", ttlMs: 1000), - ] - ) -} - -@Test("line decoder message limit counts the terminating newline") -func lineDecoderCountsNewline() { - let wire = Data("{\"v\":1,\"t\":\"answer\",\"id\":\"r1\",\"ch\":\"allow\"}\n".utf8) - var exact = NextingDeviceLineDecoder(maxMessageBytes: wire.count) - #expect(exact.push(wire) == [.answer(requestId: "r1", choice: .allow)]) - - var tooSmall = NextingDeviceLineDecoder(maxMessageBytes: wire.count - 1) - #expect( - tooSmall.push(wire) == [ - .error(requestId: nil, code: .messageTooLarge), - ] - ) -} - -@Test("one hostile chunk cannot amplify into unbounded results") -func lineDecoderBoundsChunkOutput() { - var decoder = NextingDeviceLineDecoder() - #expect( - decoder.push(Data(repeating: 0x0A, count: 4_097)) == [ - .error(requestId: nil, code: .messageTooLarge), - ] - ) -}