openvibers/openvibe.link Commits

Add an ESP32 client for the device protocol: a portable C++ core, an Arduino transport, and a desktop conformance harness over Bot's fixtures (compile-checked in CI) (#31)

OP OpenVibers committed 5 Oct 2026 · 0eb9766947 · 13 files, +3320 −0

* Add an ESP32 client for the device protocol: a portable C++ core, an Arduino transport, and a desktop conformance harness over Bot's fixtures (compile-checked in CI)

## Files created

- `esp32/library.properties` — Arduino library `OpenVibeNode` 0.1.0, `depends=ArduinoJson (>=7.0.0),WebSockets`.
- `esp32/README.md` — what it supports, what it doesn't, example wiring, how to pair, and the explicit "compile-checked in CI and conformance-tested on desktop, not yet run on a board" statement.
- `esp32/src/ov_core.h` + `ov_core.cpp` — the protocol core in portable C++17, only `<ArduinoJson.h>` + stdlib. Envelope encode/decode, hello + config descriptor (capabilities, no `worker`), clamping before callbacks, ack/nack reason codes, heartbeat cadence + both `heartbeat_ack` shapes, motion-deadline and link deadman, e-stop/local-stop latch, job refusals, and the 4000/4002/4003/backoff policy. Time injected as `now_ms`; I/O as `send`/`on_drive`/`on_actuator`/`on_stop`/`on_log` (plus optional latch/telemetry).
- `esp32/src/OpenVibeNode.h` + `OpenVibeNode.cpp` — WiFi, TLS via `beginSslWithBundle` with the ESP32 CA bundle (never `setInsecure`), `HTTPClient` `POST /api/v1/pair`, `Preferences`/NVS for device id + credential, rotation import. Credential/publish key are never logged or URL-placed.
- `esp32/examples/Basic/Basic.ino` — differential drive (ledc PWM + L298N pins) with `#define` WiFi/robot/code.
- `esp32/test/conformance.cpp` + `esp32/test/run.sh` — desktop harness; `run.sh` fetches ArduinoJson **v7.4.2** into gitignored `.deps`, verifies SHA256 `a05ac98f4481d2398c103ca5ffcce8e2fcd7fa2fa1d8d9f38d5757454f448d97`, builds `g++ -std=c++17 -Wall -Wextra -Werror`, and runs.
- Modified: `.github/workflows/ci.yml` (added `esp32-conformance`, `esp32-compile`), `.gitignore` (`.deps/`), `README.md` (section linking `esp32/README.md`), `docs/protocol.md` (one line).

## Harness output (`sh esp32/test/run.sh`, exit 0)

```
PASS fixtures replayed
PASS hello on connect
PASS heartbeat cadence and acks
PASS deadman stops motion after the window, not before
PASS estop latches and clears
PASS clamping to the owner limits
PASS unknown kind and malformed value nack codes
PASS job refused on a device without the worker
PASS close codes and reconnect policy
all scenarios passed
```

`go test -count=1 ./internal/protocol` → `ok`. All 16 `testdata/bot` fixtures are replayed (server frames decoded/handled; emitted device frames checked key-by-key against fixture value types).

## Not implemented on the ESP32, and why

