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)
Changes
@@@ -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@@@ -6,3 +6,4 @@ __pycache__/.pytest_cache/*.egg-info//build/++esp32/test/.deps/@@@ -29,6 +29,8 @@ openvibe-node resumeKit [`adeept_adr036`](plugins/adeept_adr036/README.md), and [`cozmo`](plugins/cozmo/README.md) through PyCozmo. Eachplugin stops its own actuators when stdin closes, at a command's deadline and after 1 s without a heartbeat, so thecore 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 driverprotocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md).@@@ -6,6 +6,8 @@ doc are pinned verbatim in [`internal/protocol/testdata/bot`](../internal/protocBot 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. PairingThe owner adds a robot on openvibe.bot and gets an 8-character code (shown `XXXX-XXXX`, 10 minutes, single use, 5 wrongnnew file mode 100644@@@ -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.nnew file mode 100644@@@ -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(); }nnew file mode 100644@@@ -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.hnnew file mode 100644@@@ -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();++}nnew file mode 100644@@@ -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_;++};nnew file mode 100644@@@ -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 ovnnew file mode 100644@@@ -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 ovnnew file mode 100644@@@ -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;++}nnew file mode 100755@@@ -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"
@@ -100,3 +100,30 @@ jobs:
@@ -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: 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-stock-server.sh
- run: sh scripts/test-install-pair-args.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
@@ -6,3 +6,4 @@ __pycache__/
@@ -6,3 +6,4 @@ __pycache__/
.pytest_cache/
.pytest_cache/
*.egg-info/
*.egg-info/
/build/
/build/
+esp32/test/.deps/
@@ -29,6 +29,8 @@ openvibe-node resume
@@ -29,6 +29,8 @@ openvibe-node resume
Kit [`adeept_adr036`](plugins/adeept_adr036/README.md), and [`cozmo`](plugins/cozmo/README.md) through PyCozmo. Each
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
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.
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
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).
protocol, the bundled drivers, and where Actor's computer-control plugin attaches), [install](docs/install.md).
@@ -6,6 +6,8 @@ doc are pinned verbatim in [`internal/protocol/testdata/bot`](../internal/protoc
@@ -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
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.
(`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
## 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
The owner adds a robot on openvibe.bot and gets an 8-character code (shown `XXXX-XXXX`, 10 minutes, single use, 5 wrong
new file mode 100644
new file mode 100644
@@ -0,0 +1,136 @@
@@ -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.
new file mode 100644
new file mode 100644
@@ -0,0 +1,89 @@
@@ -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(); }
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,445 @@
@@ -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();
+}
new file mode 100644
new file mode 100644
@@ -0,0 +1,113 @@
@@ -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_;
+};
new file mode 100644
new file mode 100644
@@ -0,0 +1,1096 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,304 @@
@@ -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
new file mode 100644
new file mode 100644
@@ -0,0 +1,1045 @@
@@ -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;
+}
new file mode 100755
new file mode 100755
@@ -0,0 +1,49 @@
@@ -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"