- **Jobs / worker** — by design: no worker, so a well-formed job is `nack unsupported` / `class not available` (with the earlier `invalid inputs`, `missing or invalid args, ttl_ms or limits`, `unknown class`, artifact and net checks in the protocol's order). Nothing runs.

* ESP32 client: 64-bit monotonic safety timers, strict latch types, bounded frames and ids, persisted local stop, TLS-only servers, definitive pairing refusals, Go-matching reconnect backoff; fixes the transport compile (review)

All fixes are in, the harness passes all 19 scenarios, `go test -count=1 ./internal/protocol` passes fresh, and no Go file was touched (nothing committed or pushed).

**Findings**

1. **Compile error** — call is now `telemetry_(telemetry)` (reference, not pointer): `esp32/src/OpenVibeNode.cpp:386`.
2. **millis() wrap** — the transport now feeds Core two clocks: `esp_timer_get_time()/1000` (64-bit monotonic) drives every timer, `time()` is used only for `ts`/`at`/heartbeat `t`: `OpenVibeNode.cpp:43,69-80`; Core's `NowFn`/`WallFn` split at `ov_core.h:128-132`; deadman/heartbeat/motion-deadline use the monotonic clock and the skew is estimated against it so a wall step can't reopen a deadline: `ov_core.cpp:309-357,541,570,729,742,923-971`.
3. **Wrong-typed latch** — `handle_config` and `handle_estop` require `is<bool>()`, otherwise log, ignore the whole frame and keep the latch: `ov_core.cpp:398-405,596-604`.
4. **Dropped e-stop on bad input** — input is bounded (`kMaxFrameBytes` 16 KiB, explicit `DeserializationOption::NestingLimit(10)`, `feed(data,length)` so the transport never copies an oversize frame); oversize, `NoMemory` and `TooDeep` all `stop_motion()` + set `link_lost_`; `emit()` checks `doc.overflowed()` and drops half-formed frames: `ov_core.h:45-51,163`, `ov_core.cpp:213-228,309-334`, `OpenVibeNode.cpp:351-353`.
5. **Unbounded dedup entries** — ids and kinds over 64 bytes are rejected, the unknown-kind message no longer embeds the kind, and the cache expires after `kDedupTTLMS` (10 min, across reconnects): `ov_core.cpp:464-496,852-873`, `ov_core.h:43-44`.
6. **Local kill switch** — persisted through the internal latch callback to NVS (`local_stop`), restored in `begin()` before WiFi, with `setLocalStop`/`resume` added; the remote latch stays config-synced (README documents why): `OpenVibeNode.h:56-57,68`, `OpenVibeNode.cpp:46,59,144-153,299-305`.
7. **Auth header** — built into a member `std::string` and refused (not truncated) past 512 bytes; `importCredential` rebuilds it: `OpenVibeNode.cpp:316-325,327-343,274-278`. Verified against the pinned 2.6.1 source: `setExtraHeaders` assigns into `WSclient_t::extraHeaders` (an Arduino `String`), so 2.6.1 copies and the dangling-pointer risk does not actually apply — no truncation now either.
8. **Cleartext servers** — `parseServer` runs first and refuses non-TLS unless host is `localhost`/`127.0.0.1` or `-DOPENVIBE_ALLOW_INSECURE_HTTP` is built: `OpenVibeNode.cpp:84-125,291-297`.
9. **Pairing retries** — a 4xx is definitive (stops until reboot); only transport errors and 5xx/3xx retry, with 30 s→5 min backoff; `saveCredential` checks `putString` and logs failures; README notes NVS is unencrypted: `OpenVibeNode.cpp:134-142,170-252`.
10. **Reconnect policy** — `reconnect_attempt_` resets after 30 s of live link (`ov_core.cpp:923-928`); a pre-hello unknown close gets the 10 s floor with an explicit "cannot know the close code" log (`ov_core.cpp:259-285`); the 4003-while-connected gap is documented as a known violation in README *Limitations*.
11. **Heartbeat/deadman** — `heartbeat_ms` capped at 5000 and `deadman_ms_` computed in 64-bit (`ov_core.cpp:407-415`); guarded kinds are nacked `not_connected` while `link_lost_` (`ov_core.cpp:514-523`).
12. **README** — rewritten so every claim matches code/tests: retry policy, monotonic clocks, frame bounds, NVS-encryption and no-SNTP notes, uncertain pre-hello close, verified CA-bundle symbol, updated harness scenario list.

**Harness scenarios

* ESP32 client: TLS-only servers enforced before pairing or linking, no pre-hello timer with the transport down, bounded e-stop reasons, overflowing values refused, NVS failure fails closed (second review)

All checks pass. Summary of the round-2 fixes:

**Finding 1 — TLS-only rule not enforced (transport).** `parseServer` now clears `host` on every refusal (top, bad port, cleartext) and validates the port as non-empty/decimal/1–65535 (`esp32/src/OpenVibeNode.cpp:42,99,127,139`); a new `server_ok_` flag records the result (`esp32/src/OpenVibeNode.h:94`), `begin()` sets it and clears `host_` (`:315-316`), `startLink()` requires it (`:355`), and `loop()` returns before pairing or linking when false (`:417`) — so no pairing POST and no Bearer-carrying `ws://` link start. Re-read the changed lines against the pinned WebSockets 2.6.1 headers in `esp32/test/.deps/ws`; no library API changed (only `std::strtol` from the already-included `<cstdlib>`).

**Finding 2 — pre-hello timer fired on every loop() pass with the socket down.** Added `transport_up_` (`esp32/src/ov_core.h:259`), set in `connect()` (`ov_core.cpp:237`) and cleared in `on_close()` (`:265`); `tick()` applies the hello allowance only while `transport_up_ && !link_lost_` (`:951`), so a stale `last_rx_ms_` no longer cancels in-flight reconnects or floods logs (also fixes first boot).

**Finding 3 — dedup cache could hold megabytes.** `by` is truncated to `kMaxEstopReasonBytes` (64) at assignment (`esp32/src/ov_core.h:56`, `ov_core.cpp:617`) and `remote_reason_` is capped in `set_remote_stop` (`:910`); the latch log now uses the bounded copy.

**Finding 4 — README overstated.** Reworded the dedup claim to a bounded-message claim (`esp32/README.md:29`), made the cleartext-refusal claim match the now-enforcing code and added the port rule (`:19-20`), documented NVS fail-closed (`:41`), and added the three new scenarios to the harness list (`:120`). No scenario is claimed that a test does not cover.

**Finding 5 — both minor items.** (a) `clamp_command` now builds its document through the value seam and returns `bad_value` on `overflowed()` (`ov_core.cpp:771`); the actuator re-parse checks the `deserializeJson` result and `overflowed()` and nacks `bad_value` (`:580`). A separate `set_value_allocator` test seam (`ov_core.h:162,244`) lets the harness overflow a value document without also breaking the frame parser. (b) an NVS open failure at boot now calls `core_.set_local_stop(true)` — stopped until the sketch calls `resume()` — instead of only logging (`esp32/src/OpenVibeNode.cpp:326-328`).

Harness scenario lines (all registered in `esp32/test/conformance.cpp`):
- `report("the pre-hello timer does not fire with the transport down", scenarioPreHelloTimer());` (`:776,1035`)
- `report("a huge e-stop by is bounded in every cached message", scenarioEstopReasonBound(hello, config));` (`:800,1036`)
- `report("an overflowing command or actuator value is refused", scenarioValueOverflow(hello, config));` (`:830,1037`)

Verification: `sh esp32/test/run.sh` — all 22 scenarios PASS (including the 3 new ones); `go test -count=1 ./internal/protocol` — ok, Go code untouched. I also confirmed each new scenario FAILS when compiled against the pre-fix behavior (transport-down timer fires; reason is 16003 bytes; a truncated drive reaches the driver), so they are real regressions tests. Nothing committed or pushed.

Changes

ddiff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
iindex 2bf39bd..fc52c3a 100644
---- a/.github/workflows/ci.yml
++++ b/.github/workflows/ci.yml
@@@ -100,3 +100,30 @@ jobs:
- run: shellcheck -s sh install/install.sh scripts/dist.sh scripts/test-install-stock-server.sh scripts/test-install-pair-args.sh
- run: sh scripts/test-install-stock-server.sh
- run: sh scripts/test-install-pair-args.sh
++
++ esp32-conformance:
++ name: esp32 protocol conformance (desktop)
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - run: sh esp32/test/run.sh
++
++ esp32-compile:
++ name: esp32 compile (arduino-cli)
++ runs-on: ubuntu-latest
++ steps:
++ - uses: actions/checkout@v4
++ - uses: arduino/setup-arduino-cli@v2
++ - name: Install the pinned core and libraries
++ run: |
++ arduino-cli config init
++ arduino-cli config add board_manager.additional_urls https://espressif.github.io/arduino-esp32/package_esp32_index.json
++ arduino-cli core update-index
++ arduino-cli core install esp32:[email protected]
++ arduino-cli lib install "[email protected]"
++ arduino-cli lib install "[email protected]"
++ - name: Compile the example against this tree's library
++ run: |
++ mkdir -p build/ovlibs
++ cp -r esp32 build/ovlibs/OpenVibeNode
++ arduino-cli compile --fqbn esp32:esp32:esp32 --libraries build/ovlibs esp32/examples/Basic
ddiff --git a/.gitignore b/.gitignore
iindex 6c4bbdb..7cb616f 100644
---- a/.gitignore
++++ b/.gitignore
@@@ -6,3 +6,4 @@ __pycache__/
.pytest_cache/
*.egg-info/
/build/
++esp32/test/.deps/
ddiff --git a/README.md b/README.md
iindex 21d151e..c22815f 100644
---- a/README.md
++++ b/README.md
@@@ -29,6 +29,8 @@ openvibe-node resume
Kit [`adeept_adr036`](plugins/adeept_adr036/README.md), and [`cozmo`](plugins/cozmo/README.md) through PyCozmo. Each
plugin stops its own actuators when stdin closes, at a command's deadline and after 1 s without a heartbeat, so the
core dying never leaves motors running.
++- **ESP32 library** ([`esp32/`](esp32/README.md), `OpenVibeNode`): the device side of the protocol for ESP32 boards —
++ pairing, the control link, drive/actuator commands, telemetry, heartbeat, deadman and e-stop — but no jobs or video.
Docs: [protocol](docs/protocol.md) (the wire format with OpenVibe.Bot), [plugins](docs/plugins.md) (the driver
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md).
ddiff --git a/docs/protocol.md b/docs/protocol.md
iindex d2ebc1f..3f3616d 100644
---- a/docs/protocol.md
++++ b/docs/protocol.md
@@@ -6,6 +6,8 @@ doc are pinned verbatim in [`internal/protocol/testdata/bot`](../internal/protoc
Bot commit). `go test ./internal/protocol ./internal/link` decodes every one of them and drives the fake server
(`internal/fakebot`) through pair → connect → command → ack → reconnect with those exact bytes.
++An ESP32 library ([`esp32/`](../esp32/README.md), `OpenVibeNode`) implements this device side minus jobs and video: pairing, the control link, drive/actuator/halt, telemetry, heartbeat, deadman and the e-stop.
++
## 1. Pairing
The owner adds a robot on openvibe.bot and gets an 8-character code (shown `XXXX-XXXX`, 10 minutes, single use, 5 wrong
ddiff --git a/esp32/README.md b/esp32/README.md
nnew file mode 100644
iindex 0000000..bacdd15
---- /dev/null
++++ b/esp32/README.md
@@@ -0,0 +1,136 @@
++# OpenVibeNode — the ESP32 client
++
++`OpenVibeNode` is an Arduino/ESP32 library that makes a board a **device** on OpenVibe.Bot's device protocol
++(`bot.device-message@1`, [docs/protocol.md](../docs/protocol.md) sections 1–3): pair once, keep an outbound control
++link, drive the motors, report telemetry, and hold the safety rules locally.
++
++It is **conformance-tested on desktop and compile-checked in CI, not yet run on a board.** The protocol core is
++exercised against every fixture in `internal/protocol/testdata/bot` and the scenarios below by `esp32/test/run.sh`;
++the `esp32-compile` CI job compiles `examples/Basic` for `esp32:esp32:esp32` against the pinned core (3.1.1),
++ArduinoJson (7.4.2) and WebSockets (2.6.1). Nothing here has been on real hardware.
++
++## What it supports
++
++- **Pairing** — `POST /api/v1/pair` over HTTPS with the code normalised as Bot does (upper case, dash/space removed,
++ `I`/`L` → `1`, `O` → `0`); the device id and credential are kept in NVS. A refusal is turned into the same sentence
++ the Node prints. A 4xx answer (wrong, used, locked or expired code) is definitive: the board stops trying until it
++ is rebooted with a new code, so it never burns extra tries toward Bot's 5-try lock. Transport errors and 5xx answers
++ retry with backoff (30 s doubling to 5 min). A plain `http://`/`ws://` origin whose host is not `localhost` or
++ `127.0.0.1` is refused outright — the host is cleared and both the pairing POST and the control link are suppressed
++ — unless the library is built with `-DOPENVIBE_ALLOW_INSECURE_HTTP` for a test server. A malformed port (empty,
++ non-numeric, 0, or over 65535) is refused the same way.
++- **The control link** — one outbound WebSocket to the control URL, the credential in the upgrade's `Authorization`
++ header only. The device sends nothing until the server's `hello`, and the first `status`/`estop_state` wait for the
++ connection's `config`.
++- **Drive and actuator commands** — `drive` (differential and mecanum fields), `actuator` and `halt`. Values are
++ clamped in the core, before any driver sees them, to the stricter of the server's `config.limits` and the local caps.
++ `ptz`, `say` and `display` are valid protocol kinds this device has no driver for, so they are `nack unsupported`.
++ A command id or kind over 64 bytes is rejected, and the dedup cache answers a repeated id for the spec's 10 minutes
++ (across reconnects) while every cached message stays within a fixed bound: the id and kind are rejected or not
++ echoed, and a hostile e-stop reason is truncated before it is copied into an entry.
++- **Telemetry** — `battery`, `voltage`, `rssi` and `sensors`, at most every 500 ms (Bot's cap).
++- **Heartbeat and deadman** — a `heartbeat` every `config.heartbeat_ms` (capped at 5000 ms so a server cannot stretch
++ the deadman for hours) once `hello` has arrived, both `heartbeat_ack` shapes (the newer `echo`/`t` and the older
++ `seq`) measured for RTT; two missed heartbeats stop the motors and drop the link; a motion command stops when its
++ deadline lapses. Every timer runs on `esp_timer_get_time()`, a 64-bit monotonic clock, so the `millis()` wrap at
++ 49.7 days and an NTP step cannot move the deadman, a deadline or the cadence; the wall clock is read only for the
++ envelope `ts` and the protocol's Unix timestamps.
++- **E-stop** — the server's `estop` latches or clears the remote stop (`config.estop_latched` re-applies a latch set
++ while offline); a frame with a wrong-typed `latched`/`estop_latched` is ignored and keeps the latch, as the Go Node's
++ decoder does. The local kill switch (`setLocalStop(true)` / `resume()`) is persisted in NVS and restored in
++ `begin()` before WiFi comes up; if NVS cannot be opened, `begin()` fails closed and treats the switch as latched
++ until the sketch calls `resume()`. Only `resume()` clears it. Every stop path calls `on_stop`.
++- **Credential rotation** — `importCredential()` takes the owner's `POST /api/v1/devices/:id/rotate` response, refuses
++ it when it is for another device, rewrites the stored credential without pairing again, and the next reconnect uses
++ it. A failed NVS write is logged rather than passed over silently.
++- **Close codes** — 4002 and 4003 (and an upgrade refused with 401/403) stop the motors and wait at least 10 s, with
++ jitter that only adds, logging "pair again, or import the owner's rotation"; other drops use the documented
++ exponential backoff with full jitter, and a link that has been up for 30 s resets it (as the Go Node does).
++ arduinoWebSockets 2.x does not surface the close code or the upgrade's status, so the transport treats a socket that
++ ends before `hello` as the refused case and logs that the cause is uncertain; see *Limitations* below.
++- **Frame and memory bounds** — the protocol allows frames up to 1 MiB; this device bounds them at 16 KiB and a JSON
++ depth of 10. A frame over either bound, or one the JSON parser cannot allocate, stops the motors and drops the link
++ (it could have been an e-stop; the reconnect re-reads `config.estop_latched`). A frame that loses fields to a failed
++ allocation is never sent half-formed.
++
++## What it does not
++
++- **Jobs / worker** — nothing runs jobs. A job frame is refused exactly as the protocol says for a node without the
++ worker: `nack unsupported` with `class not available` (or `invalid inputs` / `missing or invalid args, ttl_ms or
++ limits` when those are the first problem).
++- **Video / WHIP** — no camera publishing.
++- **OpenVibe.Network pairing and node tokens** — only Bot credential pairing.
++- **The local CLI** — there is no `openvibe-node` binary on a board; the local kill switch is the API, not a command.
++
++## Layout
++
++- `src/ov_core.{h,cpp}` — the protocol core in portable C++17, no Arduino headers, only ArduinoJson v7 (header-only).
++ Everything stateful lives here; time is injected as two clocks (monotonic and Unix) and every side effect is a
++ callback, so it runs on desktop.
++- `src/OpenVibeNode.{h,cpp}` — the Arduino transport: WiFi, TLS with the ESP32 CA bundle (never `setInsecure()`), the
++ arduinoWebSockets client, `HTTPClient` for pairing, and `Preferences` (NVS) for the device id, credential and local
++ kill switch. TLS verifies against the core's embedded bundle (`_binary_x509_crt_bundle_start`), the same one the
++ core's own HTTPS client uses.
++- `examples/Basic/Basic.ino` — a differential-drive example.
++- `test/` — the desktop conformance harness (`run.sh` fetches the pinned ArduinoJson single header into `.deps`,
++ checks its SHA256, builds with `g++ -std=c++17 -Wall -Wextra -Werror`, and runs it).
++
++## Pairing and running the example
++
++1. Add the robot on openvibe.bot and copy the 8-character code it shows (`XXXX-XXXX`, 10 minutes, single use).
++2. In `examples/Basic/Basic.ino`, fill in `OV_WIFI_SSID`, `OV_WIFI_PASSWORD`, `OV_ROBOT` (the `rob_…` id) and
++ `OV_CODE`. `OV_NAME` is what the owner sees. On later boots the stored credential is reused, so `OV_CODE` is only
++ needed for the first pairing.
++3. Install the ESP32 core (3.x) and the `ArduinoJson` (7.x) and `WebSockets` (2.x) libraries, then compile and upload
++ `examples/Basic` for your board with the `OpenVibeNode` library from this tree.
++4. The library never starts SNTP. Add `configTime(...)` in the sketch (NTP) so the protocol's Unix timestamps are
++ right; the safety timers do not depend on it.
++
++## Wiring the example
++
++An L298N-class dual H-bridge; change the pins in `Basic.ino` if they clash with your board.
++
++| ESP32 | driver |
++|-------|--------|
++| GPIO25 | ENA (left motor PWM) |
++| GPIO26 | IN1 |
++| GPIO27 | IN2 |
++| GPIO14 | ENB (right motor PWM) |
++| GPIO32 | IN3 |
++| GPIO33 | IN4 |
++| GND | GND |
++| battery + / − | the driver's motor supply |
++
++The two enable pins are the `ledc` PWM channels (20 kHz, 8-bit) and the four IN pins the direction. `onDrive` mixes
++`throttle ± steer` for the two wheels; the core has already clamped them. `onStop` is the only place that zeroes the
++motors, and it runs on `halt`, on a motion deadline, on two missed heartbeats, on an e-stop and whenever the link
++closes.
++
++## Building the conformance harness
++
++```sh
++sh esp32/test/run.sh
++```
++
++It needs `g++` with C++17 and a way to fetch one file (curl, wget or python3). The harness prints one line per
++scenario and exits non-zero on a failure. Its scenarios cover the fixtures, the deadman, the e-stop latch (including
++wrong-typed frames), clamping, nack codes, job refusals, close codes, heartbeat cadence, a wall clock that steps
++backwards, 64-bit monotonic time across the 32-bit `millis()` wrap, oversize/too-deep/allocation-failing frames,
++over-long ids and kinds, the dedup cache's 10-minute limit, the heartbeat cap, guarded commands while the link is
++lost, the backoff reset after a stable link, the 10 s floor after a refused reconnect, the pre-hello timer staying
++quiet while the transport is down, a hostile e-stop `by` being bounded in the latch and in every cached message, and a
++command or actuator value document that overflows being refused rather than reaching a driver truncated.
++
++## Limitations
++
++- Not run on hardware yet (above).
++- The library never starts SNTP (step 4). Before the sketch syncs time, `ts`, `estop_state.at` and `heartbeat.t` carry
++ the monotonic clock rather than Unix milliseconds. The core's timers never read that clock, so safety is unaffected.
++- NVS is not encrypted by default: the credential and the local kill switch live in flash readable to anyone with
++ physical access unless the sketch enables NVS encryption. The remote e-stop is not stored at all; each connection's
++ `config.estop_latched` re-applies it (including after a reboot).
++- arduinoWebSockets 2.x delivers `WStype_DISCONNECTED` without the WebSocket close code or the upgrade's HTTP status.
++ The transport therefore treats "disconnected before `hello`" as the 4002/401/403 case (10 s floor, logged as an
++ uncertain cause) and "disconnected after `hello`" as a normal drop; a 4003 sent while connected cannot be told
++ apart, so it uses the normal backoff and can retry once below the spec's 10 s floor before the pre-hello rule
++ applies. The core itself implements the close-code policy and the desktop harness tests it.
ddiff --git a/esp32/examples/Basic/Basic.ino b/esp32/examples/Basic/Basic.ino
nnew file mode 100644
iindex 0000000..fbebd24
---- /dev/null
++++ b/esp32/examples/Basic/Basic.ino
@@@ -0,0 +1,89 @@
++// Basic — a differential-drive ESP32 robot on the OpenVibe Bot device protocol.
++//
++// Wiring (an L298N-class dual H-bridge; use your own pins if these clash with your board):
++//
++// ESP32 GPIO25 -> ENA (left motor PWM) ESP32 GPIO14 -> ENB (right motor PWM)
++// ESP32 GPIO26 -> IN1 ESP32 GPIO32 -> IN3
++// ESP32 GPIO27 -> IN2 ESP32 GPIO33 -> IN4
++// ESP32 GND -> the driver's GND Motor + battery -> the driver's +12V/GND
++//
++// The two enable pins are the ledc PWM channels and the four IN pins the direction. The core clamps every value to the
++// owner's limits before onDrive sees it, stops the motors when a motion deadline lapses or two heartbeats are missed,
++// and stops them on an e-stop or when the link closes. onStop() is the only place that zeroes the motors.
++//
++// Fill in the four defines below. OV_CODE is the 8-character, single-use code openvibe.bot shows for the robot
++// (XXXX-XXXX, 10 minutes). After the first pairing the device id and credential are kept in NVS, so OV_CODE is only
++// needed when there is no stored credential.
++#include <ArduinoJson.h>
++#include <OpenVibeNode.h>
++
++#define OV_WIFI_SSID "your-ssid"
++#define OV_WIFI_PASSWORD "your-password"
++#define OV_ROBOT "rob_00000000000000000000000000"
++#define OV_CODE "ABCD-1234"
++#define OV_NAME "esp32-rover"
++
++static const int PIN_ENA = 25;
++static const int PIN_IN1 = 26;
++static const int PIN_IN2 = 27;
++static const int PIN_ENB = 14;
++static const int PIN_IN3 = 32;
++static const int PIN_IN4 = 33;
++static const int PWM_FREQ = 20000;
++static const int PWM_RES = 8;
++
++OpenVibeNode node;
++
++static void setMotor(int en, int in1, int in2, float speed) {
++ speed = constrain(speed, -1.0f, 1.0f);
++ digitalWrite(in1, speed >= 0.0f ? HIGH : LOW);
++ digitalWrite(in2, speed >= 0.0f ? LOW : HIGH);
++ ledcWrite(en, static_cast<uint32_t>(fabsf(speed) * 255.0f));
++}
++
++// The core has already clamped throttle/steer to the owner's max_speed/max_turn; mix them for a differential drive.
++static void onDrive(const std::string& value, int deadline_ms) {
++ (void)deadline_ms; // the core owns the motion deadline and calls onStop when it lapses
++ JsonDocument doc;
++ if (deserializeJson(doc, value)) return;
++ const float throttle = doc["throttle"] | 0.0f;
++ const float steer = doc["steer"] | 0.0f;
++ const float left = constrain(throttle + steer, -1.0f, 1.0f);
++ const float right = constrain(throttle - steer, -1.0f, 1.0f);
++ setMotor(PIN_ENA, PIN_IN1, PIN_IN2, left);
++ setMotor(PIN_ENB, PIN_IN3, PIN_IN4, right);
++}
++
++static void onActuator(const std::string& name, const std::string& value, int deadline_ms) {
++ (void)name;
++ (void)value;
++ (void)deadline_ms; // this rover has no actuator; the core still acks the command
++}
++
++static void onStop() {
++ setMotor(PIN_ENA, PIN_IN1, PIN_IN2, 0.0f);
++ setMotor(PIN_ENB, PIN_IN3, PIN_IN4, 0.0f);
++}
++
++static void onLog(const char* level, const std::string& message) { Serial.printf("[%s] %s\n", level, message.c_str()); }
++
++void setup() {
++ Serial.begin(115200);
++ delay(200);
++ pinMode(PIN_IN1, OUTPUT);
++ pinMode(PIN_IN2, OUTPUT);
++ pinMode(PIN_IN3, OUTPUT);
++ pinMode(PIN_IN4, OUTPUT);
++ ledcAttach(PIN_ENA, PWM_FREQ, PWM_RES);
++ ledcAttach(PIN_ENB, PWM_FREQ, PWM_RES);
++ onStop();
++
++ node.setCallbacks(onDrive, onActuator, onStop, onLog);
++ node.setTelemetryProvider([](ov::Telemetry& telemetry) {
++ telemetry.has_rssi = true;
++ telemetry.rssi = WiFi.RSSI();
++ });
++ node.begin(OV_WIFI_SSID, OV_WIFI_PASSWORD, OV_ROBOT, OV_CODE, OV_NAME);
++}
++
++void loop() { node.loop(); }
ddiff --git a/esp32/library.properties b/esp32/library.properties
nnew file mode 100644
iindex 0000000..e5deee9
---- /dev/null
++++ b/esp32/library.properties
@@@ -0,0 +1,11 @@
++name=OpenVibeNode
++version=0.1.0
++author=OpenVibers
++maintainer=OpenVibers <[email protected]>
++sentence=ESP32 client for the OpenVibe Bot device protocol: pairing, control link, drive and actuator commands, telemetry, heartbeat, deadman and e-stop.
++paragraph=Implements the bot.device-message@1 device side (protocol sections 1-3) minus jobs and video. The protocol core builds without any Arduino headers and is exercised by a desktop conformance harness; the transport adds WiFi, TLS with the ESP32 CA bundle, arduinoWebSockets and NVS.
++category=Communication
++url=https://github.com/OpenVibers/OpenVibe.Node
++architectures=esp32
++depends=ArduinoJson (>=7.0.0),WebSockets
++includes=OpenVibeNode.h
ddiff --git a/esp32/src/OpenVibeNode.cpp b/esp32/src/OpenVibeNode.cpp
nnew file mode 100644
iindex 0000000..fbaba63
---- /dev/null
++++ b/esp32/src/OpenVibeNode.cpp
@@@ -0,0 +1,445 @@
++// OpenVibeNode.cpp — see OpenVibeNode.h.
++#include "OpenVibeNode.h"
++
++#include <cstdio>
++#include <cstdlib>
++#include <NetworkClient.h>
++#include <ctime>
++#include <esp_timer.h>
++#include <utility>
++
++namespace {
++
++// The CA bundle the ESP32 arduino core embeds (CONFIG_MBEDTLS_CERTIFICATE_BUNDLE, on by default): the Mozilla roots,
++// refreshed with the core. TLS is only ever set up from it — never setInsecure(), never a fingerprint.
++extern const uint8_t rootca_crt_bundle_start[] asm("_binary_x509_crt_bundle_start");
++extern const uint8_t rootca_crt_bundle_end[] asm("_binary_x509_crt_bundle_end");
++
++size_t caBundleSize() { return static_cast<size_t>(rootca_crt_bundle_end - rootca_crt_bundle_start); }
++
++// The Authorization/User-Agent block is built as a std::string with no truncation; a credential beyond this is refused
++// rather than letting a fixed buffer cut the header short (which would leave it unterminated).
++constexpr size_t kMaxHeaderBytes = 512;
++
++// The sentence the person at the board should read for a Bot pairing refusal (mirrors internal/link/pair.go).
++std::string pairingMessage(int status, const std::string& code, const std::string& detail) {
++ if (code == "bot.invalid_pairing_code") return "a pairing code is 8 letters and digits, like ABCD-1234";
++ if (code == "bot.pairing_code_invalid")
++ return "that is not the pairing code for this robot; check it, or ask for a new one on openvibe.bot";
++ if (code == "bot.pairing_code_locked")
++ return "too many wrong tries: the code is dead; ask for a new one on openvibe.bot";
++ if (code == "bot.pairing_code_used") return "the pairing code was already used; ask for a new one on openvibe.bot";
++ if (code == "bot.pairing_code_expired")
++ return "the pairing code expired (codes last 10 minutes); ask for a new one on openvibe.bot";
++ if (code == "bot.no_pairing_code") return "this robot has no live pairing code; ask for a new one on openvibe.bot";
++ if (status == 429) return "too many pairing attempts from here; wait a minute and try again";
++ if (!detail.empty()) return detail;
++ return "pairing refused with HTTP " + std::to_string(status);
++}
++
++// A URL authority's port must be explicit, decimal and in range: atoi() would read ":abc" as 0 and ":70000" would wrap
++// through uint16_t, either of which would send the pairing code or the credential to the wrong place.
++bool parsePort(const std::string& text, uint16_t& out) {
++ if (text.empty() || text.size() > 5) return false;
++ for (char c : text) {
++ if (c < '0' || c > '9') return false;
++ }
++ const long value = std::strtol(text.c_str(), nullptr, 10);
++ if (value <= 0 || value > 65535) return false;
++ out = static_cast<uint16_t>(value);
++ return true;
++}
++
++} // namespace
++
++OpenVibeNode::OpenVibeNode()
++ : core_([this]() { return monotonicMs(); }, [this]() { return unixMs(); },
++ [this](const std::string& frame) { wsSend(frame); }) {
++ // Persist the kill switch through the core's latch callback (setLatchCallback() only replaces the user's half).
++ core_.set_latch_callback([this](bool remote, bool local) { onLatchChanged(remote, local); });
++}
++
++void OpenVibeNode::setCallbacks(DriveFn drive, ActuatorFn actuator, StopFn stop, LogFn log) {
++ user_log_ = log;
++ core_.set_callbacks(std::move(drive), std::move(actuator), std::move(stop),
++ [this](const char* level, const std::string& message) { logLine(level, message); });
++}
++
++void OpenVibeNode::setLatchCallback(ov::Core::LatchFn latch) { user_latch_ = std::move(latch); }
++
++void OpenVibeNode::setTelemetryProvider(TelemetryFn provider) { telemetry_ = std::move(provider); }
++
++void OpenVibeNode::setLocalStop(bool on) { core_.set_local_stop(on); }
++
++void OpenVibeNode::logLine(const char* level, const std::string& message) {
++ if (user_log_) {
++ user_log_(level, message);
++ } else {
++ Serial.printf("[openvibe][%s] %s\n", level, message.c_str());
++ }
++}
++
++int64_t OpenVibeNode::monotonicMs() const { return static_cast<int64_t>(esp_timer_get_time() / 1000); }
++
++int64_t OpenVibeNode::unixMs() {
++ // Unix milliseconds for the envelope's `ts` and the protocol's Unix timestamps only; every timer in the core uses the
++ // monotonic clock, so an NTP step (or its absence) cannot move the deadman, a deadline or the heartbeat cadence.
++ const time_t t = time(nullptr);
++ if (t > 1600000000 && epoch_offset_ms_ < 0) {
++ epoch_offset_ms_ = static_cast<int64_t>(t) * 1000 - monotonicMs();
++ }
++ if (epoch_offset_ms_ >= 0) return epoch_offset_ms_ + monotonicMs();
++ return monotonicMs(); // before NTP there is no Unix time; report the monotonic clock instead of a bogus epoch
++}
++
++void OpenVibeNode::wsSend(const std::string& text) { ws_.sendTXT(text.c_str(), text.size()); }
++
++bool OpenVibeNode::parseServer(const std::string& url, std::string& host, uint16_t& port, std::string& path,
++ bool& secure) {
++ host.clear(); // a refused origin must never leave a usable host behind (startLink() only checks host_.empty())
++ std::string rest;
++ if (url.rfind("https://", 0) == 0) {
++ secure = true;
++ port = 443;
++ rest = url.substr(8);
++ } else if (url.rfind("http://", 0) == 0) {
++ secure = false;
++ port = 80;
++ rest = url.substr(7);
++ } else if (url.rfind("wss://", 0) == 0) {
++ secure = true;
++ port = 443;
++ rest = url.substr(6);
++ } else if (url.rfind("ws://", 0) == 0) {
++ secure = false;
++ port = 80;
++ rest = url.substr(5);
++ } else {
++ return false;
++ }
++ const size_t slash = rest.find('/');
++ std::string authority = slash == std::string::npos ? rest : rest.substr(0, slash);
++ const std::string tail = slash == std::string::npos ? "" : rest.substr(slash);
++ path = tail.empty() || tail == "/" ? "/device" : tail + "/device";
++ const size_t colon = authority.find(':');
++ if (colon != std::string::npos) {
++ host = authority.substr(0, colon);
++ if (!parsePort(authority.substr(colon + 1), port)) {
++ host.clear();
++ return false;
++ }
++ } else {
++ host = authority;
++ }
++ if (host.empty()) return false;
++#ifndef OPENVIBE_ALLOW_INSECURE_HTTP
++ // Protocol §1: plain http is only for localhost test servers. Anywhere else it would send the pairing code and then
++ // the Bearer credential in cleartext, so the server is refused and no link starts. Build the library with
++ // -DOPENVIBE_ALLOW_INSECURE_HTTP to opt in (and only against a test server).
++ if (!secure && host != "localhost" && host != "127.0.0.1") {
++ host.clear();
++ return false;
++ }
++#endif
++ return true;
++}
++
++bool OpenVibeNode::loadCredential() {
++ device_id_ = prefs_.getString("device_id", "").c_str();
++ credential_ = prefs_.getString("credential", "").c_str();
++ robot_id_ = prefs_.getString("robot_id", "").c_str();
++ return !credential_.empty();
++}
++
++bool OpenVibeNode::saveCredential() {
++ // putString returns the bytes written, 0 on failure. A silently failed write would burn the single-use code and leave
++ // the board unpaired after the next reboot, so say so loudly.
++ bool ok = prefs_open_ && prefs_.putString("device_id", device_id_.c_str()) > 0;
++ ok = prefs_open_ && prefs_.putString("credential", credential_.c_str()) > 0 && ok;
++ if (!robot_id_.empty()) ok = prefs_open_ && prefs_.putString("robot_id", robot_id_.c_str()) > 0 && ok;
++ if (!ok) logLine("error", "could not write the credential to NVS; it will be lost on the next reboot");
++ return ok;
++}
++
++void OpenVibeNode::onLatchChanged(bool remote, bool local) {
++ // Protocol §3: the local kill switch is latched and persisted; only resume() clears it. The remote latch is re-applied
++ // from each connection's config.estop_latched (including after a reboot), so it is deliberately not stored.
++ if (prefs_open_ && local != prefs_.getBool("local_stop", false)) {
++ if (prefs_.putBool("local_stop", local) == 0) {
++ logLine("error", "could not persist the local kill switch in NVS");
++ }
++ }
++ if (user_latch_) user_latch_(remote, local);
++}
++
++void OpenVibeNode::connectWifi(const char* ssid, const char* password) {
++ WiFi.mode(WIFI_STA);
++ WiFi.begin(ssid, password);
++ logLine("info", "connecting to WiFi");
++ const uint32_t start = millis();
++ while (WiFi.status() != WL_CONNECTED && millis() - start < 20000) {
++ delay(200);
++ }
++ if (WiFi.status() == WL_CONNECTED) {
++ logLine("info", "WiFi connected");
++ } else {
++ logLine("error", "WiFi did not connect; will keep trying in loop()");
++ }
++}
++
++void OpenVibeNode::pairOverHttps() {
++ const int64_t now = monotonicMs();
++ std::string code;
++ if (!ov::Core::normalize_pair_code(pairing_code_, code)) {
++ logLine("error", "a pairing code is 8 letters and digits, like ABCD-1234");
++ pairing_given_up_ = true; // the sketch's code is malformed; retrying the same bytes cannot help
++ return;
++ }
++ const std::string body = ov::Core::build_pair_request(pairing_robot_, code, OPENVIBE_NODE_ESP32_VERSION, "onboard",
++ "esp32", capabilities_json_, pairing_name_);
++ const std::string url = server_ + "/api/v1/pair";
++ WiFiClientSecure secure_client;
++ WiFiClient plain_client; // WiFiClient is NetworkClient in core 3.x
++ NetworkClient* client = &plain_client;
++ if (secure_) {
++ secure_client.setCACertBundle(rootca_crt_bundle_start, caBundleSize());
++ client = &secure_client;
++ } else {
++ logLine("warn", "pairing over plain http (test server)");
++ }
++ HTTPClient http;
++ if (!http.begin(*client, url.c_str())) {
++ logLine("warn", "could not start the pairing request; retrying");
++ schedulePairRetry(now);
++ return;
++ }
++ http.setTimeout(15000);
++ http.addHeader("Content-Type", "application/json");
++ const int status = http.POST(String(body.c_str()));
++ const String response = http.getString();
++ http.end();
++ if (status >= 200 && status < 300) {
++ JsonDocument doc;
++ if (deserializeJson(doc, response.c_str())) {
++ logLine("error", "the pairing answer was not valid JSON; the code is spent, pair again with a new one");
++ pairing_given_up_ = true;
++ return;
++ }
++ ov::Paired paired;
++ if (!ov::Core::parse_paired(response.c_str(), paired) || paired.device_id.empty() || paired.credential.empty()) {
++ logLine("error", "the pairing answer had no device id or credential; the code is spent, pair again with a new one");
++ pairing_given_up_ = true;
++ return;
++ }
++ device_id_ = paired.device_id;
++ credential_ = paired.credential;
++ robot_id_ = paired.robot_id;
++ if (saveCredential()) {
++ logLine("info", "paired as " + device_id_);
++ } else {
++ logLine("error", "paired as " + device_id_ + ", but the credential could not be saved; it is only in RAM");
++ }
++ return;
++ }
++ if (status >= 400 && status < 500) {
++ // A definitive refusal (4xx, including a locked or used code): retrying the same code only adds wrong tries and
++ // makes Bot's lock worse, so stop until the board is rebooted with a fresh code.
++ JsonDocument problem;
++ std::string code_str;
++ std::string detail;
++ if (!deserializeJson(problem, response.c_str())) {
++ code_str = problem["code"] | "";
++ detail = problem["detail"] | "";
++ }
++ logLine("error", "pairing refused: " + pairingMessage(status, code_str, detail) + "; not retrying this code");
++ pairing_given_up_ = true;
++ return;
++ }
++ if (status > 0) {
++ logLine("warn", "pairing failed with HTTP " + std::to_string(status) + "; retrying with backoff");
++ } else {
++ logLine("warn", "the pairing request failed (transport error); retrying with backoff");
++ }
++ schedulePairRetry(now);
++}
++
++void OpenVibeNode::schedulePairRetry(int64_t now) {
++ ++pair_attempts_;
++ int64_t backoff = 30000;
++ for (int i = 1; i < pair_attempts_ && backoff < 300000; ++i) backoff *= 2;
++ next_pair_ms_ = now + backoff;
++}
++
++bool OpenVibeNode::importCredential(const char* rotateResponseJson) {
++ if (credential_.empty()) {
++ logLine("error", "pair first: there is no stored credential to rotate");
++ return false;
++ }
++ JsonDocument doc;
++ if (deserializeJson(doc, rotateResponseJson)) {
++ logLine("error", "the rotation response was not valid JSON");
++ return false;
++ }
++ const std::string new_id = doc["device"]["id"] | "";
++ const std::string new_credential = doc["credential"] | "";
++ if (new_id.empty() || new_credential.empty()) {
++ logLine("error", "the rotation response had no device id or credential");
++ return false;
++ }
++ if (new_id != device_id_) {
++ logLine("error", "the rotation response is for another device; refusing to import it");
++ return false;
++ }
++ credential_ = new_credential;
++ saveCredential();
++ // Rebuild the header so the next reconnect presents the new credential (the current socket keeps the old one until
++ // it closes, like the Go Node, which only picks a rotated credential up on restart).
++ if (link_started_ && buildExtraHeaders()) {
++ ws_.setExtraHeaders(extra_headers_.c_str());
++ }
++ logLine("info", "imported the owner's rotated credential");
++ return true;
++}
++
++void OpenVibeNode::begin(const char* ssid, const char* password, const char* robot, const char* code, const char* name,
++ const char* server, const char* capabilitiesJson) {
++ server_ = server;
++ capabilities_json_ = capabilitiesJson;
++ firmware_ = std::string("openvibe-esp32-") + OPENVIBE_NODE_ESP32_VERSION;
++ core_.set_descriptor(firmware_, "esp32", capabilities_json_);
++ core_.set_local_limits(1.0, 1.0, ov::kDefaultMaxCommandMS);
++
++ // Parse the server before pairing: the scheme decides TLS for both the pair POST and the link, and a cleartext
++ // server that is not localhost is refused outright. A refusal clears host_ and keeps server_ok_ false, so loop()
++ // starts neither a pairing POST nor a link.
++ server_ok_ = parseServer(server_, host_, port_, path_, secure_);
++ if (!server_ok_) {
++ host_.clear();
++ logLine("error",
++ "server must be https:// or wss:// (plain http:// or ws:// is only for localhost; build with "
++ "OPENVIBE_ALLOW_INSECURE_HTTP for a test server); no pairing or link will start");
++ }
++
++ // NVS before WiFi, so a board that rebooted with the local kill switch latched comes back latched.
++ prefs_open_ = prefs_.begin("openvibe", false);
++ if (!prefs_open_) {
++ // The persisted kill switch cannot be read, so fail closed: treat it as latched until the sketch calls resume().
++ core_.set_local_stop(true);
++ logLine("error", "could not open NVS; treating the local kill switch as latched until resume()");
++ } else if (prefs_.getBool("local_stop", false)) {
++ core_.set_local_stop(true);
++ logLine("warn", "the local kill switch was latched before the reboot; call resume() to clear it");
++ }
++
++ connectWifi(ssid, password);
++ if (!loadCredential()) {
++ pairing_robot_ = robot != nullptr ? robot : "";
++ pairing_code_ = code != nullptr ? code : "";
++ pairing_name_ = name != nullptr ? name : "";
++ // loop() redeems the code: transport and 5xx errors retry with backoff, a 4xx refusal stops until a reboot.
++ }
++}
++
++bool OpenVibeNode::buildExtraHeaders() {
++ const std::string header = std::string("Authorization: Bearer ") + credential_ + "\r\nUser-Agent: openvibe-esp32/" +
++ OPENVIBE_NODE_ESP32_VERSION + "\r\n";
++ if (header.size() > kMaxHeaderBytes) {
++ logLine("error", "the stored credential is too long for the Authorization header; refusing to connect");
++ return false;
++ }
++ extra_headers_ = header;
++ return true;
++}
++
++bool OpenVibeNode::startLink() {
++ if (!server_ok_ || host_.empty()) return false; // parseServer() refused it; begin() already logged why
++ if (!buildExtraHeaders()) return false;
++ // The credential goes in the upgrade's Authorization header only, never in the URL. extra_headers_ is a member, and
++ // the library copies it into its own String, so neither side can outlive the other.
++ ws_.setExtraHeaders(extra_headers_.c_str());
++ ws_.onEvent([this](WStype_t type, uint8_t* payload, size_t length) { onWsEvent(type, payload, length); });
++ ws_.setReconnectInterval(ov::kBackoffMinMS);
++ if (secure_) {
++ ws_.beginSslWithBundle(host_.c_str(), port_, path_.c_str(), rootca_crt_bundle_start, caBundleSize());
++ } else {
++ logLine("warn", "control link over plain ws:// (test server)");
++ ws_.begin(host_.c_str(), port_, path_.c_str());
++ }
++ logLine("info", "control link starting");
++ return true;
++}
++
++void OpenVibeNode::onWsEvent(WStype_t type, uint8_t* payload, size_t length) {
++ switch (type) {
++ case WStype_CONNECTED:
++ core_.connect();
++ logLine("info", "control link upgraded; waiting for hello");
++ break;
++ case WStype_TEXT:
++ // The core bounds the frame and refuses an oversize one without this side copying it first.
++ core_.feed(reinterpret_cast<const char*>(payload), length);
++ break;
++ case WStype_DISCONNECTED: {
++ // arduinoWebSockets 2.x does not surface the close code or the upgrade's HTTP status. The core treats a drop
++ // before hello as the >=10 s refused-credential case and a drop after hello as the normal backoff; its log says
++ // the pre-hello cause is uncertain. A 4003 sent while connected cannot be told apart here.
++ const bool was_up = core_.link_up();
++ core_.on_close(0);
++ ws_.setReconnectInterval(core_.next_reconnect_delay_ms());
++ if (was_up) {
++ logLine("warn", "control link down; reconnecting");
++ } else {
++ logLine("warn",
++ "control link down before hello (the transport cannot see the close code: refused credential, "
++ "network drop or timeout); reconnecting");
++ }
++ break;
++ }
++ case WStype_ERROR:
++ logLine("warn", "control link error");
++ break;
++ default:
++ break;
++ }
++}
++
++void OpenVibeNode::maybePublishTelemetry() {
++ if (!telemetry_ || !core_.link_up()) return;
++ const int64_t now = monotonicMs();
++ if (now - last_telemetry_ms_ < 500) return; // Bot's cap: sensor telemetry at most every 500 ms
++ last_telemetry_ms_ = now;
++ ov::Telemetry telemetry;
++ telemetry_(telemetry);
++ core_.send_telemetry(telemetry);
++}
++
++void OpenVibeNode::loop() {
++ if (!server_ok_) {
++ delay(1000); // begin() logged why; never POST the code or present the credential to an origin we refused
++ return;
++ }
++ if (!paired()) {
++ // Pairing is retried with backoff; after a definitive 4xx refusal pairOverHttps() stops trying until a reboot.
++ if (pairing_given_up_) {
++ delay(1000); // permanently unpaired: do not spin the loop
++ return;
++ }
++ if (monotonicMs() >= next_pair_ms_) pairOverHttps();
++ return;
++ }
++ if (!link_started_) {
++ if (!startLink()) {
++ delay(1000); // a misconfigured server or credential: do not spin, retry shortly
++ return;
++ }
++ link_started_ = true;
++ }
++ // tick() always runs so the deadman still stops the motors if WiFi drops and no frame arrives.
++ ws_.loop();
++ core_.tick();
++ if (core_.link_lost()) {
++ core_.clear_link_lost();
++ ws_.disconnect(); // the DISCONNECTED event applies the backoff and stops the actuators
++ }
++ maybePublishTelemetry();
++}
ddiff --git a/esp32/src/OpenVibeNode.h b/esp32/src/OpenVibeNode.h
nnew file mode 100644
iindex 0000000..931d811
---- /dev/null
++++ b/esp32/src/OpenVibeNode.h
@@@ -0,0 +1,113 @@
++// OpenVibeNode.h — the Arduino/ESP32 transport for ov_core.
++//
++// This is the only part of the library that touches a board: WiFi, TLS with the ESP32 CA bundle (never setInsecure),
++// the arduinoWebSockets client to the control URL, HTTPClient for POST /api/v1/pair, and Preferences (NVS) for the
++// device id, credential and the local kill switch. The protocol state machine itself is ov::Core, which the desktop
++// harness exercises without any of this.
++//
++// Time comes from two clocks: esp_timer_get_time() (64-bit monotonic, so the millis() wrap at 49.7 days cannot reach
++// the core's timers) drives the deadman, the motion deadline and the heartbeat cadence; Unix time is used only for the
++// envelope's `ts` and the timestamps the protocol defines in Unix milliseconds.
++//
++// The credential and the publish key are never logged, never put in a URL and never sent anywhere but their own
++// Authorization header.
++#pragma once
++
++#include <Arduino.h>
++#include <ArduinoJson.h>
++#include <HTTPClient.h>
++#include <Preferences.h>
++#include <WebSocketsClient.h>
++#include <WiFi.h>
++#include <WiFiClientSecure.h>
++
++#include <functional>
++#include <string>
++
++#include "ov_core.h"
++
++// The library version reported in the User-Agent and status.firmware (kept in step with library.properties).
++#define OPENVIBE_NODE_ESP32_VERSION "0.1.0"
++
++class OpenVibeNode {
++ public:
++ using DriveFn = ov::Core::DriveFn;
++ using ActuatorFn = ov::Core::ActuatorFn;
++ using StopFn = ov::Core::StopFn;
++ using LogFn = ov::Core::LogFn;
++ using TelemetryFn = std::function<void(ov::Telemetry&)>;
++
++ OpenVibeNode();
++
++ // Connects WiFi, pairs with a code when no credential is stored, then opens the control link. Blocking for the WiFi
++ // join only; the link runs from loop(). `server` is the origin, e.g. https://openvibe.bot. Plain http/ws is accepted
++ // only for localhost (or when the library is built with OPENVIBE_ALLOW_INSECURE_HTTP for a test server).
++ void begin(const char* ssid, const char* password, const char* robot, const char* code, const char* name,
++ const char* server = "https://openvibe.bot",
++ const char* capabilitiesJson = "{\"esp32\":{\"drive\":{\"type\":\"differential\"}}}");
++ void loop();
++
++ void setCallbacks(DriveFn drive, ActuatorFn actuator, StopFn stop, LogFn log);
++ void setLatchCallback(ov::Core::LatchFn latch);
++ void setTelemetryProvider(TelemetryFn provider);
++
++ // The local kill switch (protocol §3): latched, persisted in NVS, restored in begin() before WiFi comes up. Only
++ // setLocalStop(false)/resume() clears it; the server's `estop` latched:false never does.
++ void setLocalStop(bool on);
++ void resume() { setLocalStop(false); }
++
++ // Takes the owner's rotation response (POST /api/v1/devices/:id/rotate) without pairing again: it must name this
++ // device. Never prints either secret. Uses the new credential on the next reconnect.
++ bool importCredential(const char* rotateResponseJson);
++
++ bool paired() const { return !credential_.empty(); }
++ ov::Core& core() { return core_; }
++
++ private:
++ void pairOverHttps(); // retries transport/5xx errors with backoff; a 4xx answer is definitive
++ void schedulePairRetry(int64_t now);
++ bool loadCredential();
++ bool saveCredential();
++ bool startLink();
++ void connectWifi(const char* ssid, const char* password);
++ void onWsEvent(WStype_t type, uint8_t* payload, size_t length);
++ void onLatchChanged(bool remote, bool local);
++ void maybePublishTelemetry();
++ void wsSend(const std::string& text);
++ int64_t monotonicMs() const;
++ int64_t unixMs();
++ bool buildExtraHeaders();
++ void logLine(const char* level, const std::string& message);
++ static bool parseServer(const std::string& url, std::string& host, uint16_t& port, std::string& path, bool& secure);
++
++ ov::Core core_;
++ WebSocketsClient ws_;
++ Preferences prefs_;
++ TelemetryFn telemetry_;
++ ov::Core::LatchFn user_latch_;
++ LogFn user_log_;
++ int64_t last_telemetry_ms_ = 0;
++ int64_t next_pair_ms_ = 0;
++ int pair_attempts_ = 0;
++ bool pairing_given_up_ = false;
++ bool link_started_ = false;
++ bool server_ok_ = false; // parseServer() accepted the origin; host_ is cleared and this stays false on any refusal
++ bool prefs_open_ = false;
++ int64_t epoch_offset_ms_ = -1;
++
++ std::string server_;
++ std::string host_;
++ std::string path_ = "/device";
++ uint16_t port_ = 443;
++ bool secure_ = true;
++ std::string extra_headers_; // Authorization/User-Agent, rebuilt on rotation and kept for the link's lifetime
++
++ std::string device_id_;
++ std::string credential_;
++ std::string robot_id_;
++ std::string pairing_robot_; // from the sketch, when no credential is stored yet
++ std::string pairing_code_;
++ std::string pairing_name_;
++ std::string capabilities_json_;
++ std::string firmware_;
++};
ddiff --git a/esp32/src/ov_core.cpp b/esp32/src/ov_core.cpp
nnew file mode 100644
iindex 0000000..3fe2e87
---- /dev/null
++++ b/esp32/src/ov_core.cpp
@@@ -0,0 +1,1096 @@
++// ov_core.cpp — see ov_core.h. Portable C++17, ArduinoJson v7 only. No Arduino headers.
++#include "ov_core.h"
++
++#include <algorithm>
++#include <cmath>
++#include <cstdio>
++#include <cstring>
++
++namespace ov {
++namespace {
++
++// RFC 3339 UTC from Unix milliseconds (protocol: estop_state.at, heartbeat server_time is the server's, not ours).
++std::string iso8601_utc(int64_t unix_ms) {
++ int64_t secs = unix_ms / 1000;
++ int ms = static_cast<int>(unix_ms % 1000);
++ if (ms < 0) {
++ ms += 1000;
++ secs -= 1;
++ }
++ int64_t days = secs / 86400;
++ int64_t rem = secs % 86400;
++ if (rem < 0) {
++ rem += 86400;
++ days -= 1;
++ }
++ const int hh = static_cast<int>(rem / 3600);
++ const int mm = static_cast<int>((rem % 3600) / 60);
++ const int ss = static_cast<int>(rem % 60);
++ // Howard Hinnant's civil-from-days.
++ int64_t z = days + 719468;
++ const int64_t era = (z >= 0 ? z : z - 146096) / 146097;
++ const int64_t doe = z - era * 146097;
++ const int64_t yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
++ int64_t y = yoe + era * 400;
++ const int64_t doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
++ const int64_t mp = (5 * doy + 2) / 153;
++ const int64_t d = doy - (153 * mp + 2) / 5 + 1;
++ const int64_t m = mp + (mp < 10 ? 3 : -9);
++ y += (m <= 2);
++ char buf[48];
++ std::snprintf(buf, sizeof(buf), "%04d-%02d-%02dT%02d:%02d:%02d.%03dZ", static_cast<int>(y), static_cast<int>(m),
++ static_cast<int>(d), hh, mm, ss, ms);
++ return std::string(buf);
++}
++
++bool is_ulid26(const std::string& s) {
++ if (s.size() != 26) return false;
++ for (char c : s) {
++ if (c >= '0' && c <= '9') continue;
++ if (c >= 'A' && c <= 'Z') {
++ if (c == 'I' || c == 'L' || c == 'O' || c == 'U') return false;
++ continue;
++ }
++ return false;
++ }
++ return true;
++}
++
++bool valid_job_id(const std::string& s) {
++ return s.size() == 30 && s.compare(0, 4, "job_") == 0 && is_ulid26(s.substr(4));
++}
++
++bool valid_media_id(const std::string& s) {
++ return s.size() == 30 && s.compare(0, 4, "med_") == 0 && is_ulid26(s.substr(4));
++}
++
++bool is_lower_alnum(char c) { return (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9'); }
++
++bool artifact_name_ok(const std::string& s) {
++ if (s.empty() || s.size() > 128 || !is_lower_alnum(s[0])) return false;
++ for (char c : s) {
++ if (is_lower_alnum(c) || c == '.' || c == '_' || c == '-') continue;
++ return false;
++ }
++ return true;
++}
++
++bool is_alnum(char c) { return (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9'); }
++
++bool artifact_version_ok(const std::string& s) {
++ if (s.empty() || s.size() > 64 || !is_alnum(s[0])) return false;
++ for (char c : s) {
++ if (is_alnum(c) || c == '.' || c == '+' || c == '-') continue;
++ return false;
++ }
++ return true;
++}
++
++bool input_name_ok(const std::string& s) {
++ if (s.empty() || s.size() > 128 || !is_alnum(s[0])) return false;
++ for (char c : s) {
++ if (is_alnum(c) || c == '.' || c == '_' || c == '-') continue;
++ return false;
++ }
++ return true;
++}
++
++bool sha256_ok(const std::string& s) {
++ if (s.size() != 64) return false;
++ for (char c : s) {
++ if ((c >= '0' && c <= '9') || (c >= 'a' && c <= 'f')) continue;
++ return false;
++ }
++ return true;
++}
++
++bool is_runtime_class(const std::string& c) {
++ return c == "function" || c == "code" || c == "browser" || c == "linux" || c == "desktop" || c == "gpu";
++}
++
++bool is_integral(JsonVariantConst v) { return v.is<long long>(); }
++
++// A field present with the wrong JSON type is a malformed job (Go's json.Unmarshal into the Job struct errors).
++bool job_types_ok(JsonObjectConst j) {
++ JsonVariantConst cls = j["class"];
++ if (!cls.isNull() && !cls.is<const char*>()) return false;
++ JsonVariantConst art = j["artifact"];
++ if (!art.isNull()) {
++ if (!art.is<JsonObjectConst>()) return false;
++ JsonVariantConst n = art["name"];
++ if (!n.isNull() && !n.is<const char*>()) return false;
++ JsonVariantConst v = art["version"];
++ if (!v.isNull() && !v.is<const char*>()) return false;
++ }
++ JsonVariantConst ttl = j["ttl_ms"];
++ if (!ttl.isNull() && !is_integral(ttl)) return false;
++ JsonVariantConst lim = j["limits"];
++ if (!lim.isNull()) {
++ if (!lim.is<JsonObjectConst>()) return false;
++ JsonObjectConst lo = lim.as<JsonObjectConst>();
++ for (const char* k : {"wall_ms", "cpu_ms", "mem_bytes"}) {
++ JsonVariantConst v = lo[k];
++ if (!v.isNull() && !is_integral(v)) return false;
++ }
++ }
++ JsonVariantConst net = j["net"];
++ if (!net.isNull() && !net.is<const char*>()) return false;
++ JsonVariantConst in = j["inputs"];
++ if (!in.isNull()) {
++ if (!in.is<JsonArrayConst>()) return false;
++ for (JsonVariantConst e : in.as<JsonArrayConst>()) {
++ if (!e.is<JsonObjectConst>()) return false;
++ JsonObjectConst eo = e.as<JsonObjectConst>();
++ for (const char* k : {"name", "media_id", "sha256"}) {
++ JsonVariantConst v = eo[k];
++ if (!v.isNull() && !v.is<const char*>()) return false;
++ }
++ JsonVariantConst sz = eo["size_bytes"];
++ if (!sz.isNull() && !is_integral(sz)) return false;
++ }
++ }
++ return true;
++}
++
++bool valid_job_inputs(JsonObjectConst j) {
++ JsonVariantConst in = j["inputs"];
++ if (in.isNull()) return true;
++ if (!in.is<JsonArrayConst>()) return false;
++ JsonArrayConst arr = in.as<JsonArrayConst>();
++ if (arr.size() > 32) return false;
++ std::vector<std::string> names;
++ for (JsonVariantConst e : arr) {
++ if (!e.is<JsonObjectConst>()) return false;
++ JsonObjectConst eo = e.as<JsonObjectConst>();
++ std::string name = eo["name"] | "";
++ std::string media = eo["media_id"] | "";
++ std::string sha = eo["sha256"] | "";
++ long long size = eo["size_bytes"] | 0LL;
++ if (!input_name_ok(name) || !valid_media_id(media) || !sha256_ok(sha) || size < 0) return false;
++ if (std::find(names.begin(), names.end(), name) != names.end()) return false;
++ names.push_back(name);
++ }
++ return true;
++}
++
++} // namespace
++
++Core::Core(NowFn now, WallFn wall, SendFn send)
++ : now_(std::move(now)), wall_(std::move(wall)), send_(std::move(send)) {}
++
++int64_t Core::wall_ms() const { return wall_ ? wall_() : now_(); }
++
++void Core::set_callbacks(DriveFn drive, ActuatorFn actuator, StopFn stop, LogFn log) {
++ drive_ = std::move(drive);
++ actuator_ = std::move(actuator);
++ stop_ = std::move(stop);
++ log_fn_ = std::move(log);
++}
++
++void Core::set_latch_callback(LatchFn latch) { latch_fn_ = std::move(latch); }
++
++void Core::set_local_limits(double max_speed, double max_turn, int max_command_ms) {
++ local_limits_.max_speed = std::max(0.0, std::min(1.0, max_speed));
++ local_limits_.max_turn = std::max(0.0, std::min(1.0, max_turn));
++ local_limits_.max_command_ms = max_command_ms > 0 ? max_command_ms : kDefaultMaxCommandMS;
++}
++
++void Core::set_descriptor(const std::string& firmware, const std::string& driver,
++ const std::string& capabilities_json) {
++ firmware_ = firmware;
++ driver_ = driver;
++ capabilities_json_ = capabilities_json.empty() ? "{}" : capabilities_json;
++}
++
++void Core::log(const char* level, const std::string& message) const {
++ if (log_fn_) log_fn_(level, message);
++}
++
++void Core::notify_latch() const {
++ if (latch_fn_) latch_fn_(remote_stop_, local_stop_);
++}
++
++void Core::emit(const char* type, const std::function<void(JsonObject)>& fill) {
++ JsonDocument doc(json_allocator_ ? json_allocator_ : ArduinoJson::detail::DefaultAllocator::instance());
++ doc["v"] = kVersion;
++ doc["seq"] = ++seq_;
++ doc["ts"] = wall_ms();
++ doc["type"] = type;
++ fill(doc.as<JsonObject>());
++ if (doc.overflowed()) {
++ // An ack/nack without its id (or any frame that lost fields to a failed allocation) must not go out half-formed.
++ log("error", "could not build a frame: out of memory; dropping it");
++ return;
++ }
++ std::string out;
++ serializeJson(doc, out);
++ if (send_) send_(out);
++}
++
++// ---- connection lifecycle ----
++
++void Core::connect() {
++ // A new connection: per-direction seq restarts at 1, the clock estimate and heartbeat state start empty, and no
++ // config has been applied yet (so only halt runs, and the first status/estop_state wait for config).
++ seq_ = 0;
++ up_ = false;
++ transport_up_ = true;
++ configured_ = false;
++ allowed_present_ = false;
++ allowed_.clear();
++ has_skew_ = false;
++ skew_ms_ = 0;
++ has_rtt_ = false;
++ last_rtt_ms_ = 0;
++ hb_sent_.clear();
++ credential_refused_ = false;
++ link_lost_ = false;
++ motion_deadline_ms_ = 0;
++ device_id_.clear();
++ session_id_.clear();
++ robot_ids_.clear();
++ hb_ms_ = kDefaultHeartbeatMS;
++ deadman_ms_ = 2 * kDefaultHeartbeatMS;
++ const int64_t now = now_();
++ last_rx_ms_ = now;
++ next_hb_ms_ = now;
++ connected_at_ms_ = 0;
++}
++
++void Core::on_close(int code) {
++ // Every close stops every actuator at once (protocol §Heartbeat, deadman, reconnect and §3).
++ const bool was_up = up_;
++ stop_motion();
++ up_ = false;
++ transport_up_ = false;
++ configured_ = false;
++ if (code == kCloseInvalid || code == kCloseRevoked || (code == 0 && !was_up)) {
++ // A drop before hello is assumed to be the refused case (the arduinoWebSockets library does not surface the close
++ // code or the upgrade's HTTP status); the log says the cause is uncertain rather than asserting a refusal.
++ credential_refused_ = true;
++ if (code == 0) {
++ log("warn", "the link ended before hello (the transport cannot know the close code: a refused credential, a "
++ "network drop or a timeout); retrying no sooner than 10 s");
++ } else {
++ log("error", "the server refused this device's credential: pair again, or import the owner's rotation");
++ }
++ const int ceil = kBackoffMaxMS;
++ next_delay_ms_ = kCredentialRetryMinMS + static_cast<int>(next_random() % static_cast<uint32_t>(ceil + 1));
++ } else {
++ credential_refused_ = false;
++ if (code == kCloseReplaced) {
++ log("warn", "another connection with this credential replaced this one");
++ }
++ next_delay_ms_ = normal_backoff_ms();
++ }
++ reconnect_attempt_ += 1;
++}
++
++int Core::normal_backoff_ms() {
++ // Exponential from BackoffMin to BackoffMax with full jitter; the floor keeps a flapping server from being hammered.
++ int ceil = kBackoffMaxMS;
++ if (reconnect_attempt_ < 32) {
++ const long long c = static_cast<long long>(kBackoffMinMS) << reconnect_attempt_;
++ if (c > 0 && c < ceil) ceil = static_cast<int>(c);
++ }
++ const int jitter = static_cast<int>(next_random() % static_cast<uint32_t>(ceil + 1));
++ return kBackoffMinMS / 2 + jitter;
++}
++
++uint32_t Core::next_random() {
++ uint32_t x = rng_state_;
++ x ^= x << 13;
++ x ^= x >> 17;
++ x ^= x << 5;
++ rng_state_ = x;
++ return x;
++}
++
++// ---- server frames ----
++
++void Core::feed(const char* data, size_t length) {
++ const int64_t now = now_();
++ if (length > kMaxFrameBytes) {
++ // Too big to be any frame this device handles; an estop could be hiding in it, so treat it as unreadable.
++ log("error", "frame of " + std::to_string(length) + " bytes is over the " + std::to_string(kMaxFrameBytes) +
++ "-byte device limit; stopping and dropping the link");
++ stop_motion();
++ link_lost_ = true;
++ return;
++ }
++ JsonDocument doc(json_allocator_ ? json_allocator_ : ArduinoJson::detail::DefaultAllocator::instance());
++ const DeserializationError err =
++ deserializeJson(doc, data, length, DeserializationOption::NestingLimit(kMaxJsonDepth));
++ if (err) {
++ if (err == DeserializationError::NoMemory || err == DeserializationError::TooDeep) {
++ // The frame could not be parsed for lack of memory or depth: it may have been an estop, so fail safe by stopping
++ // and dropping the link; the reconnect re-reads config.estop_latched.
++ log("error", std::string("frame could not be decoded safely (") + err.c_str() +
++ "); stopping and dropping the link");
++ stop_motion();
++ link_lost_ = true;
++ } else {
++ log("warn", std::string("bad frame: ") + err.c_str());
++ }
++ return;
++ }
++ const int v = doc["v"] | -1;
++ if (v != kVersion) {
++ log("warn", "bad frame: unsupported envelope version");
++ return;
++ }
++ const char* type_c = doc["type"] | "";
++ if (type_c == nullptr || *type_c == '\0') {
++ log("warn", "bad frame: no type");
++ return;
++ }
++ const std::string type(type_c);
++ last_rx_ms_ = now;
++ const int64_t ts = doc["ts"] | static_cast<int64_t>(0);
++ if (ts > 0) observe(ts, now);
++
++ if (type == "hello") {
++ handle_hello(doc, now);
++ return;
++ }
++ if (!up_) {
++ log("warn", "ignoring a frame before hello: " + type);
++ return;
++ }
++ if (type == "config") {
++ handle_config(doc);
++ } else if (type == "command") {
++ handle_command(doc, ts, now);
++ } else if (type == "estop") {
++ handle_estop(doc);
++ } else if (type == "heartbeat_ack") {
++ handle_heartbeat_ack(doc, now);
++ } else if (type == "error") {
++ const std::string code = doc["code"] | "";
++ const std::string detail = doc["detail"] | "";
++ log("warn", "server refused a frame: " + code + " " + detail);
++ } else if (type == "job") {
++ handle_job(doc);
++ } else if (type == "job_cancel" || type == "job_exit_ack") {
++ // No worker runs jobs here, so an unknown, refused or ended id is ignored with no answer.
++ } else {
++ log("debug", "ignoring unknown frame type " + type);
++ }
++}
++
++void Core::handle_hello(JsonDocument& doc, int64_t now) {
++ if (up_) {
++ log("debug", "ignoring a second hello on this connection");
++ return;
++ }
++ up_ = true;
++ device_id_ = doc["device_id"] | "";
++ session_id_ = doc["session_id"] | "";
++ robot_ids_.clear();
++ JsonArrayConst ids = doc["robot_ids"];
++ for (JsonVariantConst r : ids) {
++ if (r.is<const char*>()) robot_ids_.emplace_back(r.as<const char*>());
++ }
++ last_rx_ms_ = now;
++ next_hb_ms_ = now; // the first heartbeat goes out promptly once hello is up
++ connected_at_ms_ = now;
++ log("info", "link up: device " + device_id_ + ", session " + session_id_);
++}
++
++void Core::handle_config(JsonDocument& doc) {
++ // A wrong-typed estop_latched is a malformed frame (Go's json.Unmarshal would reject it whole): apply nothing and
++ // keep the latch exactly as it is, rather than reading `"false"` or `0` as false and releasing the e-stop.
++ JsonVariantConst estopv = doc["estop_latched"];
++ if (!estopv.isNull() && !estopv.is<bool>()) {
++ log("warn", "config with a non-boolean estop_latched ignored; the latch is unchanged");
++ return;
++ }
++
++ int hb = doc["heartbeat_ms"] | 0;
++ if (hb <= 0) hb = doc["limits"]["heartbeat_ms"] | 0;
++ if (hb <= 0) hb = kDefaultHeartbeatMS;
++ if (hb > kMaxHeartbeatMS) {
++ log("warn", "config.heartbeat_ms " + std::to_string(hb) + " capped at " + std::to_string(kMaxHeartbeatMS) + " ms");
++ hb = kMaxHeartbeatMS;
++ }
++ hb_ms_ = hb;
++ deadman_ms_ = 2 * static_cast<int64_t>(hb); // 64-bit: no overflow however the server sets hb
++
++ double server_speed = 1.0;
++ double server_turn = 1.0;
++ int server_max_ms = kDefaultMaxCommandMS;
++ JsonVariantConst lim = doc["limits"];
++ if (lim.is<JsonObjectConst>()) {
++ JsonVariantConst ms = lim["max_speed"];
++ if (ms.is<double>()) server_speed = ms.as<double>();
++ JsonVariantConst mt = lim["max_turn"];
++ if (mt.is<double>()) server_turn = mt.as<double>();
++ const int v = lim["max_command_ms"] | 0;
++ if (v > 0) server_max_ms = v;
++ }
++ // The stricter of the server's limits and this device's local caps (protocol step 6).
++ limits_.max_speed = std::max(0.0, std::min(std::min(1.0, server_speed), local_limits_.max_speed));
++ limits_.max_turn = std::max(0.0, std::min(std::min(1.0, server_turn), local_limits_.max_turn));
++ limits_.max_command_ms = server_max_ms;
++ if (local_limits_.max_command_ms > 0 && local_limits_.max_command_ms < limits_.max_command_ms) {
++ limits_.max_command_ms = local_limits_.max_command_ms;
++ }
++ if (limits_.max_command_ms <= 0) limits_.max_command_ms = kDefaultMaxCommandMS;
++
++ allowed_present_ = false;
++ allowed_.clear();
++ JsonVariantConst ac = doc["allowed_commands"];
++ if (ac.is<JsonArrayConst>()) {
++ allowed_present_ = true;
++ allowed_[kKindHalt] = true; // halt is always accepted
++ for (JsonVariantConst k : ac.as<JsonArrayConst>()) {
++ if (k.is<const char*>()) allowed_[std::string(k.as<const char*>())] = true;
++ }
++ }
++
++ // The server's estop_latched is the owner's latch, which may have been set or cleared while the link was down.
++ const bool estop = estopv.is<bool>() && estopv.as<bool>();
++ if (estop && !remote_stop_) {
++ set_remote_stop(true, "latched on the server", true);
++ } else if (!estop && remote_stop_) {
++ set_remote_stop(false, "", true);
++ }
++
++ const bool first = !configured_;
++ configured_ = true;
++ if (first) send_status();
++ send_estop_state();
++ log("info", "config applied: heartbeat " + std::to_string(hb_ms_) + " ms");
++}
++
++void Core::handle_command(JsonDocument& doc, int64_t ts, int64_t now) {
++ // Bound the id before copying it: an id longer than kMaxIdBytes is not echoed (it is not cached either), so a
++ // hostile server cannot make the dedup cache hold kilobyte ids.
++ JsonVariantConst idv = doc["id"];
++ if (!idv.is<const char*>() || *idv.as<const char*>() == '\0') {
++ log("warn", "command without id ignored");
++ return;
++ }
++ const size_t id_len = std::strlen(idv.as<const char*>());
++ if (id_len > kMaxIdBytes) {
++ log("warn", "command id longer than " + std::to_string(kMaxIdBytes) + " bytes ignored");
++ return;
++ }
++ const std::string id(idv.as<const char*>(), id_len);
++ DedupResult cached;
++ if (recall(id, cached, now)) {
++ if (cached.ok) {
++ send_ack(id, 0);
++ } else {
++ send_nack(id, cached.fault.c_str(), cached.message);
++ }
++ return;
++ }
++ // The kind is bounded too; it is never echoed into a cached message (a fixed message keeps the cache's size fixed).
++ JsonVariantConst kindv = doc["kind"];
++ const std::string kind = kindv.is<const char*>() ? std::string(kindv.as<const char*>()) : std::string();
++ const bool known = kind == kKindDrive || kind == kKindActuator || kind == kKindPTZ || kind == kKindSay ||
++ kind == kKindDisplay || kind == kKindHalt;
++ if (!known || kind.size() > kMaxKindBytes) {
++ send_nack(id, kFaultUnsupported, "unknown kind");
++ remember(id, DedupResult{false, kFaultUnsupported, "unknown kind"});
++ return;
++ }
++ if (kind == kKindHalt) {
++ stop_motion();
++ send_ack(id, now_() - now);
++ remember(id, DedupResult{true, "", ""});
++ return;
++ }
++ if (!configured_) {
++ send_nack(id, kFaultNotReady, "the server's config has not arrived on this connection yet");
++ remember(id, DedupResult{false, kFaultNotReady, "the server's config has not arrived on this connection yet"});
++ return;
++ }
++ if (allowed_present_ && allowed_.find(kind) == allowed_.end()) {
++ const std::string msg = kind + " is not in this robot's allowed_commands";
++ send_nack(id, kFaultNotAllowed, msg);
++ remember(id, DedupResult{false, kFaultNotAllowed, msg});
++ return;
++ }
++ const bool guarded = kind == kKindDrive || kind == kKindPTZ || kind == kKindActuator;
++ if (guarded) {
++ if (link_lost_) {
++ // The core has decided to drop the link but the transport has not torn the socket down yet: a guarded command
++ // arriving in that window is not safe to run.
++ const std::string msg = "the control link is down";
++ send_nack(id, kFaultNotConnected, msg);
++ remember(id, DedupResult{false, kFaultNotConnected, msg});
++ return;
++ }
++ if (local_stop_) {
++ const std::string msg = "stopped on the device with the local kill switch";
++ send_nack(id, kFaultLocalStop, msg);
++ remember(id, DedupResult{false, kFaultLocalStop, msg});
++ return;
++ }
++ if (remote_stop_) {
++ const std::string msg = remote_reason_.empty() ? std::string("the remote e-stop is latched") : remote_reason_;
++ send_nack(id, kFaultEstopped, msg);
++ remember(id, DedupResult{false, kFaultEstopped, msg});
++ return;
++ }
++ }
++
++ const int64_t deadline_ms = doc["deadline_ms"] | static_cast<int64_t>(0);
++ int deadline_rel = 0;
++ if (deadline_ms > 0) {
++ const int64_t left = deadline_ms - server_now(ts, now);
++ if (left <= 0) {
++ const std::string msg = "the deadline passed " + std::to_string(-left) + " ms before the command arrived";
++ send_nack(id, kFaultExpired, msg);
++ remember(id, DedupResult{false, kFaultExpired, msg});
++ return;
++ }
++ deadline_rel = static_cast<int>(std::min<int64_t>(left, limits_.max_command_ms));
++ } else {
++ deadline_rel = std::min(kDefaultDeadlineMS, limits_.max_command_ms);
++ }
++ if (deadline_rel <= 0) deadline_rel = kDefaultDeadlineMS;
++
++ std::string value_json;
++ std::string fault;
++ std::string message;
++ if (!clamp_command(kind, doc["value"], value_json, fault, message)) {
++ send_nack(id, fault.c_str(), message);
++ remember(id, DedupResult{false, fault, message});
++ return;
++ }
++
++ if (kind == kKindDrive) {
++ if (!drive_) {
++ send_nack(id, kFaultUnsupported, "no driver on this device handles drive");
++ remember(id, DedupResult{false, kFaultUnsupported, "no driver on this device handles drive"});
++ return;
++ }
++ drive_(value_json, deadline_rel);
++ motion_deadline_ms_ = now_() + deadline_rel;
++ send_ack(id, now_() - now);
++ remember(id, DedupResult{true, "", ""});
++ return;
++ }
++ if (kind == kKindActuator) {
++ JsonDocument vd(value_allocator_ ? value_allocator_ : ArduinoJson::detail::DefaultAllocator::instance());
++ const DeserializationError verr = deserializeJson(vd, value_json);
++ if (verr || vd.overflowed()) {
++ // The re-parse of the clamped value failed or lost its fields (an actuator must keep its name and value).
++ send_nack(id, kFaultBadValue, "the actuator value could not be decoded safely");
++ remember(id, DedupResult{false, kFaultBadValue, "the actuator value could not be decoded safely"});
++ return;
++ }
++ const std::string name = vd["name"] | "";
++ if (!actuator_) {
++ send_nack(id, kFaultUnsupported, "no driver on this device handles actuator");
++ remember(id, DedupResult{false, kFaultUnsupported, "no driver on this device handles actuator"});
++ return;
++ }
++ actuator_(name, value_json, deadline_rel);
++ motion_deadline_ms_ = now_() + deadline_rel;
++ send_ack(id, now_() - now);
++ remember(id, DedupResult{true, "", ""});
++ return;
++ }
++ // ptz, say and display are valid kinds with no driver on this device (protocol step 7).
++ const std::string msg = "no driver on this device handles " + kind;
++ send_nack(id, kFaultUnsupported, msg);
++ remember(id, DedupResult{false, kFaultUnsupported, msg});
++}
++
++void Core::handle_estop(JsonDocument& doc) {
++ JsonVariantConst latchedv = doc["latched"];
++ if (!latchedv.isNull() && !latchedv.is<bool>()) {
++ // Wrong type: ignore the frame and keep the latch (Go's json.Unmarshal rejects it too). Reading `"false"` or 0 as
++ // false would release the e-stop on a malformed frame.
++ log("warn", "estop with a non-boolean latched ignored; the latch is unchanged");
++ return;
++ }
++ const bool latched = latchedv.is<bool>() && latchedv.as<bool>();
++ if (latched) {
++ std::string by = doc["by"] | "";
++ // The frame can carry up to kMaxFrameBytes, and this string is copied into every dedup entry that answers an
++ // estopped command: bound it so the cache cannot be filled with megabytes.
++ if (by.size() > kMaxEstopReasonBytes) by.resize(kMaxEstopReasonBytes);
++ set_remote_stop(true, by.empty() ? std::string("the remote e-stop is latched") : ("by " + by), true);
++ } else {
++ set_remote_stop(false, "", true);
++ }
++ send_estop_state();
++}
++
++void Core::handle_heartbeat_ack(JsonDocument& doc, int64_t now) {
++ // Both shapes the fixtures carry: the newer echo/t fields, and an older server that echoes the heartbeat's seq.
++ int64_t echo = doc["t"] | static_cast<int64_t>(0);
++ if (echo == 0) {
++ JsonVariantConst e = doc["echo"];
++ if (e.is<long long>()) echo = e.as<long long>();
++ }
++ const uint64_t seq = doc["seq"] | static_cast<uint64_t>(0);
++ bool found = false;
++ int64_t at = 0;
++ if (echo > 0) {
++ for (auto it = hb_sent_.begin(); it != hb_sent_.end(); ++it) {
++ if (it->second.t == echo) {
++ at = it->second.at;
++ hb_sent_.erase(it);
++ found = true;
++ break;
++ }
++ }
++ }
++ if (!found && seq != 0) {
++ auto it = hb_sent_.find(seq);
++ if (it != hb_sent_.end()) {
++ at = it->second.at;
++ hb_sent_.erase(it);
++ found = true;
++ }
++ }
++ if (found) {
++ last_rtt_ms_ = now > at ? now - at : 0; // a measured 0 ms is still a measurement
++ has_rtt_ = true;
++ }
++}
++
++void Core::handle_job(JsonDocument& doc) {
++ JsonVariantConst jobv = doc["job"];
++ if (!jobv.is<JsonObjectConst>()) {
++ // Without a `job` object Go cannot reach an id at all, so the nack carries an empty one.
++ send_nack("", kFaultBadValue, kJobBadID);
++ return;
++ }
++ JsonObjectConst j = jobv.as<JsonObjectConst>();
++ JsonVariantConst idv = j["id"];
++ if (!idv.is<const char*>()) {
++ send_nack("", kFaultBadValue, kJobBadID);
++ return;
++ }
++ const std::string id = idv.as<const char*>();
++ if (!valid_job_id(id)) {
++ send_nack(id, kFaultBadValue, kJobBadID);
++ return;
++ }
++ const auto seen = job_answers_.find(id);
++ if (seen != job_answers_.end()) {
++ send_nack(id, seen->second.first.c_str(), seen->second.second);
++ return;
++ }
++ if (!job_types_ok(j)) {
++ send_nack(id, kFaultBadFrame, kJobBadFrame);
++ remember_job(id, kFaultBadFrame, kJobBadFrame);
++ return;
++ }
++ const std::string cls = j["class"] | "";
++ if (!is_runtime_class(cls)) {
++ send_nack(id, kFaultUnsupported, kJobUnknownClass);
++ remember_job(id, kFaultUnsupported, kJobUnknownClass);
++ return;
++ }
++ if (cls == "function" || cls == "code") {
++ JsonVariantConst art = j["artifact"];
++ bool ok = art.is<JsonObjectConst>();
++ if (ok) {
++ JsonObjectConst ao = art.as<JsonObjectConst>();
++ const std::string an = ao["name"] | "";
++ const std::string av = ao["version"] | "";
++ ok = artifact_name_ok(an) && artifact_version_ok(av);
++ }
++ if (!ok) {
++ send_nack(id, kFaultBadValue, kJobNoArtifact);
++ remember_job(id, kFaultBadValue, kJobNoArtifact);
++ return;
++ }
++ }
++ JsonVariantConst netv = j["net"];
++ if (netv.is<const char*>()) {
++ const std::string net = netv.as<const char*>();
++ if (!(net == "deny" || net == "none" || net == "public" || net == "openvibe-only")) {
++ send_nack(id, kFaultUnsupported, kJobNetUnsupported);
++ remember_job(id, kFaultUnsupported, kJobNetUnsupported);
++ return;
++ }
++ }
++ if (!valid_job_inputs(j)) {
++ send_nack(id, kFaultBadValue, kJobBadInputs);
++ remember_job(id, kFaultBadValue, kJobBadInputs);
++ return;
++ }
++ JsonVariantConst args = j["args"];
++ JsonObjectConst lo = j["limits"].as<JsonObjectConst>();
++ const long long ttl = j["ttl_ms"] | 0LL;
++ const long long wall = lo["wall_ms"] | 0LL;
++ const long long cpu = lo["cpu_ms"] | 0LL;
++ const long long mem = lo["mem_bytes"] | 0LL;
++ if (!args.is<JsonObjectConst>() || ttl < 1 || wall < 1 || cpu < 1 || mem < 1) {
++ send_nack(id, kFaultBadValue, kJobBadLimits);
++ remember_job(id, kFaultBadValue, kJobBadLimits);
++ return;
++ }
++ // No worker is on this device, so every valid class is unavailable (protocol §Jobs, `class not available`).
++ send_nack(id, kFaultUnsupported, kJobNotAvailable);
++ remember_job(id, kFaultUnsupported, kJobNotAvailable);
++}
++
++// ---- time, clamping, routing helpers ----
++
++void Core::observe(int64_t ts, int64_t now) {
++ // The skew is estimated against the monotonic clock: the server's Unix ts advances at the same rate, and a wall-clock
++ // step (NTP, a backwards correction) cannot poison the estimate and open an expired deadline.
++ if (ts <= 0) return;
++ if (!has_skew_) {
++ has_skew_ = true;
++ skew_ms_ = ts - now;
++ return;
++ }
++ const int64_t s = ts - now;
++ if (s > skew_ms_) skew_ms_ = s; // the largest ts − receive time: a delayed frame cannot reopen an expired deadline
++}
++
++int64_t Core::server_now(int64_t ts, int64_t now) const {
++ if (has_skew_) return now + skew_ms_;
++ if (ts > 0) return ts;
++ return now;
++}
++
++bool Core::clamp_command(const std::string& kind, JsonVariantConst value, std::string& out, std::string& fault,
++ std::string& message) const {
++ JsonDocument vd(value_allocator_ ? value_allocator_ : ArduinoJson::detail::DefaultAllocator::instance());
++ if (!value.isNull()) {
++ if (!value.is<JsonObjectConst>()) {
++ fault = kFaultBadValue;
++ message = "value must be a JSON object";
++ return false;
++ }
++ vd.set(value);
++ }
++ if (!vd.is<JsonObject>()) vd.to<JsonObject>();
++ if (vd.overflowed()) {
++ // The copy lost fields to a failed allocation (for example a throttle is gone): a driver must never see a
++ // truncated value, so refuse the command rather than clamping what is left.
++ fault = kFaultBadValue;
++ message = "value is too large to handle safely";
++ return false;
++ }
++ JsonObject v = vd.as<JsonObject>();
++
++ auto num = [&](const char* k, JsonVariantConst raw, double& result) -> bool {
++ if (!raw.is<double>()) {
++ fault = kFaultBadValue;
++ message = std::string(k) + " must be a number";
++ return false;
++ }
++ const double f = raw.as<double>();
++ if (std::isnan(f) || std::isinf(f)) {
++ fault = kFaultBadValue;
++ message = std::string(k) + " must be a finite number";
++ return false;
++ }
++ result = f;
++ return true;
++ };
++ auto clamp_to = [](double f, double m) {
++ if (f > m) return m;
++ if (f < -m) return -m;
++ return f;
++ };
++
++ if (kind == kKindDrive) {
++ for (JsonPair kv : v) {
++ const std::string key = kv.key().c_str();
++ const bool speed = key == "throttle" || key == "x" || key == "y";
++ const bool turn = key == "steer" || key == "rotation";
++ if (!speed && !turn) continue;
++ double f = 0;
++ if (!num(key.c_str(), kv.value(), f)) return false;
++ const double m = speed ? limits_.max_speed : limits_.max_turn;
++ kv.value().set(clamp_to(clamp_to(f, 1.0), m));
++ }
++ } else if (kind == kKindPTZ) {
++ for (JsonPair kv : v) {
++ const std::string key = kv.key().c_str();
++ if (key != "pan" && key != "tilt" && key != "zoom") continue;
++ double f = 0;
++ if (!num(key.c_str(), kv.value(), f)) return false;
++ kv.value().set(clamp_to(f, 1.0));
++ }
++ } else if (kind == kKindActuator) {
++ JsonVariantConst name = v["name"];
++ if (!name.is<const char*>() || std::string(name.as<const char*>()).empty()) {
++ fault = kFaultBadValue;
++ message = "actuator needs a name";
++ return false;
++ }
++ JsonVariant val = v["value"];
++ if (val.is<double>()) {
++ const double f = val.as<double>();
++ if (std::isnan(f) || std::isinf(f)) {
++ fault = kFaultBadValue;
++ message = "value must be a finite number";
++ return false;
++ }
++ val.set(clamp_to(f, 1.0));
++ }
++ } else if (kind == kKindSay) {
++ JsonVariantConst t = v["text"];
++ if (!t.is<const char*>()) {
++ fault = kFaultBadValue;
++ message = "say needs text (1 to 1000 bytes)";
++ return false;
++ }
++ const std::string s = t.as<const char*>();
++ if (s.empty() || s.size() > 1000) {
++ fault = kFaultBadValue;
++ message = "say needs text (1 to 1000 bytes)";
++ return false;
++ }
++ }
++ serializeJson(vd, out);
++ return true;
++}
++
++void Core::send_ack(const std::string& id, int64_t latency_ms) {
++ emit("ack", [&](JsonObject o) {
++ o["id"] = id;
++ if (latency_ms > 0) o["latency_ms"] = latency_ms;
++ });
++}
++
++void Core::send_nack(const std::string& id, const char* fault, const std::string& message) {
++ emit("nack", [&](JsonObject o) {
++ o["id"] = id;
++ o["fault_code"] = fault;
++ if (!message.empty()) o["message"] = message;
++ });
++}
++
++void Core::remember(const std::string& id, DedupResult result) {
++ if (dedup_.find(id) == dedup_.end()) dedup_order_.push_back(id);
++ result.at_ms = now_();
++ dedup_[id] = std::move(result);
++ while (dedup_order_.size() > 256) {
++ dedup_.erase(dedup_order_.front());
++ dedup_order_.erase(dedup_order_.begin());
++ }
++}
++
++bool Core::recall(const std::string& id, DedupResult& result, int64_t now) {
++ const auto it = dedup_.find(id);
++ if (it == dedup_.end()) return false;
++ if (now - it->second.at_ms > kDedupTTLMS) {
++ // The spec's ten minutes have passed: forget it, so the id runs again rather than being answered from a stale entry.
++ dedup_.erase(it);
++ dedup_order_.erase(std::remove(dedup_order_.begin(), dedup_order_.end(), id), dedup_order_.end());
++ return false;
++ }
++ result = it->second;
++ return true;
++}
++
++void Core::remember_job(const std::string& id, const char* fault, const std::string& message) {
++ if (job_answers_.find(id) == job_answers_.end()) job_order_.push_back(id);
++ job_answers_[id] = {fault, message};
++ while (job_order_.size() > 64) {
++ job_answers_.erase(job_order_.front());
++ job_order_.erase(job_order_.begin());
++ }
++}
++
++void Core::stop_motion() {
++ motion_deadline_ms_ = 0;
++ if (stop_) stop_();
++}
++
++void Core::set_remote_stop(bool on, const std::string& reason, bool notify) {
++ if (on) {
++ remote_stop_ = true;
++ remote_reason_ = reason.size() > kMaxEstopReasonBytes ? reason.substr(0, kMaxEstopReasonBytes) : reason;
++ stop_motion();
++ if (notify) {
++ log("warn", "e-stop latched: " + remote_reason_); // the bounded copy, so a huge `by` cannot flood the log either
++ notify_latch();
++ }
++ } else {
++ remote_stop_ = false;
++ remote_reason_.clear();
++ if (notify) {
++ log("warn", "e-stop cleared; motion allowed again");
++ notify_latch();
++ }
++ }
++}
++
++void Core::set_local_stop(bool on) {
++ if (on == local_stop_) return;
++ local_stop_ = on;
++ if (on) {
++ stop_motion();
++ log("warn", "local kill switch latched");
++ } else {
++ log("warn", "local kill switch cleared; motion allowed again");
++ }
++ notify_latch();
++ send_estop_state();
++}
++
++// ---- heartbeat cadence, deadman, motion deadline ----
++
++void Core::tick() {
++ const int64_t now = now_();
++ // A session that has been up for kStableLinkMS resets the reconnect backoff, exactly as the Go Node's Run does after
++ // a session of more than 30 s; otherwise a handful of drops would leave the delay at its maximum forever.
++ if (up_ && reconnect_attempt_ > 0 && connected_at_ms_ > 0 && now - connected_at_ms_ >= kStableLinkMS) {
++ reconnect_attempt_ = 0;
++ }
++ if (!up_) {
++ // Only while the socket is up and we are waiting for hello. With the transport down (between reconnects) nothing
++ // can arrive, and a stale last_rx_ms_ would otherwise fire this on every loop() pass and cancel the reconnect.
++ if (transport_up_ && !link_lost_) {
++ const int64_t allowance = std::max<int64_t>(deadman_ms_, kHelloAllowanceMS);
++ if (now - last_rx_ms_ > allowance) {
++ link_lost_ = true;
++ stop_motion();
++ log("error", "no hello from the server in time; closing the link");
++ }
++ }
++ return;
++ }
++ if (now >= next_hb_ms_) {
++ send_heartbeat();
++ next_hb_ms_ = now + hb_ms_;
++ }
++ if (!link_lost_ && now - last_rx_ms_ > deadman_ms_) {
++ link_lost_ = true;
++ stop_motion();
++ log("error", "heartbeat lost: nothing from the server in time; closing the link");
++ return;
++ }
++ if (motion_deadline_ms_ > 0 && now >= motion_deadline_ms_) {
++ stop_motion();
++ log("warn", "the motion deadline passed; stopping");
++ }
++}
++
++void Core::send_heartbeat() {
++ const int64_t t = wall_ms(); // protocol §Device → server: the heartbeat's send time in Unix ms
++ const int64_t at = now_();
++ emit("heartbeat", [&](JsonObject o) {
++ o["seq"] = seq_; // the envelope's seq, as older servers echo
++ o["t"] = t;
++ if (has_rtt_) o["rtt_ms"] = last_rtt_ms_;
++ });
++ hb_sent_[seq_] = HbSend{t, at};
++ for (auto it = hb_sent_.begin(); it != hb_sent_.end();) {
++ if (it->first + 8 < seq_) {
++ it = hb_sent_.erase(it);
++ } else {
++ ++it;
++ }
++ }
++}
++
++// ---- status, estop_state, telemetry ----
++
++void Core::send_status() {
++ if (!configured_) return;
++ emit("status", [&](JsonObject o) {
++ o["firmware"] = firmware_;
++ JsonDocument caps;
++ if (deserializeJson(caps, capabilities_json_)) {
++ caps.to<JsonObject>();
++ }
++ o["capabilities"].set(caps.as<JsonVariantConst>());
++ o["faults"].to<JsonArray>();
++ o["estop_latched"] = estopped();
++ });
++}
++
++void Core::send_estop_state() {
++ if (!configured_) return;
++ emit("estop_state", [&](JsonObject o) {
++ o["latched"] = estopped();
++ o["by"] = "device";
++ o["at"] = iso8601_utc(wall_ms());
++ if (robot_ids_.size() == 1) o["robot_id"] = robot_ids_[0];
++ o["local_stop"] = local_stop_;
++ if (!remote_reason_.empty()) o["reason"] = remote_reason_;
++ });
++}
++
++void Core::send_telemetry(const Telemetry& telemetry) {
++ emit("telemetry", [&](JsonObject o) {
++ if (telemetry.has_battery) o["battery"] = telemetry.battery;
++ if (telemetry.has_voltage) o["voltage"] = telemetry.voltage;
++ if (telemetry.has_rssi) o["rssi"] = telemetry.rssi;
++ if (!telemetry.sensors_json.empty()) {
++ JsonDocument s;
++ if (!deserializeJson(s, telemetry.sensors_json)) o["sensors"].set(s.as<JsonVariantConst>());
++ }
++ });
++}
++
++// ---- pairing helpers ----
++
++bool Core::normalize_pair_code(const std::string& in, std::string& out) {
++ out.clear();
++ for (char c : in) {
++ if (c == '-' || c == ' ') continue;
++ if (c >= 'a' && c <= 'z') c = static_cast<char>(c - 'a' + 'A');
++ if (c == 'I' || c == 'L') c = '1';
++ if (c == 'O') c = '0';
++ out.push_back(c);
++ }
++ if (out.size() != 8) {
++ out.clear();
++ return false;
++ }
++ for (char c : out) {
++ if ((c >= '0' && c <= '9') || (c >= 'A' && c <= 'Z' && c != 'I' && c != 'L' && c != 'O' && c != 'U')) continue;
++ out.clear();
++ return false;
++ }
++ return true;
++}
++
++std::string Core::build_pair_request(const std::string& robot, const std::string& code,
++ const std::string& agent_version, const std::string& device_kind,
++ const std::string& driver, const std::string& capabilities_json,
++ const std::string& name) {
++ JsonDocument doc;
++ if (!robot.empty()) doc["robot"] = robot;
++ doc["code"] = code;
++ doc["agent_version"] = agent_version;
++ doc["device_kind"] = device_kind;
++ JsonArray drivers = doc["drivers"].to<JsonArray>();
++ if (!driver.empty()) drivers.add(driver);
++ JsonDocument caps;
++ if (deserializeJson(caps, capabilities_json)) caps.to<JsonObject>();
++ doc["capabilities"].set(caps.as<JsonVariantConst>());
++ if (!name.empty()) doc["name"] = name;
++ std::string out;
++ serializeJson(doc, out);
++ return out;
++}
++
++bool Core::parse_paired(const std::string& frame, Paired& out) {
++ JsonDocument doc;
++ if (deserializeJson(doc, frame)) return false;
++ out.device_id = doc["device_id"] | "";
++ out.credential = doc["credential"] | "";
++ out.publish_key = doc["publish_key"] | "";
++ out.whip_url = doc["whip_url"] | "";
++ JsonArrayConst ids = doc["robot_ids"];
++ out.robot_id.clear();
++ for (JsonVariantConst r : ids) {
++ if (r.is<const char*>()) {
++ out.robot_id = r.as<const char*>();
++ break;
++ }
++ }
++ return true;
++}
++
++} // namespace ov
ddiff --git a/esp32/src/ov_core.h b/esp32/src/ov_core.h
nnew file mode 100644
iindex 0000000..beb4f06
---- /dev/null
++++ b/esp32/src/ov_core.h
@@@ -0,0 +1,304 @@
++// ov_core.h — the OpenVibe Bot device protocol, in portable C++17.
++//
++// This is the whole stateful device side of docs/protocol.md sections 1–3 (minus jobs and video): the frame envelope,
++// hello/config/command/estop/heartbeat_ack handling, clamping before any driver sees a value, ack/nack, the heartbeat
++// cadence and deadman, the motion deadline, the e-stop latch and the close-code policy. It has no Arduino headers:
++// it includes only ArduinoJson (v7) and the standard library, so the same file runs on an ESP32 and in the desktop
++// conformance harness (esp32/test).
++//
++// Time is injected as two clocks: a monotonic one for every timer (heartbeat, deadman, motion deadline) and a Unix
++// one for the protocol's timestamps only. Every side effect is a callback (send/heartbeat frames, drive, actuator,
++// stop, log, latch change). The Arduino transport lives in OpenVibeNode.h; nothing here touches WiFi, TLS, NVS or a
++// board.
++//
++// Source of truth: docs/protocol.md and internal/protocol in OpenVibe.Node. Where this file diverges from the Go Node
++// it is because the ESP32 has no plugin supervisor, no worker and no WHIP publisher, and says so in the protocol's own
++// language (unknown kinds are `unsupported`, a job is `class not available`).
++#pragma once
++
++#include <ArduinoJson.h>
++
++#include <cstddef>
++#include <cstdint>
++#include <functional>
++#include <map>
++#include <string>
++#include <utility>
++#include <vector>
++
++namespace ov {
++
++// Envelope version (protocol §Frames).
++inline constexpr int kVersion = 1;
++
++// Timing defaults (protocol §Commands, §Heartbeat, deadman, reconnect).
++inline constexpr int kDefaultDeadlineMS = 300;
++inline constexpr int kDefaultMaxCommandMS = 1000;
++inline constexpr int kDefaultHeartbeatMS = 1000;
++inline constexpr int kMaxHeartbeatMS = 5000; // the server cannot widen the deadman beyond 2 x this
++inline constexpr int64_t kHelloAllowanceMS = 10000;
++inline constexpr int kCredentialRetryMinMS = 10000; // 4002/4003/401/403: never sooner
++inline constexpr int kBackoffMinMS = 500;
++inline constexpr int kBackoffMaxMS = 30000;
++inline constexpr int64_t kStableLinkMS = 30000; // a session this long resets the reconnect backoff (as the Go Node does)
++inline constexpr int64_t kDedupTTLMS = 600000; // a repeated id is answered for 10 minutes and across reconnects
++
++// Resource limits for one inbound frame. The protocol allows frames up to 1 MiB; a device with a few hundred KiB of
++// heap bounds them far lower. Anything over these limits (or a frame the JSON parser cannot allocate) is treated as a
++// frame that might have been an e-stop: motion stops and the link is dropped to re-read config.estop_latched.
++inline constexpr size_t kMaxFrameBytes = 16384;
++inline constexpr size_t kMaxJsonDepth = 10; // ArduinoJson's own default nesting limit, passed explicitly
++inline constexpr size_t kMaxIdBytes = 64; // command ids (and kinds) longer than this are rejected unread
++inline constexpr size_t kMaxKindBytes = 64;
++// The e-stop reason (`by`, or a cached nack message) is bounded too: an estop frame can carry a `by` up to the frame
++// limit, and that string is copied into every dedup entry that answers an estopped command. 64 bytes keeps the cache's
++// total size small while leaving room for the "by " prefix.
++inline constexpr size_t kMaxEstopReasonBytes = 64;
++
++// Close codes the server ends the socket with (protocol §2).
++inline constexpr int kCloseReplaced = 4000;
++inline constexpr int kCloseInvalid = 4002;
++inline constexpr int kCloseRevoked = 4003;
++
++// Fault codes carried by nack.fault_code (protocol §Fault codes).
++inline constexpr const char* kFaultBadFrame = "bad_frame";
++inline constexpr const char* kFaultBadValue = "bad_value";
++inline constexpr const char* kFaultUnsupported = "unsupported";
++inline constexpr const char* kFaultNotAllowed = "not_allowed";
++inline constexpr const char* kFaultEstopped = "estopped";
++inline constexpr const char* kFaultLocalStop = "local_stop";
++inline constexpr const char* kFaultNotReady = "not_ready";
++inline constexpr const char* kFaultNotConnected = "not_connected";
++inline constexpr const char* kFaultExpired = "expired";
++inline constexpr const char* kFaultShuttingDown = "shutting_down";
++
++// Job refusal messages (protocol §Jobs), for a device with no worker.
++inline constexpr const char* kJobBadID = "missing or malformed job id";
++inline constexpr const char* kJobBadFrame = "malformed job";
++inline constexpr const char* kJobUnknownClass = "unknown class";
++inline constexpr const char* kJobNoArtifact = "function or code job needs an artifact with an exact version";
++inline constexpr const char* kJobNetUnsupported = "net policy not supported";
++inline constexpr const char* kJobBadInputs = "invalid inputs";
++inline constexpr const char* kJobInputsRefused = "job inputs not supported";
++inline constexpr const char* kJobBadLimits = "missing or invalid args, ttl_ms or limits";
++inline constexpr const char* kJobNotAvailable = "class not available";
++
++// Command kinds understood by the protocol (protocol §Commands). The ESP32 driver implements drive, actuator and
++// halt; ptz, say and display are valid kinds this device has no driver for, so they are `nack unsupported`.
++inline constexpr const char* kKindDrive = "drive";
++inline constexpr const char* kKindActuator = "actuator";
++inline constexpr const char* kKindPTZ = "ptz";
++inline constexpr const char* kKindSay = "say";
++inline constexpr const char* kKindDisplay = "display";
++inline constexpr const char* kKindHalt = "halt";
++
++// The effective owner limits: the stricter of the server's config and the device's local caps (protocol step 6).
++struct Limits {
++ double max_speed = 1.0; // throttle, x, y
++ double max_turn = 1.0; // steer, rotation
++ int max_command_ms = kDefaultMaxCommandMS;
++};
++
++// Telemetry the transport feeds in; every field is optional except the object itself (protocol §Device → server).
++struct Telemetry {
++ bool has_battery = false;
++ double battery = 0.0;
++ bool has_voltage = false;
++ double voltage = 0.0;
++ bool has_rssi = false;
++ int rssi = 0;
++ std::string sensors_json; // a JSON object, or empty
++};
++
++// The pairing answer (`paired` frame / POST /api/v1/pair body), parsed for the transport.
++struct Paired {
++ std::string device_id;
++ std::string credential;
++ std::string publish_key;
++ std::string whip_url;
++ std::string robot_id;
++};
++
++// One remembered command result: a repeated `id` is answered with it and never executed again for ten minutes
++// (protocol §Commands step 1), including across reconnects.
++struct DedupResult {
++ bool ok = false;
++ std::string fault;
++ std::string message;
++ int64_t at_ms = 0; // the monotonic instant it was cached; recall() expires it after kDedupTTLMS
++};
++
++class Core {
++ public:
++ // now is the monotonic clock (never steps back; int64, so the ESP32's 32-bit millis() wrap cannot reach it) and
++ // drives every timer: the heartbeat cadence, the deadman and the motion deadline. wall is the Unix clock and is used
++ // only for the envelope's `ts`, estop_state.at and heartbeat.t, which the protocol defines in Unix milliseconds.
++ using NowFn = std::function<int64_t()>; // monotonic milliseconds
++ using WallFn = std::function<int64_t()>; // Unix milliseconds
++ using SendFn = std::function<void(const std::string&)>; // one text frame, already encoded
++ using DriveFn = std::function<void(const std::string& value, int deadline_ms)>;
++ using ActuatorFn = std::function<void(const std::string& name, const std::string& value, int deadline_ms)>;
++ using StopFn = std::function<void()>;
++ using LogFn = std::function<void(const char* level, const std::string& message)>;
++ using LatchFn = std::function<void(bool remote, bool local)>;
++
++ Core(NowFn now, WallFn wall, SendFn send);
++
++ // --- configuration, set once by the transport/sketch ---
++ void set_callbacks(DriveFn drive, ActuatorFn actuator, StopFn stop, LogFn log);
++ void set_latch_callback(LatchFn latch);
++ // The local caps, applied on top of the server's config (the stricter wins).
++ void set_local_limits(double max_speed, double max_turn, int max_command_ms);
++ // The device descriptor the protocol carries: status.firmware, the driver name and the per-driver capabilities
++ // object. Nothing here ever adds a `worker` capability (this device runs no jobs).
++ void set_descriptor(const std::string& firmware, const std::string& driver, const std::string& capabilities_json);
++
++ // The JSON allocator seams (test-only; both default to null, so production uses ArduinoJson's default allocator):
++ // json_allocator_ is used for the inbound frame and every envelope the core emits, and value_allocator_ for the
++ // document a command's `value` is copied into (clamp_command and the actuator re-parse). They are separate so the
++ // desktop harness can make a value document overflow without also breaking the frame parser's own allocations. The
++ // allocators must outlive the Core.
++ void set_json_allocator(ArduinoJson::Allocator* allocator) { json_allocator_ = allocator; }
++ void set_value_allocator(ArduinoJson::Allocator* allocator) { value_allocator_ = allocator; }
++
++ const std::string& firmware() const { return firmware_; }
++ const std::string& driver() const { return driver_; }
++ const std::string& capabilities_json() const { return capabilities_json_; }
++
++ // --- connection lifecycle, called by the transport ---
++ void connect(); // the WebSocket upgrade succeeded; wait for hello
++ // One server text frame. The explicit length lets an oversize frame be rejected before it is copied into a string.
++ void feed(const char* data, size_t length);
++ void feed(const std::string& text) { feed(text.data(), text.size()); }
++ void tick(); // call often: heartbeat cadence, deadman, motion deadline
++ // The socket ended. 0 means the transport cannot know the code: before hello that is treated as the refused case
++ // (the >= 10 s floor, logged as uncertain); after hello it is the normal backoff. 4002/4003 are always the floor.
++ void on_close(int code);
++
++ // After on_close: jittered delay before the next connect (>= 10 s for 4002/4003/401/403).
++ int next_reconnect_delay_ms() const { return next_delay_ms_; }
++ bool credential_refused() const { return credential_refused_; }
++ bool link_lost() const { return link_lost_; } // the core decided to drop the link (hello/deadman timeout)
++ void clear_link_lost() { link_lost_ = false; }
++
++ // --- state the transport/driver may read ---
++ bool link_up() const { return up_; }
++ bool configured() const { return configured_; }
++ bool estopped() const { return remote_stop_ || local_stop_; }
++ bool remote_estopped() const { return remote_stop_; }
++ bool local_stop() const { return local_stop_; }
++ const std::string& device_id() const { return device_id_; }
++ const std::string& session_id() const { return session_id_; }
++ const std::vector<std::string>& robot_ids() const { return robot_ids_; }
++
++ void set_local_stop(bool on); // the local kill switch; latched, persisted by the transport
++
++ // --- frames the transport may need to send ---
++ void send_status();
++ void send_estop_state();
++ void send_telemetry(const Telemetry& telemetry);
++
++ // --- pairing helpers (protocol §1), portable so the harness can check them ---
++ // Normalises a code as Bot does: upper case, dash and spaces removed, I/L → 1 and O → 0.
++ static bool normalize_pair_code(const std::string& in, std::string& out);
++ // Builds the POST /api/v1/pair body (the `pair` frame without the envelope).
++ static std::string build_pair_request(const std::string& robot, const std::string& code,
++ const std::string& agent_version, const std::string& device_kind,
++ const std::string& driver, const std::string& capabilities_json,
++ const std::string& name);
++ // Parses Bot's `paired` frame into the same fields POST /api/v1/pair answers with.
++ static bool parse_paired(const std::string& frame, Paired& out);
++
++ private:
++ void emit(const char* type, const std::function<void(JsonObject)>& fill);
++ void log(const char* level, const std::string& message) const;
++ int64_t wall_ms() const;
++ void notify_latch() const;
++
++ void handle_hello(JsonDocument& doc, int64_t now);
++ void handle_config(JsonDocument& doc);
++ void handle_command(JsonDocument& doc, int64_t ts, int64_t now);
++ void handle_estop(JsonDocument& doc);
++ void handle_heartbeat_ack(JsonDocument& doc, int64_t now);
++ void handle_job(JsonDocument& doc);
++
++ void observe(int64_t ts, int64_t now);
++ int64_t server_now(int64_t ts, int64_t now) const;
++ bool clamp_command(const std::string& kind, JsonVariantConst value, std::string& out, std::string& fault,
++ std::string& message) const;
++ void send_ack(const std::string& id, int64_t latency_ms);
++ void send_nack(const std::string& id, const char* fault, const std::string& message);
++ void remember(const std::string& id, DedupResult result);
++ void remember_job(const std::string& id, const char* fault, const std::string& message);
++ bool recall(const std::string& id, DedupResult& result, int64_t now);
++ int normal_backoff_ms();
++ uint32_t next_random();
++ void stop_motion();
++ void send_heartbeat();
++ void set_remote_stop(bool on, const std::string& reason, bool notify);
++
++ NowFn now_;
++ WallFn wall_;
++ SendFn send_;
++ ArduinoJson::Allocator* json_allocator_ = nullptr;
++ ArduinoJson::Allocator* value_allocator_ = nullptr;
++ DriveFn drive_;
++ ActuatorFn actuator_;
++ StopFn stop_;
++ LogFn log_fn_;
++ LatchFn latch_fn_;
++
++ std::string firmware_ = "openvibe-esp32-0.0.0";
++ std::string driver_ = "esp32";
++ std::string capabilities_json_ = "{}";
++
++ Limits local_limits_{};
++ Limits limits_{};
++
++ bool up_ = false;
++ bool transport_up_ = false; // the socket is connected/handshaking (hello may still be pending); set by connect()
++ bool configured_ = false;
++ bool remote_stop_ = false;
++ bool local_stop_ = false;
++ bool credential_refused_ = false;
++ bool link_lost_ = false;
++ std::string remote_reason_;
++ std::string device_id_;
++ std::string session_id_;
++ std::vector<std::string> robot_ids_;
++
++ bool allowed_present_ = false;
++ std::map<std::string, bool> allowed_;
++
++ uint64_t seq_ = 0;
++ int64_t last_rx_ms_ = 0;
++ int64_t next_hb_ms_ = 0;
++ int64_t connected_at_ms_ = 0;
++ int hb_ms_ = kDefaultHeartbeatMS;
++ int64_t deadman_ms_ = 2 * kDefaultHeartbeatMS;
++
++ int64_t motion_deadline_ms_ = 0;
++
++ bool has_skew_ = false;
++ int64_t skew_ms_ = 0;
++
++ int64_t last_rtt_ms_ = 0;
++ bool has_rtt_ = false;
++
++ struct HbSend {
++ int64_t t;
++ int64_t at;
++ };
++ std::map<uint64_t, HbSend> hb_sent_;
++
++ std::map<std::string, DedupResult> dedup_;
++ std::vector<std::string> dedup_order_;
++ std::map<std::string, std::pair<std::string, std::string>> job_answers_;
++ std::vector<std::string> job_order_;
++
++ int reconnect_attempt_ = 0;
++ int next_delay_ms_ = kBackoffMinMS;
++ uint32_t rng_state_ = 0x9e3779b9u;
++};
++
++} // namespace ov
ddiff --git a/esp32/test/conformance.cpp b/esp32/test/conformance.cpp
nnew file mode 100644
iindex 0000000..42224aa
---- /dev/null
++++ b/esp32/test/conformance.cpp
@@@ -0,0 +1,1045 @@
++// conformance.cpp — the desktop conformance harness for ov_core.
++//
++// Builds with `g++ -std=c++17 -Wall -Wextra -Werror` against esp32/src/ov_core.cpp and the pinned ArduinoJson single
++// header in esp32/test/.deps (run.sh fetches and checks it). It replays every fixture in
++// internal/protocol/testdata/bot — each server-to-device frame is decoded and handled, and each device-to-server frame
++// the core emits is checked for the fixture's keys and value types — and runs the protocol's scenarios (deadman,
++// e-stop, clamping, nack codes, a job refusal, close codes, heartbeat cadence, hello/config ordering) plus the safety
++// hardening scenarios (a wall clock that steps backwards, 64-bit monotonic time across the millis() wrap, wrong-typed
++// latch frames, oversize/too-deep/allocation-failing frames, over-long ids and kinds, the dedup cache's ten-minute
++// limit, the heartbeat cap, guarded commands while the link is lost, the backoff reset, the refused-reconnect floor,
++// the pre-hello timer with the transport down, a bounded e-stop reason and an overflowing command value).
++//
++// Exit status is non-zero if any scenario fails; every scenario prints one PASS/FAIL line.
++#include <ArduinoJson.h>
++
++#include <algorithm>
++#include <cmath>
++#include <cstdlib>
++#include <cstring>
++#include <fstream>
++#include <iostream>
++#include <map>
++#include <sstream>
++#include <string>
++#include <vector>
++
++#include "ov_core.h"
++
++namespace {
++
++std::string readFile(const std::string& path) {
++ std::ifstream in(path, std::ios::binary);
++ std::ostringstream ss;
++ ss << in.rdbuf();
++ return ss.str();
++}
++
++bool parse(const std::string& text, JsonDocument& doc) { return !deserializeJson(doc, text); }
++
++const char* tag(JsonVariantConst v) {
++ if (v.is<JsonObjectConst>()) return "object";
++ if (v.is<JsonArrayConst>()) return "array";
++ if (v.is<bool>()) return "bool";
++ if (v.is<const char*>()) return "string";
++ if (v.is<long long>()) return "number";
++ if (v.is<double>()) return "number";
++ if (v.isNull()) return "null";
++ return "other";
++}
++
++// Every key the fixture carries (v/seq/ts/type are the envelope, checked separately) must be present in the emitted
++// frame with a compatible value type.
++bool frameMatchesFixture(const std::string& emitted, const std::string& fixture, std::string& why) {
++ JsonDocument e;
++ JsonDocument f;
++ if (!parse(emitted, e)) {
++ why = "emitted frame is not JSON";
++ return false;
++ }
++ if (!parse(fixture, f)) {
++ why = "fixture is not JSON";
++ return false;
++ }
++ for (JsonPairConst kv : f.as<JsonObjectConst>()) {
++ const std::string key = kv.key().c_str();
++ if (key == "v" || key == "seq" || key == "ts" || key == "type") continue;
++ JsonVariantConst ev = e[key.c_str()];
++ if (ev.isNull()) {
++ why = "emitted frame is missing key " + key;
++ return false;
++ }
++ if (std::strcmp(tag(ev), tag(kv.value())) != 0) {
++ why = "key " + key + " is " + tag(ev) + ", fixture has " + tag(kv.value());
++ return false;
++ }
++ }
++ return true;
++}
++
++bool lastOfType(const std::vector<std::string>& frames, const char* type, JsonDocument& out) {
++ for (auto it = frames.rbegin(); it != frames.rend(); ++it) {
++ JsonDocument doc;
++ if (!parse(*it, doc)) continue;
++ if (std::strcmp(doc["type"] | "", type) == 0) {
++ out.clear();
++ out.set(doc.as<JsonVariantConst>());
++ return true;
++ }
++ }
++ return false;
++}
++
++const int64_t kT = 1738065600000LL;
++
++std::size_t countType(const std::vector<std::string>& frames, const char* type) {
++ std::size_t n = 0;
++ for (const auto& s : frames) {
++ JsonDocument d;
++ if (parse(s, d) && std::strcmp(d["type"] | "", type) == 0) n++;
++ }
++ return n;
++}
++
++// Exercises the "the parser could not allocate" path on a desktop with gigabytes of free heap: flip fail and the next
++// JsonDocument built by the core (in feed() or emit()) fails its allocations exactly as a full ESP32 heap would.
++struct FailingAllocator : ArduinoJson::Allocator {
++ bool fail = false;
++ void* allocate(size_t size) override { return fail ? nullptr : std::malloc(size); }
++ void deallocate(void* ptr) override { std::free(ptr); }
++ void* reallocate(void* ptr, size_t new_size) override { return fail ? nullptr : std::realloc(ptr, new_size); }
++};
++
++struct H {
++ int64_t now = kT; // monotonic milliseconds: every core timer runs on this clock
++ int64_t wall_shift = 0; // Unix milliseconds = now + wall_shift; only the envelope ts/at/heartbeat t read it
++ FailingAllocator alloc; // the frame/envelope seam (feed and emit)
++ FailingAllocator value_alloc; // the command-value seam (clamp_command and the actuator re-parse)
++ std::vector<std::string> sent;
++ std::vector<std::string> drives;
++ std::vector<std::string> stops;
++ std::vector<std::string> actuator_names;
++ std::vector<std::string> actuator_values;
++ std::vector<std::string> logs;
++ std::string last_drive;
++ std::string last_actuator_name;
++ std::string last_actuator_value;
++ int last_deadline = 0;
++ ov::Core core;
++
++ H()
++ : core([this]() { return now; }, [this]() { return now + wall_shift; },
++ [this](const std::string& s) { sent.push_back(s); }) {
++ core.set_json_allocator(&alloc);
++ core.set_value_allocator(&value_alloc);
++ core.set_descriptor("openvibe-esp32-0.1.0", "esp32", "{\"esp32\":{\"drive\":{\"type\":\"differential\"}}}");
++ core.set_local_limits(1.0, 1.0, 1000);
++ core.set_callbacks(
++ [this](const std::string& v, int d) {
++ last_drive = v;
++ last_deadline = d;
++ drives.push_back(v);
++ },
++ [this](const std::string& n, const std::string& v, int /*d*/) {
++ last_actuator_name = n;
++ last_actuator_value = v;
++ actuator_names.push_back(n);
++ actuator_values.push_back(v);
++ },
++ [this]() { stops.push_back("stop"); },
++ [this](const char* l, const std::string& m) { logs.push_back(std::string(l) + ": " + m); });
++ }
++
++ bool hasFrame(const char* type) {
++ JsonDocument d;
++ return lastOfType(sent, type, d);
++ }
++ bool lastNack(const std::string& id, std::string& fault, std::string& message) {
++ for (auto it = sent.rbegin(); it != sent.rend(); ++it) {
++ JsonDocument d;
++ if (!parse(*it, d)) continue;
++ if (std::strcmp(d["type"] | "", "nack") != 0) continue;
++ if (std::string(d["id"] | "") != id) continue;
++ fault = d["fault_code"] | "";
++ message = d["message"] | "";
++ return true;
++ }
++ return false;
++ }
++};
++
++std::string commandFrame(const std::string& id, const std::string& kind, const std::string& value_json, int64_t ts,
++ int64_t deadline) {
++ return "{\"v\":1,\"seq\":1,\"ts\":" + std::to_string(ts) + ",\"type\":\"command\",\"id\":\"" + id +
++ "\",\"kind\":\"" + kind + "\",\"value\":" + value_json + ",\"deadline_ms\":" + std::to_string(deadline) + "}";
++}
++
++std::string configFrameRaw(double max_speed, double max_turn, int max_command_ms, int heartbeat_ms,
++ const std::string& estop_json) {
++ return "{\"v\":1,\"seq\":1,\"ts\":1,\"type\":\"config\",\"heartbeat_ms\":" + std::to_string(heartbeat_ms) +
++ ",\"limits\":{\"max_speed\":" + std::to_string(max_speed) + ",\"max_turn\":" + std::to_string(max_turn) +
++ ",\"max_command_ms\":" + std::to_string(max_command_ms) +
++ "},\"allowed_commands\":[\"drive\",\"actuator\",\"halt\"],\"estop_latched\":" + estop_json + "}";
++}
++
++std::string configFrame(double max_speed, double max_turn, int max_command_ms, int heartbeat_ms) {
++ return configFrameRaw(max_speed, max_turn, max_command_ms, heartbeat_ms, "false");
++}
++
++#define REQUIRE(cond, msg) \
++ do { \
++ if (!(cond)) return std::string(msg); \
++ } while (0)
++
++std::string scenarioHello(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ REQUIRE(!h.core.link_up(), "link counted up before hello");
++ REQUIRE(h.sent.empty(), "the device sent a frame before hello");
++ h.core.feed(hello);
++ REQUIRE(h.core.link_up(), "hello did not bring the link up");
++ REQUIRE(h.core.device_id() == "dev_01J8Z4…", "hello did not record the device id");
++ REQUIRE(h.core.robot_ids().size() == 1, "hello did not record the robot id");
++ REQUIRE(h.sent.empty(), "the device sent a frame between hello and config");
++ h.core.feed(config);
++ REQUIRE(h.core.configured(), "config was not applied");
++ JsonDocument status;
++ REQUIRE(lastOfType(h.sent, "status", status), "no status went out after config");
++ REQUIRE(lastOfType(h.sent, "estop_state", status), "no estop_state went out after config");
++ JsonDocument status2;
++ lastOfType(h.sent, "status", status2);
++ REQUIRE(std::strcmp(status2["firmware"] | "", "openvibe-esp32-0.1.0") == 0, "status.firmware wrong");
++ REQUIRE(status2["capabilities"].is<JsonObjectConst>(), "status.capabilities is not an object");
++ REQUIRE(status2["capabilities"]["worker"].isNull(), "status advertised a worker capability");
++ return "";
++}
++
++std::string scenarioHeartbeat(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.sent.clear();
++ h.core.tick();
++ JsonDocument hb;
++ REQUIRE(lastOfType(h.sent, "heartbeat", hb), "no heartbeat after hello");
++ std::size_t count = 0;
++ for (const auto& s : h.sent) {
++ JsonDocument d;
++ if (parse(s, d) && std::strcmp(d["type"] | "", "heartbeat") == 0) count++;
++ }
++ REQUIRE(count == 1, "expected one heartbeat, got " + std::to_string(count));
++ REQUIRE(hb["seq"].is<long long>(), "heartbeat.seq is not a number");
++ REQUIRE(hb["t"].is<long long>(), "heartbeat.t is not a number");
++ h.core.tick();
++ h.now = kT + 999;
++ h.core.tick();
++ std::size_t count2 = 0;
++ for (const auto& s : h.sent) {
++ JsonDocument d;
++ if (parse(s, d) && std::strcmp(d["type"] | "", "heartbeat") == 0) count2++;
++ }
++ REQUIRE(count2 == 1, "a heartbeat went out before the interval");
++ h.now = kT + 1000;
++ h.core.tick();
++ std::size_t count3 = 0;
++ for (const auto& s : h.sent) {
++ JsonDocument d;
++ if (parse(s, d) && std::strcmp(d["type"] | "", "heartbeat") == 0) count3++;
++ }
++ REQUIRE(count3 == 2, "no heartbeat at the interval");
++ JsonDocument hb_latest;
++ REQUIRE(lastOfType(h.sent, "heartbeat", hb_latest), "no second heartbeat");
++ const int64_t t2 = hb_latest["t"].as<long long>();
++ // The newer echo/t shape measures the round trip.
++ h.now = kT + 1050;
++ h.core.feed("{\"v\":1,\"seq\":99,\"ts\":" + std::to_string(h.now) + ",\"type\":\"heartbeat_ack\",\"echo\":" +
++ std::to_string(t2) + ",\"t\":" + std::to_string(t2) + "}");
++ h.sent.clear();
++ h.now = kT + 2000;
++ h.core.tick();
++ JsonDocument hb2;
++ REQUIRE(lastOfType(h.sent, "heartbeat", hb2), "no heartbeat with rtt");
++ REQUIRE(hb2["rtt_ms"].is<long long>(), "rtt_ms missing after an echo ack");
++ REQUIRE(hb2["rtt_ms"].as<long long>() == 50, "the echoed send time measured " + std::to_string(hb2["rtt_ms"].as<long long>()) + " ms");
++ // The older seq shape is matched too.
++ H h2;
++ h2.now = kT;
++ h2.core.connect();
++ h2.core.feed(hello);
++ h2.core.feed(config);
++ h2.sent.clear();
++ h2.core.tick();
++ JsonDocument hb3;
++ REQUIRE(lastOfType(h2.sent, "heartbeat", hb3), "no heartbeat in the seq-shape core");
++ const int64_t seq3 = hb3["seq"].as<long long>();
++ h2.now = kT + 20;
++ h2.core.feed("{\"v\":1,\"seq\":41,\"ts\":" + std::to_string(h2.now) + ",\"type\":\"heartbeat_ack\",\"seq\":" +
++ std::to_string(seq3) + "}");
++ h2.sent.clear();
++ h2.now = kT + 1000;
++ h2.core.tick();
++ JsonDocument hb4;
++ REQUIRE(lastOfType(h2.sent, "heartbeat", hb4), "no heartbeat after a seq-shape ack");
++ REQUIRE(hb4["rtt_ms"].is<long long>(), "rtt_ms missing after a seq-shape ack");
++ return "";
++}
++
++std::string scenarioDeadman(const std::string& hello, const std::string& config, const std::string& drive_fixture) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.core.feed(drive_fixture);
++ REQUIRE(h.drives.size() == 1, "the drive fixture did not reach on_drive");
++ REQUIRE(h.stops.empty(), "motion stopped when the drive arrived");
++ h.now = kT + 299;
++ h.core.tick();
++ REQUIRE(h.stops.empty(), "motion stopped before the window lapsed");
++ h.now = kT + 300;
++ h.core.tick();
++ REQUIRE(h.stops.size() == 1, "motion did not stop when the window lapsed");
++ h.now = kT + 400;
++ h.core.tick();
++ REQUIRE(h.stops.size() == 1, "motion stopped more than once");
++ return "";
++}
++
++std::string scenarioEstop(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.sent.clear();
++ h.core.feed("{\"v\":1,\"seq\":5,\"ts\":" + std::to_string(h.now) +
++ ",\"type\":\"estop\",\"latched\":true,\"by\":\"usr_1\",\"at\":\"2026-09-29T19:20:01.200Z\"}");
++ REQUIRE(h.core.estopped(), "the e-stop did not latch");
++ REQUIRE(!h.stops.empty(), "the e-stop did not stop motion");
++ JsonDocument st;
++ REQUIRE(lastOfType(h.sent, "estop_state", st), "no estop_state on latch");
++ REQUIRE(st["latched"].as<bool>(), "estop_state did not report the latch");
++ const std::string id = "cmd_estopped";
++ h.core.feed(commandFrame(id, "drive", "{\"throttle\":0.5,\"steer\":0}", h.now, h.now + 300));
++ std::string fault;
++ std::string message;
++ REQUIRE(h.lastNack(id, fault, message), "a drive while e-stopped was not nacked");
++ REQUIRE(fault == "estopped", "drive while e-stopped nacked " + fault);
++ REQUIRE(h.drives.empty(), "a drive reached the motors while e-stopped");
++ h.core.feed("{\"v\":1,\"seq\":6,\"ts\":" + std::to_string(h.now) + ",\"type\":\"estop\",\"latched\":false}");
++ REQUIRE(!h.core.estopped(), "the e-stop did not clear");
++ h.core.feed(commandFrame("cmd_after_clear", "drive", "{\"throttle\":0.5,\"steer\":0}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == 1, "a drive after the clear did not reach the motors");
++ return "";
++}
++
++std::string scenarioClamp(const std::string& hello) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(configFrame(0.5, 0.4, 1000, 1000));
++ h.core.feed(commandFrame("cmd_clamp", "drive", "{\"throttle\":0.9,\"steer\":-0.8,\"x\":2.0,\"y\":-2.0,\"rotation\":1.5}",
++ h.now, h.now + 300));
++ JsonDocument d;
++ REQUIRE(parse(h.last_drive, d), "on_drive did not get JSON");
++ REQUIRE(std::fabs(d["throttle"].as<double>() - 0.5) < 1e-6, "throttle was not clamped to max_speed");
++ REQUIRE(std::fabs(d["steer"].as<double>() + 0.4) < 1e-6, "steer was not clamped to max_turn");
++ REQUIRE(std::fabs(d["x"].as<double>() - 0.5) < 1e-6, "x was not clamped to max_speed");
++ REQUIRE(std::fabs(d["y"].as<double>() + 0.5) < 1e-6, "y was not clamped to max_speed");
++ REQUIRE(std::fabs(d["rotation"].as<double>() - 0.4) < 1e-6, "rotation was not clamped to max_turn");
++ // The stricter of the server's config and the local caps wins.
++ h.core.set_local_limits(0.25, 0.25, 1000);
++ h.core.feed(configFrame(0.5, 0.5, 1000, 1000));
++ h.core.feed(commandFrame("cmd_clamp2", "drive", "{\"throttle\":0.9,\"steer\":0.9}", h.now, h.now + 300));
++ REQUIRE(parse(h.last_drive, d), "second on_drive was not JSON");
++ REQUIRE(std::fabs(d["throttle"].as<double>() - 0.25) < 1e-6, "the local max_speed was not the stricter one");
++ REQUIRE(std::fabs(d["steer"].as<double>() - 0.25) < 1e-6, "the local max_turn was not the stricter one");
++ return "";
++}
++
++std::string scenarioNacks(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ std::string fault;
++ std::string message;
++ h.core.feed(commandFrame("cmd_unknown", "jump", "{}", h.now, h.now + 300));
++ REQUIRE(h.lastNack("cmd_unknown", fault, message), "unknown kind was not nacked");
++ REQUIRE(fault == "unsupported", "unknown kind nacked " + fault);
++ h.core.feed(commandFrame("cmd_badvalue", "drive", "{\"throttle\":\"fast\"}", h.now, h.now + 300));
++ REQUIRE(h.lastNack("cmd_badvalue", fault, message), "a malformed value was not nacked");
++ REQUIRE(fault == "bad_value", "malformed value nacked " + fault);
++ // A repeated id is answered with the first result and never runs again.
++ const std::size_t runs = h.drives.size();
++ h.core.feed(commandFrame("cmd_repeat", "drive", "{\"throttle\":0.3}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == runs + 1, "the first command did not run");
++ h.core.feed(commandFrame("cmd_repeat", "drive", "{\"throttle\":0.9}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == runs + 1, "a repeated id ran a second time");
++ return "";
++}
++
++std::string scenarioJob(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ const std::string id = "job_01J8Z4M2Q0R7T9YV3K6N8P1W2X";
++ const std::string job = "{\"v\":1,\"seq\":1,\"ts\":" + std::to_string(h.now) +
++ ",\"type\":\"job\",\"job\":{\"id\":\"" + id +
++ "\",\"class\":\"function\",\"artifact\":{\"name\":\"hello\",\"version\":\"1.0.0\"},"
++ "\"args\":{},\"ttl_ms\":1000,\"limits\":{\"wall_ms\":1000,\"cpu_ms\":1000,\"mem_bytes\":1048576}}}";
++ h.core.feed(job);
++ std::string fault;
++ std::string message;
++ REQUIRE(h.lastNack(id, fault, message), "a job was not nacked");
++ REQUIRE(fault == "unsupported", "a job nacked " + fault);
++ REQUIRE(message == "class not available", "a job nacked with: " + message);
++ h.core.feed("{\"v\":1,\"seq\":2,\"ts\":" + std::to_string(h.now) +
++ ",\"type\":\"job\",\"job\":{\"id\":\"nope\",\"class\":\"function\"}}");
++ REQUIRE(h.lastNack("nope", fault, message), "a malformed job id was not nacked");
++ REQUIRE(fault == "bad_value" && message == "missing or malformed job id", "malformed job id nacked " + fault + "/" + message);
++ return "";
++}
++
++std::string scenarioClose(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.core.feed(commandFrame("cmd_close", "drive", "{\"throttle\":0.5}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == 1, "the drive before the close did not run");
++ h.stops.clear();
++ h.core.on_close(4003);
++ REQUIRE(!h.stops.empty(), "close 4003 did not stop motion");
++ REQUIRE(h.core.credential_refused(), "close 4003 did not mark the credential refused");
++ REQUIRE(h.core.next_reconnect_delay_ms() >= 10000, "close 4003 reconnects sooner than 10 s");
++ bool remedy = false;
++ for (const auto& l : h.logs) {
++ if (l.find("pair again, or import the owner's rotation") != std::string::npos) remedy = true;
++ }
++ REQUIRE(remedy, "close 4003 did not log the two remedies");
++
++ H h2;
++ h2.now = kT;
++ h2.core.connect();
++ h2.core.feed(hello);
++ h2.core.feed(config);
++ h2.core.on_close(4002);
++ REQUIRE(h2.core.next_reconnect_delay_ms() >= 10000, "close 4002 reconnects sooner than 10 s");
++
++ H h3;
++ h3.now = kT;
++ h3.core.connect();
++ h3.core.feed(hello);
++ h3.core.feed(config);
++ h3.core.on_close(4000);
++ REQUIRE(!h3.core.credential_refused(), "close 4000 was treated as a refused credential");
++ REQUIRE(h3.core.next_reconnect_delay_ms() < 10000, "close 4000 used the credential retry floor");
++ return "";
++}
++
++std::string scenarioClock(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ const int64_t t0 = h.now;
++ h.core.feed(commandFrame("cmd_clock1", "drive", "{\"throttle\":0.5}", t0, t0 + 300));
++ REQUIRE(h.drives.size() == 1, "the drive before the clock step did not run");
++
++ // The wall clock steps back an hour. The envelope ts follows it; no safety timer may.
++ h.wall_shift = -3600000;
++ ov::Telemetry telemetry;
++ telemetry.has_battery = true;
++ telemetry.battery = 0.5;
++ h.sent.clear();
++ h.core.send_telemetry(telemetry);
++ JsonDocument d;
++ REQUIRE(lastOfType(h.sent, "telemetry", d), "no telemetry after the wall clock stepped back");
++ REQUIRE(d["ts"].as<int64_t>() == h.now - 3600000, "the envelope ts did not follow the wall clock backwards");
++
++ h.now = t0 + 299;
++ h.core.tick();
++ REQUIRE(h.stops.empty(), "the motion deadline moved with the wall clock");
++ h.now = t0 + 300;
++ h.core.tick();
++ REQUIRE(h.stops.size() == 1, "the motion deadline did not fire on the monotonic clock");
++
++ // A command after the step is still timed against the monotonic clock and the skew estimate, not the wall clock.
++ h.core.feed(commandFrame("cmd_clock2", "drive", "{\"throttle\":0.5}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == 2, "a drive after the wall clock step did not run");
++ h.now += 300;
++ h.core.tick();
++ REQUIRE(h.stops.size() == 2, "the second deadline did not fire after the wall clock step");
++
++ // The heartbeat cadence and the deadman still run on the monotonic clock after the step.
++ h.sent.clear();
++ h.now += 2001;
++ h.core.tick();
++ JsonDocument hb;
++ REQUIRE(lastOfType(h.sent, "heartbeat", hb), "no heartbeat after the wall clock step");
++ REQUIRE(hb["t"].as<int64_t>() == h.now - 3600000, "heartbeat.t did not follow the wall clock");
++ REQUIRE(h.core.link_lost(), "the deadman moved with the wall clock");
++ return "";
++}
++
++std::string scenarioMonotonic64(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = 4294967000LL; // just below the 32-bit millis() wrap at 49.7 days
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ const int64_t t0 = h.now;
++ // The server's clock is Unix (the hello ts); the device's monotonic clock is the wrap point. Deadlines ride the
++ // server's clock, the timers the device's.
++ const auto server_ms = [&]() { return kT + (h.now - t0); };
++ h.core.feed(commandFrame("cmd_wrap", "drive", "{\"throttle\":0.4}", server_ms(), server_ms() + 300));
++ REQUIRE(h.drives.size() == 1, "the drive before the 32-bit wrap did not run");
++ h.sent.clear();
++ h.now = t0 + 296; // exactly 2^32
++ h.core.tick();
++ REQUIRE(h.stops.empty(), "motion stopped early across the 32-bit wrap");
++ h.now = t0 + 300;
++ h.core.tick();
++ REQUIRE(h.stops.size() == 1, "the motion deadline did not fire across the 32-bit wrap");
++ JsonDocument hb;
++ REQUIRE(lastOfType(h.sent, "heartbeat", hb), "no heartbeat across the 32-bit wrap");
++ REQUIRE(hb["t"].as<int64_t>() >= 4294967000LL, "the heartbeat lost the 64-bit monotonic time");
++ return "";
++}
++
++std::string scenarioWrongTypedLatch(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ const std::string estop_true = "{\"v\":1,\"seq\":5,\"ts\":" + std::to_string(h.now) +
++ ",\"type\":\"estop\",\"latched\":true,\"by\":\"usr_1\"}";
++ h.core.feed(estop_true);
++ REQUIRE(h.core.estopped(), "the e-stop did not latch");
++ for (const std::string& raw : {std::string("\"false\""), std::string("0"), std::string("1"), std::string("{}")}) {
++ h.core.feed("{\"v\":1,\"seq\":6,\"ts\":" + std::to_string(h.now) + ",\"type\":\"estop\",\"latched\":" + raw + "}");
++ REQUIRE(h.core.estopped(), "estop latched:" + raw + " released the latch");
++ }
++ h.core.feed(commandFrame("cmd_wrongtype", "drive", "{\"throttle\":0.5}", h.now, h.now + 300));
++ std::string fault;
++ std::string message;
++ REQUIRE(h.lastNack("cmd_wrongtype", fault, message) && fault == "estopped",
++ "a drive while latched was not nacked estopped");
++ REQUIRE(h.drives.empty(), "a drive reached the motors while latched");
++ h.core.feed("{\"v\":1,\"seq\":9,\"ts\":" + std::to_string(h.now) + ",\"type\":\"estop\",\"latched\":false}");
++ REQUIRE(!h.core.estopped(), "a proper clear did not release the latch");
++
++ // The same for config.estop_latched: a wrong-typed frame must apply nothing, including its heartbeat_ms.
++ h.core.feed(estop_true);
++ REQUIRE(h.core.estopped(), "the second latch did not take");
++ h.core.tick();
++ for (const std::string& raw : {std::string("\"false\""), std::string("0"), std::string("1"), std::string("{}")}) {
++ h.core.feed(configFrameRaw(1.0, 1.0, 1000, 4000, raw));
++ REQUIRE(h.core.estopped(), "config estop_latched:" + raw + " released the latch");
++ }
++ h.now += 1000;
++ h.core.tick();
++ h.sent.clear();
++ h.now += 1000;
++ h.core.tick();
++ REQUIRE(countType(h.sent, "heartbeat") == 1,
++ "a wrong-typed config was still applied (its heartbeat_ms took effect)");
++ h.core.feed(configFrame(1.0, 1.0, 1000, 1000));
++ REQUIRE(!h.core.estopped(), "a proper config did not clear the latch");
++ return "";
++}
++
++std::string scenarioFrameLimits(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++
++ // A frame past the device's size limit: too big to be any frame this device handles (an estop could be inside).
++ h.core.feed(commandFrame("cmd_limits1", "drive", "{\"throttle\":0.5}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == 1, "the drive before the oversize frame did not run");
++ h.stops.clear();
++ h.core.feed(std::string(ov::kMaxFrameBytes + 1, 'x'));
++ REQUIRE(h.stops.size() == 1, "an oversize frame did not stop motion");
++ REQUIRE(h.core.link_lost(), "an oversize frame did not drop the link");
++
++ // A frame nested past the JSON depth limit.
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.core.feed(commandFrame("cmd_limits2", "drive", "{\"throttle\":0.5}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == 2, "the drive before the too-deep frame did not run");
++ h.stops.clear();
++ std::string deep;
++ for (int i = 0; i < 14; ++i) deep += "[";
++ deep += "1";
++ for (int i = 0; i < 14; ++i) deep += "]";
++ h.core.feed(deep);
++ REQUIRE(h.stops.size() == 1, "a too-deep frame did not stop motion");
++ REQUIRE(h.core.link_lost(), "a too-deep frame did not drop the link");
++
++ // A frame the parser cannot allocate room for.
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.core.feed(commandFrame("cmd_limits3", "drive", "{\"throttle\":0.5}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == 3, "the drive before the allocation-failing frame did not run");
++ h.stops.clear();
++ h.alloc.fail = true;
++ h.core.feed("{\"v\":1,\"seq\":9,\"ts\":" + std::to_string(h.now) +
++ ",\"type\":\"estop\",\"latched\":true,\"by\":\"usr_1\"}");
++ h.alloc.fail = false;
++ REQUIRE(h.stops.size() == 1, "a frame that failed to allocate did not stop motion");
++ REQUIRE(h.core.link_lost(), "a frame that failed to allocate did not drop the link");
++
++ // An outbound frame that lost fields to a failed allocation is dropped, never sent half-formed.
++ ov::Telemetry telemetry;
++ telemetry.has_battery = true;
++ telemetry.battery = 0.5;
++ h.sent.clear();
++ h.alloc.fail = true;
++ h.core.send_telemetry(telemetry);
++ h.alloc.fail = false;
++ REQUIRE(h.sent.empty(), "a frame that lost fields to allocation failure was sent");
++ return "";
++}
++
++std::string scenarioLongIds(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.sent.clear();
++ h.core.feed(commandFrame(std::string(200, 'a'), "drive", "{\"throttle\":0.5}", h.now, h.now + 300));
++ REQUIRE(h.drives.empty(), "a command with a 200-byte id reached the motors");
++ REQUIRE(h.sent.empty(), "a command with a 200-byte id was answered or echoed");
++
++ const std::string big_kind = std::string("k") + std::string(150, 'k');
++ h.core.feed(commandFrame("cmd_bigkind", big_kind, "{}", h.now, h.now + 300));
++ std::string fault;
++ std::string message;
++ REQUIRE(h.lastNack("cmd_bigkind", fault, message), "a 151-byte kind was not nacked");
++ REQUIRE(fault == "unsupported", "a 151-byte kind nacked " + fault);
++ REQUIRE(message == "unknown kind", "the cached nack echoes the kind: " + message);
++ h.sent.clear();
++ h.core.feed(commandFrame("cmd_bigkind", big_kind, "{}", h.now, h.now + 300));
++ REQUIRE(h.lastNack("cmd_bigkind", fault, message) && message == "unknown kind", "the cached nack changed");
++ return "";
++}
++
++std::string scenarioDedupTtl(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ const std::string id = "cmd_ttl";
++ h.core.feed(commandFrame(id, "drive", "{\"throttle\":0.3}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == 1, "the first command did not run");
++ h.core.feed(commandFrame(id, "drive", "{\"throttle\":0.9}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == 1, "a repeat inside ten minutes ran again");
++
++ // The cache lives across reconnects (protocol §Commands step 1).
++ h.core.on_close(4000);
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.core.feed(commandFrame(id, "drive", "{\"throttle\":0.9}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == 1, "a repeat after a reconnect ran again");
++
++ // ... and expires after the spec's ten minutes.
++ h.now += ov::kDedupTTLMS + 1;
++ h.core.feed(commandFrame(id, "drive", "{\"throttle\":0.2}", h.now, h.now + 300));
++ REQUIRE(h.drives.size() == 2, "an id older than ten minutes was still answered from the cache");
++ return "";
++}
++
++std::string scenarioHeartbeatCap(const std::string& hello) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(configFrameRaw(1.0, 1.0, 1000, 2000000000, "false")); // a server trying to stretch the deadman for weeks
++ h.sent.clear();
++ h.core.tick();
++ REQUIRE(countType(h.sent, "heartbeat") == 1, "no heartbeat after hello");
++ h.now = kT + 4999;
++ h.core.tick();
++ REQUIRE(countType(h.sent, "heartbeat") == 1, "a heartbeat went out before the capped interval");
++ h.now = kT + 5000;
++ h.core.tick();
++ REQUIRE(countType(h.sent, "heartbeat") == 2, "the heartbeat interval was not capped at 5000 ms");
++ h.stops.clear();
++ h.now = kT + 10000;
++ h.core.tick();
++ REQUIRE(!h.core.link_lost(), "the deadman fired before 2 x the capped heartbeat");
++ h.now = kT + 10001;
++ h.core.tick();
++ REQUIRE(h.core.link_lost(), "the deadman did not fire at 2 x the capped heartbeat (overflow?)");
++ return "";
++}
++
++std::string scenarioLinkLostGuard(const std::string& hello, const std::string& config, const std::string& drive_fixture) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.core.feed(drive_fixture);
++ REQUIRE(h.drives.size() == 1, "the drive before the deadman did not run");
++ h.core.tick();
++ h.now += 2001;
++ h.core.tick();
++ REQUIRE(h.core.link_lost(), "the deadman did not drop the link");
++ h.stops.clear();
++ h.core.feed(commandFrame("cmd_lost", "drive", "{\"throttle\":0.5}", h.now, h.now + 300));
++ std::string fault;
++ std::string message;
++ REQUIRE(h.lastNack("cmd_lost", fault, message), "a guarded command was not answered while the link is lost");
++ REQUIRE(fault == "not_connected", "a guarded command while the link is lost nacked " + fault);
++ REQUIRE(h.stops.empty(), "a guarded command reached the motors while the link is lost");
++ h.core.feed(commandFrame("cmd_lost_halt", "halt", "{}", h.now, h.now + 300));
++ bool acked = false;
++ for (const auto& frame : h.sent) {
++ JsonDocument d;
++ if (parse(frame, d) && std::strcmp(d["type"] | "", "ack") == 0 && std::string(d["id"] | "") == "cmd_lost_halt") {
++ acked = true;
++ }
++ }
++ REQUIRE(acked, "halt was refused while the link is lost");
++ return "";
++}
++
++std::string scenarioBackoffReset(const std::string& hello) {
++ H h;
++ h.now = kT;
++ int max_delay = 0;
++ for (int i = 0; i < 10; ++i) {
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.on_close(0); // an unknown close after hello: the normal exponential backoff
++ max_delay = std::max(max_delay, h.core.next_reconnect_delay_ms());
++ }
++ REQUIRE(max_delay > ov::kBackoffMinMS + 500, "the backoff never grew across ten drops");
++ // A link that stays up for kStableLinkMS resets it: the next drop is back at the minimum.
++ h.core.connect();
++ h.core.feed(hello);
++ h.now += ov::kStableLinkMS;
++ h.core.tick();
++ h.core.on_close(0);
++ REQUIRE(h.core.next_reconnect_delay_ms() <= ov::kBackoffMinMS + 500, "the backoff was not reset after a stable link");
++ return "";
++}
++
++std::string scenarioRefusedFloor(const std::string& hello) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.on_close(4003);
++ REQUIRE(h.core.next_reconnect_delay_ms() >= ov::kCredentialRetryMinMS, "4003 used less than the 10 s floor");
++ // The reconnect is refused again before hello; the transport reports code 0 ("the library cannot know").
++ h.core.connect();
++ h.core.on_close(0);
++ REQUIRE(h.core.next_reconnect_delay_ms() >= ov::kCredentialRetryMinMS,
++ "a reconnect refused before hello used less than the 10 s floor");
++ REQUIRE(h.core.credential_refused(), "a pre-hello drop was not marked as the refused case");
++ bool uncertain = false;
++ for (const auto& l : h.logs) {
++ if (l.find("cannot know the close code") != std::string::npos) uncertain = true;
++ }
++ REQUIRE(uncertain, "the pre-hello drop did not say the cause is uncertain");
++ // An unknown close after hello is an ordinary drop and keeps the ordinary backoff.
++ H h2;
++ h2.now = kT;
++ h2.core.connect();
++ h2.core.feed(hello);
++ h2.core.on_close(0);
++ REQUIRE(h2.core.next_reconnect_delay_ms() < ov::kCredentialRetryMinMS,
++ "an unknown close after hello used the credential floor");
++ return "";
++}
++
++std::string scenarioPreHelloTimer() {
++ H h;
++ h.now = kT;
++ // While the socket is up and no hello has arrived, the pre-hello allowance still fires: the device must not wait
++ // forever on a server that upgraded the socket and then said nothing.
++ h.core.connect();
++ h.now = kT + ov::kHelloAllowanceMS + 1;
++ h.core.tick();
++ REQUIRE(h.core.link_lost(), "the pre-hello timer did not fire while the socket was up");
++ h.core.clear_link_lost();
++ // Once the transport is down (the socket ended) no timer may fire: nothing can arrive, and a firing timer would
++ // cancel the in-flight reconnect on every loop() pass and flood the log. on_close() is what tells the core.
++ h.core.on_close(0);
++ h.stops.clear();
++ h.sent.clear();
++ for (int i = 0; i < 20; ++i) {
++ h.now += 1000;
++ h.core.tick();
++ }
++ REQUIRE(!h.core.link_lost(), "the pre-hello timer fired with the transport down");
++ REQUIRE(h.stops.empty(), "a tick with the transport down stopped motion");
++ return "";
++}
++
++std::string scenarioEstopReasonBound(const std::string& hello, const std::string& config) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.sent.clear();
++ const std::string big_by(16000, 'x'); // under the frame limit, far over the reason cap
++ h.core.feed("{\"v\":1,\"seq\":5,\"ts\":" + std::to_string(h.now) +
++ ",\"type\":\"estop\",\"latched\":true,\"by\":\"" + big_by + "\"}");
++ REQUIRE(h.core.estopped(), "the e-stop with a huge by did not latch");
++ JsonDocument st;
++ REQUIRE(lastOfType(h.sent, "estop_state", st), "no estop_state for the huge-by latch");
++ const std::string reason = st["reason"] | "";
++ REQUIRE(reason.size() <= ov::kMaxEstopReasonBytes,
++ "estop_state.reason is " + std::to_string(reason.size()) + " bytes");
++ // The same bounded reason is copied into the dedup cache on the estopped nack, so the cache cannot grow to megabytes.
++ std::string fault;
++ std::string message;
++ h.core.feed(commandFrame("cmd_bigreason", "drive", "{\"throttle\":0.5}", h.now, h.now + 300));
++ REQUIRE(h.lastNack("cmd_bigreason", fault, message), "a drive after the huge-by latch was not nacked");
++ REQUIRE(fault == "estopped", "the drive after the huge-by latch nacked " + fault);
++ REQUIRE(message.size() <= ov::kMaxEstopReasonBytes,
++ "the cached estopped message is " + std::to_string(message.size()) + " bytes");
++ h.core.feed(commandFrame("cmd_bigreason", "drive", "{\"throttle\":0.5}", h.now, h.now + 300));
++ REQUIRE(h.lastNack("cmd_bigreason", fault, message) && message.size() <= ov::kMaxEstopReasonBytes,
++ "the cached estopped message grew on a repeat");
++ return "";
++}
++
++std::string scenarioValueOverflow(const std::string& hello, const std::string& config) {
++ // The document a command's value is copied into can fail to allocate under memory pressure. The value that reaches
++ // a driver must then be refused (bad_value), never a truncated document with a field missing. Only the value-document
++ // seam is stressed (the frame parser and emit keep their own healthy allocator), so the frame still decodes and the
++ // refusal can go out.
++ const char* kinds[] = {"drive", "actuator"};
++ const char* values[] = {"{\"throttle\":0.5,\"steer\":0.1}", "{\"name\":\"pan\",\"value\":0.5}"};
++ for (int k = 0; k < 2; ++k) {
++ const std::string id = std::string("cmd_overflow_") + kinds[k];
++ const std::string frame = commandFrame(id, kinds[k], values[k], kT, kT + 300);
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.sent.clear();
++ h.value_alloc.fail = true; // every allocation of the value document fails
++ h.core.feed(frame);
++ h.value_alloc.fail = false;
++ REQUIRE(!h.core.link_lost(), "the frame parser failed although only the value document was stressed");
++ REQUIRE(h.drives.empty(), "a drive reached the driver while its value document could not be built");
++ REQUIRE(h.actuator_names.empty(), "an actuator reached the driver while its value document could not be built");
++ std::string fault;
++ std::string message;
++ REQUIRE(h.lastNack(id, fault, message), std::string("an overflowing ") + kinds[k] + " command was not refused");
++ REQUIRE(fault == "bad_value", std::string("an overflowing ") + kinds[k] + " command nacked " + fault);
++ // A repeat answers from the cache with the same refusal.
++ h.value_alloc.fail = true;
++ h.core.feed(frame);
++ h.value_alloc.fail = false;
++ REQUIRE(h.lastNack(id, fault, message) && fault == "bad_value", "the cached refusal changed");
++ // With the allocator healthy the same command runs and reaches the driver whole.
++ H ok;
++ ok.now = kT;
++ ok.core.connect();
++ ok.core.feed(hello);
++ ok.core.feed(config);
++ ok.sent.clear();
++ ok.core.feed(frame);
++ if (k == 0) {
++ REQUIRE(ok.drives.size() == 1, "the drive command did not run when the value allocator was healthy");
++ JsonDocument d;
++ REQUIRE(parse(ok.drives[0], d) && !d["throttle"].isNull(),
++ "the healthy drive reached the driver without a throttle");
++ } else {
++ REQUIRE(ok.actuator_names.size() == 1 && ok.actuator_names[0] == "pan",
++ "the actuator command did not run when the value allocator was healthy");
++ JsonDocument d;
++ REQUIRE(parse(ok.actuator_values[0], d) && std::string(d["name"] | "") == "pan",
++ "the healthy actuator reached the driver without its name");
++ }
++ }
++ return "";
++}
++
++std::string scenarioFixtures(const std::string& dir) {
++ const char* names[] = {"ack", "command_actuator", "command_drive", "config", "error",
++ "estop", "estop_state", "heartbeat", "heartbeat_ack", "heartbeat_ack_echo",
++ "hello", "nack", "paired", "pair", "status",
++ "telemetry"};
++ std::map<std::string, std::string> fx;
++ for (const char* n : names) {
++ fx[n] = readFile(dir + "/" + n + ".json");
++ if (fx[n].empty()) return std::string("fixture ") + n + ".json is missing or empty";
++ }
++ const std::string hello = fx["hello"];
++ const std::string config = fx["config"];
++
++ // Every server-to-device frame decodes and is handled (hello and config first, as on the wire).
++ const char* server_frames[] = {"hello", "config", "command_drive", "command_actuator", "estop",
++ "heartbeat_ack", "heartbeat_ack_echo", "error"};
++ for (const char* n : server_frames) {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.core.feed(fx[n]);
++ if (!h.core.link_up()) return std::string("feeding the ") + n + " fixture lost the link";
++ }
++ {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ h.core.feed(fx["command_drive"]);
++ if (h.drives.size() != 1) return "the command_drive fixture did not reach on_drive";
++ h.core.feed(fx["command_actuator"]);
++ if (h.last_actuator_name != "pan") return "the command_actuator fixture did not reach on_actuator";
++ }
++
++ // The pairing fixtures: `paired` parses to the pairing answer, `pair` is built as the request body.
++ ov::Paired paired;
++ if (!ov::Core::parse_paired(fx["paired"], paired)) return "the paired fixture did not parse";
++ if (paired.device_id != "dev_01J8Z4…" || paired.robot_id != "rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X") {
++ return "the paired fixture parsed the wrong ids";
++ }
++ {
++ const std::string req = ov::Core::build_pair_request("rob_01J8Z4M2Q0R7T9YV3K6N8P1W2X", "7Q2M4XZP", "0.1.0",
++ "onboard", "esp32", "{\"esp32\":{}}", "Rover");
++ std::string why;
++ if (!frameMatchesFixture(req, fx["pair"], why)) return "pair request: " + why;
++ }
++
++ // Every device-to-server frame the core emits carries the fixture's keys with its value types.
++ std::string why;
++ {
++ H h;
++ h.now = kT;
++ h.core.connect();
++ h.core.feed(hello);
++ h.core.feed(config);
++ JsonDocument d;
++ if (!lastOfType(h.sent, "status", d)) return "no status emitted for the status fixture";
++ std::string s;
++ serializeJson(d, s);
++ if (!frameMatchesFixture(s, fx["status"], why)) return "status: " + why;
++ if (!lastOfType(h.sent, "estop_state", d)) return "no estop_state emitted";
++ serializeJson(d, s);
++ if (!frameMatchesFixture(s, fx["estop_state"], why)) return "estop_state: " + why;
++
++ h.core.feed(commandFrame("cmd_ack", "drive", "{\"throttle\":0.1}", h.now, h.now + 300));
++ if (!lastOfType(h.sent, "ack", d)) return "no ack emitted";
++ serializeJson(d, s);
++ if (!frameMatchesFixture(s, fx["ack"], why)) return "ack: " + why;
++
++ h.core.feed(commandFrame("cmd_nack", "jump", "{}", h.now, h.now + 300));
++ if (!lastOfType(h.sent, "nack", d)) return "no nack emitted";
++ serializeJson(d, s);
++ if (!frameMatchesFixture(s, fx["nack"], why)) return "nack: " + why;
++
++ h.sent.clear();
++ h.core.tick();
++ if (!lastOfType(h.sent, "heartbeat", d)) return "no heartbeat emitted";
++ const int64_t hb_t = d["t"].as<long long>();
++ h.now += 50;
++ h.core.feed("{\"v\":1,\"seq\":50,\"ts\":" + std::to_string(h.now) + ",\"type\":\"heartbeat_ack\",\"echo\":" +
++ std::to_string(hb_t) + "}");
++ h.sent.clear();
++ h.now += 1000;
++ h.core.tick();
++ if (!lastOfType(h.sent, "heartbeat", d)) return "no measured heartbeat emitted";
++ serializeJson(d, s);
++ if (!frameMatchesFixture(s, fx["heartbeat"], why)) return "heartbeat: " + why;
++
++ ov::Telemetry t;
++ t.has_battery = true;
++ t.battery = 0.72;
++ t.has_voltage = true;
++ t.voltage = 7.41;
++ t.has_rssi = true;
++ t.rssi = -57;
++ t.sensors_json = "{\"ultrasonic\":118}";
++ h.sent.clear();
++ h.core.send_telemetry(t);
++ if (!lastOfType(h.sent, "telemetry", d)) return "no telemetry emitted";
++ serializeJson(d, s);
++ if (!frameMatchesFixture(s, fx["telemetry"], why)) return "telemetry: " + why;
++ }
++ return "";
++}
++
++int failures = 0;
++
++void report(const char* name, const std::string& problem) {
++ if (problem.empty()) {
++ std::cout << "PASS " << name << "\n";
++ } else {
++ std::cout << "FAIL " << name << " — " << problem << "\n";
++ failures++;
++ }
++}
++
++} // namespace
++
++int main(int argc, char** argv) {
++ const std::string dir = argc > 1 ? argv[1] : "internal/protocol/testdata/bot";
++ const std::string hello = readFile(dir + "/hello.json");
++ const std::string config = readFile(dir + "/config.json");
++ const std::string drive_fixture = readFile(dir + "/command_drive.json");
++ if (hello.empty() || config.empty() || drive_fixture.empty()) {
++ std::cout << "FAIL fixtures — could not read hello/config/command_drive from " << dir << "\n";
++ return 1;
++ }
++
++ report("fixtures replayed", scenarioFixtures(dir));
++ report("hello on connect", scenarioHello(hello, config));
++ report("heartbeat cadence and acks", scenarioHeartbeat(hello, config));
++ report("deadman stops motion after the window, not before", scenarioDeadman(hello, config, drive_fixture));
++ report("estop latches and clears", scenarioEstop(hello, config));
++ report("clamping to the owner limits", scenarioClamp(hello));
++ report("unknown kind and malformed value nack codes", scenarioNacks(hello, config));
++ report("job refused on a device without the worker", scenarioJob(hello, config));
++ report("close codes and reconnect policy", scenarioClose(hello, config));
++ report("the wall clock stepping backwards leaves the safety timers alone", scenarioClock(hello, config));
++ report("64-bit monotonic time across the 32-bit millis wrap", scenarioMonotonic64(hello, config));
++ report("wrong-typed latched/estop_latched keep the latch", scenarioWrongTypedLatch(hello, config));
++ report("oversize, too-deep and allocation-failing frames stop motion", scenarioFrameLimits(hello, config));
++ report("long command ids and kinds are rejected", scenarioLongIds(hello, config));
++ report("the dedup cache expires after ten minutes", scenarioDedupTtl(hello, config));
++ report("heartbeat_ms is capped and the deadman cannot overflow", scenarioHeartbeatCap(hello));
++ report("guarded commands are refused while the link is lost", scenarioLinkLostGuard(hello, config, drive_fixture));
++ report("the reconnect backoff resets after a stable link", scenarioBackoffReset(hello));
++ report("the 10 s floor after a refused reconnect", scenarioRefusedFloor(hello));
++ report("the pre-hello timer does not fire with the transport down", scenarioPreHelloTimer());
++ report("a huge e-stop by is bounded in every cached message", scenarioEstopReasonBound(hello, config));
++ report("an overflowing command or actuator value is refused", scenarioValueOverflow(hello, config));
++
++ if (failures > 0) {
++ std::cout << failures << " scenario(s) failed\n";
++ return 1;
++ }
++ std::cout << "all scenarios passed\n";
++ return 0;
++}
ddiff --git a/esp32/test/run.sh b/esp32/test/run.sh
nnew file mode 100755
iindex 0000000..031a401
---- /dev/null
++++ b/esp32/test/run.sh
@@@ -0,0 +1,49 @@
++#!/bin/sh
++# run.sh — build and run the desktop conformance harness for ov_core.
++#
++# It fetches the pinned ArduinoJson v7 single header into esp32/test/.deps (gitignored), checks its SHA256, builds the
++# harness with the same flags CI uses (g++ -std=c++17 -Wall -Wextra -Werror against src/ov_core.cpp) and runs it over
++# internal/protocol/testdata/bot. Exit status is the harness's.
++set -eu
++
++here=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
++root=$(CDPATH= cd -- "$here/../.." && pwd)
++deps="$here/.deps"
++header="$deps/ArduinoJson.h"
++
++# Pinned ArduinoJson v7 release and the SHA256 of its single header.
++json_version="7.4.2"
++json_url="https://github.com/bblanchon/ArduinoJson/releases/download/v${json_version}/ArduinoJson-v${json_version}.h"
++json_sha="a05ac98f4481d2398c103ca5ffcce8e2fcd7fa2fa1d8d9f38d5757454f448d97"
++
++mkdir -p "$deps"
++
++if ! (printf '%s %s\n' "$json_sha" "$header" | sha256sum -c - >/dev/null 2>&1); then
++ printf 'downloading ArduinoJson v%s ...\n' "$json_version"
++ if command -v curl >/dev/null 2>&1; then
++ curl -fsSL --retry 2 -o "$header" "$json_url"
++ elif command -v wget >/dev/null 2>&1; then
++ wget -qO "$header" "$json_url"
++ elif command -v python3 >/dev/null 2>&1; then
++ python3 - "$header" "$json_url" <<'PY'
++import sys, urllib.request
++urllib.request.urlretrieve(sys.argv[2], sys.argv[1])
++PY
++ else
++ printf 'error: need a downloader (see README) for ArduinoJson\n' >&2
++ exit 1
++ fi
++fi
++
++printf '%s %s\n' "$json_sha" "$header" | sha256sum -c - >/dev/null
++
++build="${TMPDIR:-/tmp}/openvibe-esp32-conformance.$$"
++mkdir -p "$build"
++trap 'rm -rf "$build"' EXIT
++
++g++ -std=c++17 -Wall -Wextra -Werror \
++ -I "$here/../src" -I "$deps" \
++ "$here/../src/ov_core.cpp" "$here/conformance.cpp" \
++ -o "$build/conformance"
++
++"$build/conformance" "$root/internal/protocol/testdata/bot"