diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..20a5ebb --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,27 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - name: Host regression tests (ASan + UBSan) + run: make sanitize + + compile: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v7 + - uses: arduino/setup-arduino-cli@v2 + - name: Install pinned ESP32 core and NimBLE-Arduino + run: make deps + - name: Compile firmware + run: make compile diff --git a/.github/workflows/compile.yml b/.github/workflows/compile.yml deleted file mode 100644 index 73701d7..0000000 --- a/.github/workflows/compile.yml +++ /dev/null @@ -1,31 +0,0 @@ -name: Compile firmware - -on: - push: - pull_request: - -permissions: - contents: read - -jobs: - compile: - runs-on: ubuntu-latest - - steps: - - name: Check out repository - uses: actions/checkout@v6 - - - name: Install Arduino CLI - uses: arduino/setup-arduino-cli@v2 - - - name: Install ESP32 core and NimBLE - run: | - arduino-cli core update-index --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json - arduino-cli core install esp32:esp32@3.3.12 --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json - arduino-cli lib install NimBLE-Arduino@2.5.1 - - - name: Run host regression tests - run: bash tests/run.sh - - - name: Compile TrainerBridge - run: arduino-cli compile --warnings all --fqbn esp32:esp32:esp32 TrainerBridge diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..b585914 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,20 @@ +# Instructions for AI agents working on TrainerBridge + +TrainerBridge is ESP32 Arduino firmware (C++17) that proxies one FTMS smart trainer to +several BLE clients. Read [docs/how-it-works.md](docs/how-it-works.md) first. + +- Run `make sanitize` before finishing. `make compile` needs `arduino-cli` and `make deps`; + if it isn't installed, CI runs it on the pull request. +- Pinned versions: ESP32 Arduino core 3.3.12, NimBLE-Arduino 2.5.1 (Makefile, CI, README). + Bump them together. +- Never write to the trainer from a NimBLE callback: queue the command and let + `processQueuedCommands()` in `loop()` send it. +- Control Point transaction state (`cpTxState`, `pendingCp`, `pendingCpSince`) is shared + with the NimBLE host task. Touch it only inside `cpTxMux`, and make BLE/Serial calls after + leaving the critical section. +- Control Point responses go only to the originating connection handle. Never indicate to + all clients: Garmin drops sensors that send unexpected indications. +- Add a named test to `tests/firmware_test.cpp` for any behaviour you change. Extend the + stubs in `tests/stubs/` only as far as the firmware uses the real API. +- Nothing here replaces a ride on real hardware: say so in the PR and point to the + checklist in [docs/testing.md](docs/testing.md). diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..ea56b8c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,30 @@ +# Changelog + +All notable changes to this project are documented here. The format follows +[Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +## [Unreleased] + +### Added +- Host regression tests: the real firmware against Arduino/NimBLE test doubles, with + sanitizers in CI (`make test`, `make sanitize`). +- `Makefile` with `check`, `deps`, `compile`, `upload` and `monitor`. +- `docs/how-it-works.md`, `docs/testing.md` with a hardware checklist, `AGENTS.md`, + `CONTRIBUTING.md`. + +### Changed +- ESP32 Arduino core 3.3.12 and NimBLE-Arduino 2.5.1. +- Start/Resume is forwarded to the trainer as well as answered locally. +- The command queue merges repeated updates only from the same client, so every app gets a + response. + +### Fixed +- Request Control is actually sent to the trainer on connection (it was skipped). +- Trainer responses must match the pending command's opcode. +- A trainer response arriving before the write returned was dropped, and the command then + timed out. +- On trainer disconnect, pending and queued commands fail immediately instead of leaving + apps waiting. +- Failure responses with no sender (serial console commands, or an app that left) were + indicated to every client, including Garmin. +- Control Point transaction state is locked between the Bluetooth task and the main loop. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..5ed0e1c --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,29 @@ +# Contributing + +Issues and PRs welcome, especially reports from trainers other than the Van Rysel D100. + +## Development + +```bash +make test # host tests, needs only g++ +make check # host tests with sanitizers + firmware build (needs arduino-cli, `make deps` once) +make upload # build and flash, PORT=/dev/ttyUSB0 by default +make monitor # serial console +``` + +The host tests run the real firmware code against Arduino and NimBLE test doubles, so most +logic can be tested without a board. See [docs/testing.md](docs/testing.md) for what they +cover and the hardware checklist. + +## Guidelines + +- Keep the firmware warning-free: CI builds with `--warnings all`. +- Bluetooth callbacks must not block. Writes to the trainer go through the command queue. +- Add or update a named test in `tests/firmware_test.cpp` for behaviour changes. +- Change user-facing settings in `Config.h` and the table in the README together. +- Mention in the PR whether you tested on hardware, with which trainer, app and Garmin. + +## Reporting a trainer that doesn't work + +Set `DEBUG_LOG 1` in `Config.h`, flash, and attach the serial log from boot until the +problem, plus the trainer model and the app or Garmin you used. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..0c64f1f --- /dev/null +++ b/Makefile @@ -0,0 +1,29 @@ +FQBN ?= esp32:esp32:esp32 +PORT ?= /dev/ttyUSB0 +ESP32_CORE := 3.3.12 +NIMBLE := 2.5.1 +ESP32_INDEX := https://espressif.github.io/arduino-esp32/package_esp32_index.json + +.PHONY: check test sanitize deps compile upload monitor + +check: sanitize compile ## Host tests with sanitizers, then the real firmware build + +test: ## Host regression tests (C++17 compiler only) + bash tests/run.sh + +sanitize: ## Host tests with AddressSanitizer + UBSan + SANITIZE=1 bash tests/run.sh + +deps: ## Install the pinned ESP32 core and NimBLE-Arduino + arduino-cli core update-index --additional-urls $(ESP32_INDEX) + arduino-cli core install esp32:esp32@$(ESP32_CORE) --additional-urls $(ESP32_INDEX) + arduino-cli lib install NimBLE-Arduino@$(NIMBLE) + +compile: ## Build the firmware with all warnings + arduino-cli compile --warnings all --fqbn $(FQBN) TrainerBridge + +upload: compile ## Build and flash (PORT=/dev/ttyUSB0) + arduino-cli upload --port $(PORT) --fqbn $(FQBN) TrainerBridge + +monitor: ## Serial console at 115200 baud + arduino-cli monitor --port $(PORT) --config baudrate=115200 diff --git a/README.md b/README.md index 81b91a2..f30f355 100644 --- a/README.md +++ b/README.md @@ -1,202 +1,129 @@ # TrainerBridge -TrainerBridge turns an ESP32 into a high-performance Bluetooth Low Energy (BLE) proxy between an indoor smart trainer (such as the Van Rysel D100), cycling training apps (Zwift, MyWhoosh, TrainerDay), and Garmin bike computers / watches. - -- **Cycling Apps** (Zwift, MyWhoosh, TrainerDay) receive FTMS telemetry and control trainer resistance via standard FTMS Control Point commands. -- **Garmin Edge & Watches** receive live Power (Watts) and native simulated flat-terrain Speed (km/h) and Distance (km) without requiring GPS or external sensors. -- Simultaneous multi-client connections: Ride on Zwift while simultaneously recording on your Garmin Edge. - -Tested and validated on a standard ESP32 with the Decathlon Van Rysel D100 FTMS smart trainer. Other FTMS trainers can be paired by advertised name or MAC address. - ---- - -## Architecture & How It Works - -```text -Real Trainer (FTMS 0x1826) - │ - [BLE Client] - ▼ - TrainerBridge (ESP32) - ├── Physics Engine (CdA 0.32, Crr 0.004, 85 kg mass, flywheel coasting) - ├── Power Filter (Q8 EMA smoothing, scale calibration, stale watchdog) - └── Coalescing Queue (Thread-safe FreeRTOS ring buffer) - │ - [BLE Server / Proxy] - ├── FTMS (0x1826) ────► Zwift / MyWhoosh / TrainerDay (Interactive ERG / Sim) - │ └──► Garmin Edge (Unified Smart Trainer mode) - ├── CSC (0x1816) ────► Garmin Speed & Cadence (1/1024s wheel event time) - └── CPS (0x1818) ────► Garmin Cycling Power (Watts) -``` - -### Tri-Service GATT Implementation -1. **Fitness Machine Service (FTMS `0x1826`)**: - - `0x2AD2` (Indoor Bike Data): Transmits Instantaneous Power, Instantaneous Speed ($0.01\text{ km/h}$ resolution), and Total Distance ($1\text{ m}$ resolution, `uint24`). - - `0x2ACC` (Fitness Machine Feature): Declares support for Power, Resistance, and Total Distance. - - `0x2AD9` (Control Point): Receives app commands (target power, simulation grade, start/stop) and queues them for deterministic delivery to the real trainer. -2. **Cycling Speed and Cadence (CSC `0x1816`)**: - - `0x2A5B` (CSC Measurement): Broadcasts cumulative wheel revolutions and high-resolution wheel event timing ($1/1024\text{ s}$ resolution) for native Garmin speed sensor pairing. - - `0x2A5C` (CSC Feature) & `0x2A5D` (Sensor Location): Full Bluetooth SIG compliance. -3. **Cycling Power Service (CPS `0x1818`)**: - - `0x2A63` (Power Measurement): Broadcasts Instantaneous Power in Watts for standalone power meter profiles. - ---- - -## Physics Engine (Virtual Speed & Distance) - -On flat road ($0\%$ grade) with no wind, the power delivered to the wheels is opposed by aerodynamic drag and rolling resistance: - -$$P_{\text{wheel}} = \left(C_{\text{rr}} \cdot m_{\text{total}} \cdot g\right) v + \left(\frac{1}{2} \cdot \rho \cdot C_d A\right) v^3$$ - -* **Total mass ($m$)**: $85\text{ kg}$ (rider $75\text{ kg}$ + bike $10\text{ kg}$) -* **Rolling resistance ($C_{\text{rr}}$)**: $0.004$ (standard performance road tire on smooth asphalt) -* **Aerodynamic drag ($C_d A$)**: $0.32\text{ m}^2$ (standard endurance road position on the hoods) -* **Air density ($\rho$)**: $1.225\text{ kg/m}^3$ (sea level) -* **Drivetrain efficiency ($\eta$)**: $0.98$ ($98\%$) -* **Coasting Deceleration**: $1.5\text{ m/s}^2$ smooth flywheel coasting when pedaling ceases, preventing sudden auto-pauses on Garmin. +**Turn an ESP32 into a Bluetooth bridge that lets Zwift and your Garmin use the same smart trainer at the same time.** -**Speed Benchmarks:** -* $100\text{ W} \to \approx 26.0\text{ km/h}$ -* $150\text{ W} \to \approx 30.5\text{ km/h}$ -* $200\text{ W} \to \approx 34.0\text{ km/h}$ -* $250\text{ W} \to \approx 36.9\text{ km/h}$ -* $300\text{ W} \to \approx 39.4\text{ km/h}$ +Many trainers accept only one Bluetooth connection at a time, so you pick: ERG mode in the app, or power +and speed on your Garmin. TrainerBridge connects to the trainer once and re-publishes it as a +new device that **several apps and bike computers can pair with at once**. Apps keep full control +(ERG, simulation grade), and Garmin gets power, speed and distance without GPS or extra sensors. ---- - -## Requirements - -- ESP32 Arduino core **3.3.12** -- [NimBLE-Arduino](https://github.com/h2zero/NimBLE-Arduino) **2.5.1** -- Arduino CLI or Arduino IDE +```bash +make deps upload # pinned ESP32 core + NimBLE, then build and flash +``` ---- +[![CI](https://github.com/antomanc/TrainerBridge/actions/workflows/ci.yml/badge.svg)](https://github.com/antomanc/TrainerBridge/actions) +[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](LICENSE) -## Configuration +> Tested on a standard ESP32 DevKit with the Decathlon Van Rysel D100. Other FTMS trainers +> should work: pair them by advertised name or MAC address. -Edit [`TrainerBridge/Config.h`](TrainerBridge/Config.h): +## Why -- `DEVICE_NAME`: BLE name exposed to apps and bike computers (default: `"TrainerBridge"`). -- `TARGET_NAME_CONTAINS`: case-insensitive substring of the trainer's advertised name (default: `"RYSEL"`). -- `TARGET_MAC`: optional exact trainer MAC address (takes priority over name matching). -- `DEFAULT_POWER_SCALE`: measured-power multiplier (default: `1.00`). -- `ENABLE_VIRTUAL_SPEED`: enables physics-based speed and distance calculation. -- `ENABLE_CSC_SERVICE`: enables the standard BLE Speed Sensor GATT service. -- `VIRTUAL_WHEEL_CIRCUMFERENCE_M`: virtual wheel circumference (default: `2.096f` for $700\times 25\text{c}$). -- `ENABLE_STATUS_LED`: enables onboard/external LED for visual status indication (default: `true`). -- `STATUS_LED_PIN`: GPIO pin for status LED (default: `2` / `LED_BUILTIN` on standard ESP32 DevKit). -- `STATUS_LED_ACTIVE_HIGH`: polarity of the LED (default: `true`). -- `STATUS_LED_BLINK_WHILE_SCANNING`: blinks at 1 Hz while searching/connecting, solid ON when connected (default: `true`). -- `DEBUG_LOG`: set to `1` temporarily for verbose serial debugging. +Riding indoors with an app *and* a bike computer usually means compromises: +- the trainer drops the Garmin as soon as Zwift connects, or the other way round +- no speed or distance on the Garmin indoors, because there's no GPS and no wheel sensor +- ride data split between the app and your Garmin history +- a separate power meter or speed sensor just to record the ride twice -### Hardware Status LED +TrainerBridge sits in between and gives everyone what they need from one trainer connection. -When operating headless without a serial monitor, the onboard LED provides immediate visual feedback: +## What you get -| LED State | Meaning | +| | | |---|---| -| **Blinking (1 Hz)** | ESP32 is active and searching/connecting to the smart trainer | -| **Solid ON** | Real trainer connected and proxy services ready for Garmin / apps | -| **OFF** | Board unpowered or status LED disabled in `Config.h` | +| **Apps** | Zwift, MyWhoosh, TrainerDay and any FTMS app see a controllable trainer: ERG target power, simulation grade, start/stop, routed to the real trainer | +| **Garmin** | Edge and watches pair it as a **Smart Trainer**, or as separate **Power** and **Speed** sensors | +| **Speed & distance** | Physics-based virtual speed on a flat road (CdA, rolling resistance, 85 kg rider + bike), with flywheel coasting so Garmin doesn't auto-pause | +| **Multi-client** | App and Garmin connected together; each gets the same telemetry, and control responses go only to the client that sent the command | +| **Recovery** | Trainer power-cycled? Clients get 0 W immediately, and the bridge keeps scanning and reconnects on its own | +| **Status LED** | Blinks while searching, solid once the trainer is connected. No serial monitor needed | +| **Diagnostics** | Serial console with live status, power calibration and manual ERG commands | ---- +## Quick start -## Build and Flash - -Install the pinned dependencies and compile with all warnings enabled: +You need an ESP32 (the classic ESP32, e.g. a DevKit V1), a USB cable and +[Arduino CLI](https://arduino.github.io/arduino-cli/) (or the Arduino IDE with +ESP32 Arduino core **3.3.12** and NimBLE-Arduino **2.5.1**). ```bash -arduino-cli core update-index \ - --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json -arduino-cli core install esp32:esp32@3.3.12 \ - --additional-urls https://espressif.github.io/arduino-esp32/package_esp32_index.json -arduino-cli lib install NimBLE-Arduino@2.5.1 -arduino-cli compile --warnings all --fqbn esp32:esp32:esp32 TrainerBridge +git clone https://github.com/antomanc/TrainerBridge && cd TrainerBridge +make deps # install the pinned core and library (once) +make upload PORT=/dev/ttyUSB0 # build with all warnings and flash +make monitor # optional: serial console at 115200 baud ``` -Upload to a connected ESP32: +Turn on the trainer: the LED blinks while the bridge looks for it and stays on once connected. +Then pair your devices with **TrainerBridge**: -```bash -arduino-cli upload --port /dev/ttyUSB0 --fqbn esp32:esp32:esp32 TrainerBridge -``` +**Garmin Edge or watch**: *Settings > Sensors > Add Sensor > Smart Trainer*, pick +TrainerBridge. You get power, speed and distance over one connection. If asked for a wheel +size, use **2096 mm**. (Alternatively, add it as a *Speed/Cadence* sensor and a *Power* sensor.) ---- +**Zwift, MyWhoosh, TrainerDay**: in the pairing screen, choose TrainerBridge for both +**Controllable** and **Power Source**. -## Software Checks and Hardware Validation +## Configure -Run the host regression harness with a C++17 compiler: +Everything lives in [`TrainerBridge/Config.h`](TrainerBridge/Config.h). Defaults work for a +Van Rysel D100. The settings you are most likely to change: -```bash -bash tests/run.sh -``` - -The harness executes the firmware callbacks and loop with Arduino/NimBLE test doubles, -including the existing module self-checks, scan retry timing, repeated reconnects, -multi-client telemetry routing, and FTMS transactions. CI also compiles against the -real pinned libraries with `--warnings all`. Test doubles do not validate the BLE -stack, FreeRTOS concurrency, radio delivery, or scan-response timer lifetime. - -See [the patch upgrade validation notes](tests/VALIDATION.md) for coverage, known -limitations, and the physical ESP32/trainer/Garmin/app checks still required for -ESP32 Arduino 3.3.12 and NimBLE-Arduino 2.5.1. - ---- - -## Pairing Guide - -### Connecting to Garmin Edge or Watch +| Setting | Default | | +|---|---|---| +| `TARGET_NAME_CONTAINS` | `"RYSEL"` | Case-insensitive part of the trainer's Bluetooth name | +| `TARGET_MAC` | `""` | Exact trainer MAC. Takes priority over the name; leave both empty to use the first FTMS trainer found | +| `DEVICE_NAME` | `"TrainerBridge"` | Name apps and Garmin see | +| `DEFAULT_POWER_SCALE` | `1.00` | Multiply the trainer's watts, if it reads high or low | +| `ENABLE_VIRTUAL_SPEED` | `true` | Physics-based speed and distance | +| `ENABLE_CSC_SERVICE` | `true` | Speed sensor service for Garmin | +| `VIRTUAL_WHEEL_CIRCUMFERENCE_M` | `2.096` | Match the wheel size set on your Garmin (700x25c) | +| `STATUS_LED_PIN` | `LED_BUILTIN` or `2` | Status LED GPIO; `STATUS_LED_ACTIVE_HIGH` sets polarity | +| `DEBUG_LOG` | `0` | Set to `1` for verbose serial logging while debugging | -You can pair using either of the following methods: +Rider mass, CdA, rolling resistance and coasting are in the same file. +[→ How it works](docs/how-it-works.md) explains the model and the Bluetooth services. -#### Method 1: Smart Trainer Mode (Recommended) -1. Power on your trainer and the ESP32. -2. On your Garmin, navigate to **Settings > Sensors > Add Sensor > Smart Trainer**. -3. Select **TrainerBridge** and add it. -4. Garmin receives Power, Speed, and Distance simultaneously over a single unified Bluetooth channel. -5. If prompted for a wheel size in the sensor details, set it to **2096 mm**. +## Serial console -#### Method 2: Speed + Power Sensor Mode -1. On your Garmin, navigate to **Settings > Sensors > Add Sensor > Speed/Cadence** (or **Search All**). -2. Select **TrainerBridge** and add it. Set wheel size to **2096 mm**. -3. Add **TrainerBridge** under **Power** sensors if not already paired. -4. Both services communicate concurrently over the same physical Bluetooth link. +Open the serial port at 115200 baud (`make monitor`): -### Connecting to Cycling Apps (Zwift / MyWhoosh / TrainerDay) -1. In the app's pairing screen, search for **Controllable** and **Power Source**. -2. Select **TrainerBridge**. -3. Resistance, target power, and simulation gradients are automatically routed to the real trainer. - ---- - -## Serial Diagnostics & CLI - -Open the serial monitor at **115200 baud**. Commands: +``` +status Connection, power, virtual speed, distance and control queue state +scale 1.05 Change the power calibration until reboot +p 200 Send an ERG target of 200 W to the trainer (testing) +adv / noadv Start or stop advertising to apps and Garmin +``` -- `status`: prints connection status, power, virtual speed, distance, wheel revs, and queue state. -- `scale `: adjusts the live power calibration multiplier (e.g., `scale 1.05`). -- `p `: queues a manual ERG target power command for testing (e.g., `p 200`). -- `adv` / `noadv`: manually starts or stops proxy BLE advertising. +## Design ---- +- **One trainer connection, many clients.** The ESP32 is the only device that talks to the + trainer, so apps and Garmin never fight over it. +- **The trainer has the last word.** ERG and simulation commands are queued, sent one at a + time, and the app gets the trainer's real answer, not an optimistic one. Commands that time + out or hit a disconnect fail right away instead of leaving the app waiting. +- **Answers go to whoever asked.** Control responses are sent only to the client that sent + the command, so the Garmin never sees an app's traffic (Garmin drops sensors that send + unexpected indications). +- **Never blocks the Bluetooth stack.** Writes to the trainer happen in the main loop, not in + Bluetooth callbacks. State shared between the two is locked. +- **Fails safe.** If the trainer disappears, every client sees 0 W immediately, not a frozen + number. -## Project Structure +## Development -```text -TrainerBridge/ -├── Config.h User parameters & physics constants -├── FtmsConstants.h BLE UUIDs, opcodes, and bitfield flags -├── PowerFilter.h/.cpp Q8 EMA power filter & zero-lag coasting -├── CommandQueue.h/.cpp Thread-safe FreeRTOS coalescing command queue -├── TrainerMatcher.h/.cpp Trainer MAC/name matching with boot self-check -├── VirtualSpeedModel.h/.cpp Flat-road aerodynamic engine & dual-resolution event timers -├── StatusLed.h/.cpp Non-blocking visual connection & scan status indicator -└── TrainerBridge.ino Firmware entrypoint, GATT services & BLE state machines +```bash +make test # host tests: the real sketch against Arduino/NimBLE test doubles (g++ only) +make check # tests with AddressSanitizer + UBSan, then the real firmware build ``` ---- +The host tests run the actual firmware code through scanning, reconnecting 20 times, two +clients at once, and FTMS success, failure, timeout and late-response cases. They can't test the +radio, so hardware changes still need a ride on a real trainer: +[→ docs/testing.md](docs/testing.md). See [CONTRIBUTING.md](CONTRIBUTING.md) and +[CHANGELOG.md](CHANGELOG.md). ## License -TrainerBridge is licensed under the GNU General Public License v3.0. See [LICENSE](LICENSE). +[GPL-3.0](LICENSE) © 2026 Antonio Mancini -*TrainerBridge is an independent open-source project and is not affiliated with or endorsed by Garmin, Decathlon, Van Rysel, Zwift, MyWhoosh, or TrainerDay.* +*TrainerBridge is an independent project, not affiliated with or endorsed by Garmin, +Decathlon, Van Rysel, Zwift, MyWhoosh or TrainerDay.* diff --git a/TrainerBridge/TrainerBridge.ino b/TrainerBridge/TrainerBridge.ino index dc48e7a..2061c9f 100644 --- a/TrainerBridge/TrainerBridge.ino +++ b/TrainerBridge/TrainerBridge.ino @@ -28,7 +28,7 @@ * - Flywheel coasting deceleration for realistic momentum * - Thread-safe FreeRTOS coalescing command queue * - Non-blocking serial CLI - * - Brownout-safe BLE power profile (+6 dBm) + * - +9 dBm BLE TX power (brownout detector disabled for weak USB supplies) * - Deterministic Control Point state machine (zero double-indication violations) * - Value-based BLE address handling (zero heap leaks) */ @@ -118,15 +118,14 @@ static NimBLECharacteristic *virtualStatusChr = nullptr; static bool proxyAdvertisingStartedOnce = false; -// Control Point transaction tracking +// Control Point transaction tracking. +// Shared by loop() and the NimBLE host task: read and transition it only inside +// cpTxMux, and make BLE/Serial calls after leaving the critical section. +static portMUX_TYPE cpTxMux = portMUX_INITIALIZER_UNLOCKED; static volatile CpTxState cpTxState = CpTxState::IDLE; -static uint8_t pendingCpOpcode = 0x00; -static uint16_t pendingCpConnHandle = CommandQueue::NO_CONN_HANDLE; +static QueuedCommand pendingCp = {}; static unsigned long pendingCpSince = 0; -static int16_t pendingTargetPower = 0; static int16_t activeTargetPower = 0; -static uint8_t pendingSimCommandData[CommandQueue::MAX_PAYLOAD_LEN]; -static size_t pendingSimCommandLen = 0; // Packet counters and diagnostics static uint32_t packetsFromTrainer = 0; @@ -538,60 +537,67 @@ static void realControlPointCallback( uint8_t opcode = pData[1]; uint8_t result = pData[2]; - // Ignore responses that do not belong to the pending transaction - // (e.g. the trainer's reply to the Request Control sent at connection time) - if (cpTxState == CpTxState::IDLE || opcode != pendingCpOpcode) + // Claim the pending transaction if this response belongs to it. Responses with + // another opcode (e.g. the reply to the Request Control sent at connection time) + // are ignored. + QueuedCommand completed; + bool matches = false; + bool timedOut = false; + portENTER_CRITICAL(&cpTxMux); + if (cpTxState != CpTxState::IDLE && opcode == pendingCp.opcode) + { + matches = true; + timedOut = cpTxState == CpTxState::TIMED_OUT; + completed = pendingCp; + cpTxState = CpTxState::IDLE; + } + portEXIT_CRITICAL(&cpTxMux); + + if (!matches) { LOGLN("Unsolicited real CP response ignored"); return; } // If the command already timed out, do not send a duplicate indication to app - if (cpTxState == CpTxState::TIMED_OUT) + if (timedOut) { LOGLN("Late real CP indication dropped after timeout"); - cpTxState = CpTxState::IDLE; return; } - if (cpTxState == CpTxState::WAITING_RESPONSE) + // Forward the real trainer's confirmation only to the client that sent the command + if (virtualControlPointChr != nullptr) { - cpTxState = CpTxState::IDLE; + Serial.printf("[REAL CP RESP] opcode=0x%02X, res=0x%02X, targetConnHandle=%d\n", + opcode, result, completed.connHandle); - // Forward the real trainer's confirmation only to the client that sent the command - if (virtualControlPointChr != nullptr) + virtualControlPointChr->setValue(pData, length); + if (completed.connHandle != CommandQueue::NO_CONN_HANDLE) { - Serial.printf("[REAL CP RESP] opcode=0x%02X, res=0x%02X, targetConnHandle=%d\n", - opcode, result, pendingCpConnHandle); - - virtualControlPointChr->setValue(pData, length); - if (pendingCpConnHandle != CommandQueue::NO_CONN_HANDLE) - { - virtualControlPointChr->indicate(pData, length, pendingCpConnHandle); - } + virtualControlPointChr->indicate(pData, length, completed.connHandle); } - pendingCpConnHandle = CommandQueue::NO_CONN_HANDLE; + } - // Emit status notifications ONLY after real trainer confirms success - if (result == FTMS_CP_RES_SUCCESS) + // Emit status notifications ONLY after real trainer confirms success + if (result == FTMS_CP_RES_SUCCESS) + { + if (opcode == FTMS_CP_OP_SET_TARGET_POWER) { - if (opcode == FTMS_CP_OP_SET_TARGET_POWER) - { - activeTargetPower = pendingTargetPower; - notifyFtmsStatusNewPower(activeTargetPower); - } - else if (opcode == FTMS_CP_OP_SET_INDOOR_BIKE_SIM && pendingSimCommandLen >= 7) - { - notifyFtmsStatusIndoorSimulation(pendingSimCommandData, pendingSimCommandLen); - } - else if (opcode == FTMS_CP_OP_START_RESUME) - { - notifyFtmsStatusStarted(); - } - else if (opcode == FTMS_CP_OP_STOP_PAUSE) - { - notifyFtmsStatusStopped(); - } + activeTargetPower = completed.targetValue; + notifyFtmsStatusNewPower(activeTargetPower); + } + else if (opcode == FTMS_CP_OP_SET_INDOOR_BIKE_SIM && completed.len >= 7) + { + notifyFtmsStatusIndoorSimulation(completed.data, completed.len); + } + else if (opcode == FTMS_CP_OP_START_RESUME) + { + notifyFtmsStatusStarted(); + } + else if (opcode == FTMS_CP_OP_STOP_PAUSE) + { + notifyFtmsStatusStopped(); } } } @@ -644,6 +650,9 @@ class VirtualControlPointCallbacks : public NimBLECharacteristicCallbacks { sendVirtualControlPointResponse(opcode, FTMS_CP_RES_SUCCESS, clientConnHandle); notifyFtmsStatusStarted(); + // Also start the real trainer, from loop() (writing here would block the host task). + // It has no originating client: the app already has its response. + commandQueue.push(cmd, len, opcode); return; } @@ -725,10 +734,12 @@ class ProxyServerCallbacks : public NimBLEServerCallbacks reason, server->getConnectedCount()); - if (pendingCpConnHandle == connInfo.getConnHandle()) + portENTER_CRITICAL(&cpTxMux); + if (pendingCp.connHandle == connInfo.getConnHandle()) { - pendingCpConnHandle = CommandQueue::NO_CONN_HANDLE; + pendingCp.connHandle = CommandQueue::NO_CONN_HANDLE; } + portEXIT_CRITICAL(&cpTxMux); } void onMTUChange(uint16_t MTU, NimBLEConnInfo &connInfo) override @@ -763,12 +774,17 @@ class RealClientCallbacks : public NimBLEClientCallbacks realControlPointChr = nullptr; // Fail the in-flight and queued commands so apps do not wait for their own timeout - if (cpTxState == CpTxState::WAITING_RESPONSE) + portENTER_CRITICAL(&cpTxMux); + bool wasWaiting = cpTxState == CpTxState::WAITING_RESPONSE; + QueuedCommand inFlight = pendingCp; + cpTxState = CpTxState::IDLE; + pendingCp.connHandle = CommandQueue::NO_CONN_HANDLE; + portEXIT_CRITICAL(&cpTxMux); + + if (wasWaiting) { - sendVirtualControlPointResponse(pendingCpOpcode, FTMS_CP_RES_OPERATION_FAILED, pendingCpConnHandle); + sendVirtualControlPointResponse(inFlight.opcode, FTMS_CP_RES_OPERATION_FAILED, inFlight.connHandle); } - cpTxState = CpTxState::IDLE; - pendingCpConnHandle = CommandQueue::NO_CONN_HANDLE; QueuedCommand dropped; while (commandQueue.pop(dropped)) @@ -1294,15 +1310,26 @@ static void setupScanner() static void processQueuedCommands() { + const unsigned long now = millis(); + // If an acknowledged command is currently awaiting confirmation from the real trainer - if (cpTxState == CpTxState::WAITING_RESPONSE) + portENTER_CRITICAL(&cpTxMux); + bool waiting = cpTxState == CpTxState::WAITING_RESPONSE; + bool timedOut = waiting && now - pendingCpSince > CP_TIMEOUT_MS; + QueuedCommand expired = pendingCp; + if (timedOut) { - if (millis() - pendingCpSince > CP_TIMEOUT_MS) + cpTxState = CpTxState::TIMED_OUT; + pendingCp.connHandle = CommandQueue::NO_CONN_HANDLE; + } + portEXIT_CRITICAL(&cpTxMux); + + if (waiting) + { + if (timedOut) { LOGLN("Control Point transaction timeout"); - cpTxState = CpTxState::TIMED_OUT; - sendVirtualControlPointResponse(pendingCpOpcode, FTMS_CP_RES_OPERATION_FAILED, pendingCpConnHandle); - pendingCpConnHandle = CommandQueue::NO_CONN_HANDLE; + sendVirtualControlPointResponse(expired.opcode, FTMS_CP_RES_OPERATION_FAILED, expired.connHandle); } return; } @@ -1320,28 +1347,27 @@ static void processQueuedCommands() return; } - if (cmd.opcode == FTMS_CP_OP_SET_TARGET_POWER) - { - pendingTargetPower = cmd.targetValue; - } - else if (cmd.opcode == FTMS_CP_OP_SET_INDOOR_BIKE_SIM) - { - pendingSimCommandLen = cmd.len; - memcpy(pendingSimCommandData, cmd.data, cmd.len); - } + // Start waiting before the write: the trainer's response is delivered on the + // host task and can arrive before writeValue returns. + portENTER_CRITICAL(&cpTxMux); + cpTxState = CpTxState::WAITING_RESPONSE; + pendingCp = cmd; + pendingCpSince = now; + portEXIT_CRITICAL(&cpTxMux); bool ok = writeRealControlPoint(cmd.data, cmd.len); if (!ok) { + portENTER_CRITICAL(&cpTxMux); + if (cpTxState == CpTxState::WAITING_RESPONSE && pendingCp.opcode == cmd.opcode) + { + cpTxState = CpTxState::IDLE; + } + portEXIT_CRITICAL(&cpTxMux); + sendVirtualControlPointResponse(cmd.opcode, FTMS_CP_RES_OPERATION_FAILED, cmd.connHandle); - return; } - - cpTxState = CpTxState::WAITING_RESPONSE; - pendingCpOpcode = cmd.opcode; - pendingCpConnHandle = cmd.connHandle; - pendingCpSince = millis(); } // ===================================================== diff --git a/docs/how-it-works.md b/docs/how-it-works.md new file mode 100644 index 0000000..295dcbb --- /dev/null +++ b/docs/how-it-works.md @@ -0,0 +1,112 @@ +# How it works + +## Architecture + +```text +Real trainer (FTMS 0x1826) + │ + [BLE client] one connection, owned by the ESP32 + ▼ + TrainerBridge (ESP32) + ├── Power filter Q8 EMA smoothing, calibration scale, stale-data watchdog + ├── Speed model flat-road physics, flywheel coasting + └── Command queue FreeRTOS-safe, one trainer transaction at a time + │ + [BLE server] several clients at once + ├── FTMS (0x1826) ──► Zwift / MyWhoosh / TrainerDay (ERG, simulation) + │ └──► Garmin Edge (Smart Trainer) + ├── CSC (0x1816) ──► Garmin speed sensor + └── CPS (0x1818) ──► Garmin power meter +``` + +The ESP32 scans for the trainer (by MAC, then by name, otherwise the first FTMS device), +subscribes to its Indoor Bike Data and Control Point, and takes control of it. It then +advertises its own three services. Advertising pauses at two connected clients and resumes +when one leaves. + +## Bluetooth services + +1. **Fitness Machine Service (FTMS `0x1826`)** + - `0x2AD2` Indoor Bike Data: instantaneous power, speed (0.01 km/h) and total distance + (1 m, `uint24`). + - `0x2ACC` Fitness Machine Feature: power, resistance and total distance. + - `0x2AD9` Control Point: target power, simulation parameters, start/stop. + - `0x2ADA` Fitness Machine Status: reports target and simulation changes once the trainer + has accepted them. +2. **Cycling Speed and Cadence (CSC `0x1816`)** + - `0x2A5B` CSC Measurement: cumulative wheel revolutions and last wheel event time + (1/1024 s), what Garmin expects from a wheel speed sensor. + - `0x2A5C` CSC Feature and `0x2A5D` Sensor Location. +3. **Cycling Power Service (CPS `0x1818`)** + - `0x2A63` Cycling Power Measurement: instantaneous power in watts. + +## Control Point + +App commands go through a queue and reach the trainer from the main loop, never from a +Bluetooth callback (a blocking write there can deadlock the NimBLE host task). + +- **Request Control** and **Reset** are answered by the bridge; it requested control of the + trainer itself when it connected. **Start/Resume** is answered right away and also + forwarded to the trainer. +- **Target power, simulation, resistance, stop**: queued and sent one at a time. The app + gets the trainer's own response, only on its connection. Repeated updates from the same + app are merged while queued, so a slider drag sends only the latest value. +- A command fails with *Operation Failed* when the trainer doesn't answer within 1.2 s, when + the trainer disconnects, or when the queue is full. A late answer after a timeout is + dropped. +- Responses with no recipient (serial console commands, or an app that left) are never + broadcast to other clients. + +## Virtual speed and distance + +On a flat road with no wind, power at the wheel balances rolling resistance and +aerodynamic drag: + +$$P_{\text{wheel}} = \left(C_{\text{rr}} \cdot m \cdot g\right) v + \left(\frac{1}{2} \cdot \rho \cdot C_d A\right) v^3$$ + +| | | | +|---|---|---| +| Total mass $m$ | 85 kg | rider 75 kg + bike 10 kg | +| Rolling resistance $C_{\text{rr}}$ | 0.004 | performance road tyre on smooth asphalt | +| Drag area $C_d A$ | 0.32 m² | endurance position on the hoods | +| Air density $\rho$ | 1.225 kg/m³ | sea level | +| Drivetrain efficiency | 98% | | +| Coasting | 1.5 m/s² | smooth slow-down when you stop pedalling, so Garmin doesn't auto-pause | + +| Power | Speed | +|---|---| +| 100 W | ≈ 26.0 km/h | +| 150 W | ≈ 30.5 km/h | +| 200 W | ≈ 34.0 km/h | +| 250 W | ≈ 36.9 km/h | +| 300 W | ≈ 39.4 km/h | + +All constants are in [`Config.h`](../TrainerBridge/Config.h). + +## Status LED + +| LED | Meaning | +|---|---| +| Blinking (1 Hz) | Searching for or connecting to the trainer | +| Solid | Trainer connected, ready for apps and Garmin | +| Off | No power, or the LED is disabled in `Config.h` | + +Set `STATUS_LED_BLINK_WHILE_SCANNING = false` to keep it off until connected. + +## Source layout + +```text +TrainerBridge/ +├── TrainerBridge.ino Entry point: GATT services, scanning, connection and Control Point state +├── Config.h User settings and physics constants +├── FtmsConstants.h UUIDs, opcodes and flags +├── PowerFilter.h/.cpp Power smoothing, scaling and stale watchdog +├── CommandQueue.h/.cpp Critical-section-protected coalescing command queue +├── TrainerMatcher.h/.cpp Trainer MAC/name matching (with boot self-check) +├── VirtualSpeedModel.h/.cpp Speed/distance model and CSC wheel event timing +└── StatusLed.h/.cpp Non-blocking status LED +tests/ +├── run.sh Builds and runs the host tests (SANITIZE=1 for ASan/UBSan) +├── firmware_test.cpp Named scenario tests against the real sketch +└── stubs/ Arduino, NimBLE and FreeRTOS test doubles +``` diff --git a/docs/testing.md b/docs/testing.md new file mode 100644 index 0000000..6a89c52 --- /dev/null +++ b/docs/testing.md @@ -0,0 +1,56 @@ +# Testing + +## Host tests + +```bash +make test # g++ with C++17, nothing else +make sanitize # same, with AddressSanitizer + UndefinedBehaviorSanitizer (what CI runs) +make check # sanitize, then the real firmware build with all warnings +``` + +`tests/run.sh` compiles the real `TrainerBridge.ino` and helper modules against the doubles in +`tests/stubs/`. The doubles only stand in for the hardware and the Bluetooth stack: they record +notifications, indications and trainer writes per connection, and let a test trigger NimBLE +callbacks. They are not a NimBLE emulator. CI compiles the firmware against the real pinned +ESP32 core and NimBLE-Arduino, which is the API compatibility check. + +`tests/firmware_test.cpp` runs named tests in order, as one session. Covered: + +- Module self-checks (trainer matcher, LED, speed model). +- Scanning: ignoring other devices, 8 s scans, the 3 s retry, no extra scan after a match. +- Connecting: subscriptions, Request Control sent to the trainer, its reply not forwarded. +- Two clients: identical CPS, CSC and FTMS telemetry; advertising pauses at two clients. +- Control Point: local Request Control, Reset and Start answers, Start forwarded to the + trainer; ERG and simulation forwarded, with the response sent only to the sender; + mismatched, failed, timed-out and late responses; a response that arrives before the + trainer write returns; empty and oversized writes; failures not broadcast to other clients. +- 20 trainer disconnect/reconnect cycles without re-initialising NimBLE: queued and in-flight + commands fail to their senders, 0 W is sent, and the client is reused. +- Failed connection and failed subscription return to scanning. +- Command queue: per-client coalescing, payload bounds and capacity. + +Not covered: the radio, NimBLE and FreeRTOS internals, real concurrency between the +Bluetooth host task and `loop()`, timing on the device and memory over a long ride. + +## Hardware checklist + +Run this after changes to Bluetooth, Control Point or connection handling, and after a core or +NimBLE upgrade. It needs an ESP32, a trainer, a Garmin and an app. + +1. **Boot**: flash, open `make monitor`. The module self-checks pass, the trainer is found by + name or MAC, and the LED blinks then turns solid. +2. **Reconnects**: with the Garmin and an app paired, power-cycle the trainer at least 20 + times. Each time: 0 W on loss, rediscovery, fresh telemetry, advertising back, and no + crash, watchdog reset or heap growth (`status`). +3. **Two clients**: Garmin and app together. Power, speed and distance reach both; one + disconnecting and rejoining doesn't interrupt the other. +4. **Control**: ERG target, simulation grade, Start/Stop and Reset against the real trainer. + Resistance changes, and only the sending app gets the response. Disconnect each side while + a command is pending, and repeat after a trainer reconnect. + +## History + +- **2026-10-07**: ESP32 Arduino 3.3.10 → 3.3.12 and NimBLE-Arduino 2.5.0 → 2.5.1. No API + changes affect the firmware. NimBLE 2.5.1's scan-response timer fix concerns host + re-initialisation, which TrainerBridge never does (it calls `NimBLEDevice::init` once). + Hardware checklist not yet run on these versions. diff --git a/tests/VALIDATION.md b/tests/VALIDATION.md deleted file mode 100644 index a8bbef0..0000000 --- a/tests/VALIDATION.md +++ /dev/null @@ -1,97 +0,0 @@ -# Stable patch upgrade validation - -## Dependency and API review - -- [ESP32 Arduino 3.3.11](https://github.com/espressif/arduino-esp32/releases/tag/3.3.11) - and [3.3.12](https://github.com/espressif/arduino-esp32/releases/tag/3.3.12): - reviewed both intervening release notes, including ESP-IDF changes. The sketch - uses Arduino timing, Serial, GPIO, FreeRTOS critical sections and the classic - ESP32 brownout register. It uses NimBLE-Arduino, not the core's separate BLE - library, so the core BLE fixes are not proof of a TrainerBridge behavior change. -- [NimBLE-Arduino 2.5.1](https://github.com/h2zero/NimBLE-Arduino/releases/tag/2.5.1) - and its [2.5.0 comparison](https://github.com/h2zero/NimBLE-Arduino/compare/2.5.0...2.5.1): - reviewed scan timer lifecycle changes and the public signatures used by this - sketch. Scan callbacks/start, client callbacks/connect/subscriptions/writes, - server callbacks, broadcast `notify(data, length)` and connection-specific - `indicate(data, length, handle)` remain compatible. No firmware migration needed. -- The scan-response timer fix initializes the callout lazily and tears it down - before the host event queue. TrainerBridge calls `NimBLEDevice::init` once and - never calls `deinit`; ordinary reconnect reuses the host/client. This does not - reproduce or establish exposure to the upstream reinitialization crash. - -## Reproducible software checks - -```bash -bash tests/run.sh -arduino-cli compile --warnings all --fqbn esp32:esp32:esp32 TrainerBridge -``` - -`tests/run.sh` includes the actual sketch and links the actual helper modules. -The stubs only provide hardware/stack boundaries; they are not a NimBLE emulator. -The Arduino compile is the compatibility check against the real dependencies. - -Host assertions cover: - -- Existing matcher, LED and virtual-speed self-checks. -- Nonmatching/matching advertisements, address copying, scan completion, 8-second - scans and 3-second retry boundaries; no extra retry on a matched scan stopping. -- Twenty disconnect/rediscovery/reconnect cycles, without another host init, - including queue/filter cleanup, remote-pointer clearing, zero-power telemetry, - failed reconnect and failed telemetry subscription. -- Two subscriber handles receive identical CPS/CSC/FTMS payloads; advertising - pauses at two clients and resumes below two after its existing throttle. -- FTMS Request Control, Start and Reset responses; queued ERG and simulation - writes; response routing to the originating handle; successful/failed trainer - replies; failed writes; timeout and late-response suppression; a departing app's - pending response; empty/oversized writes; truncated telemetry; queue capacity, - payload bounds and existing coalescing behavior. - -## Results (2026-10-07) - -- Host regression harness: PASS, including twenty simulated reconnect cycles. -- Arduino CLI 1.5.2-rc.1, ESP32 Arduino 3.3.12 and NimBLE-Arduino 2.5.1: - the exact compile command above passed with no warnings. Flash: 627,988 / - 1,310,720 bytes (47%); static RAM: 37,172 / 327,680 bytes (11%). -- AddressSanitizer and UndefinedBehaviorSanitizer: PASS for the host harness with - `ASAN_OPTIONS=detect_leaks=0`. LeakSanitizer cannot inspect processes in this - execution environment; neither this run nor the mocks validate physical leaks. -- Hardware checks below: not executed. - -## FTMS control point fixes (follow-up) - -The pre-existing control point issues documented with the patch upgrade were fixed -separately, with host coverage: - -- The bridge now actually sends Request Control to the trainer after connecting - (the write previously ran before `realConnected` was set and was declined). -- Trainer responses are matched against the pending opcode; unsolicited or - mismatched responses no longer complete the pending transaction. -- On trainer disconnect, the in-flight and queued commands fail immediately with - Operation Failed, each to its originating client. -- Responses with no originating client (serial CLI commands, or a client that - disconnected mid-transaction) are no longer indicated to every connected client. -- The command queue coalesces only within one client, so every app gets a response. - -Still unhandled: a late reply arriving after a timeout *and* after the next command -with the same opcode was sent is attributed to the newer command; FTMS responses -carry no sequence number to tell them apart. - -## Physical checks still required - -No physical ESP32, trainer, Garmin or app is available in the software harness. - -1. Flash the build on the supported standard ESP32 and D100. Confirm serial module - self-checks, discovery by name/MAC, active scan responses and LED states. -2. Power-cycle the trainer at least 20 times with Garmin and an app still paired. - Check zero power on loss, scan retry after three seconds, rediscovery, fresh - telemetry, recovered advertising, and no crash/watchdog/heap growth. -3. Connect Garmin and an app concurrently. Verify CPS, CSC and FTMS subscriptions, - power/speed/distance delivery, disconnect/rejoin and continued delivery to the - remaining subscriber. Exercise subscription changes and MTU negotiation. -4. Exercise Request Control, ERG target power, simulation grade, Start/Stop and - Reset against the real trainer. Verify resistance changes and response/status - delivery only as intended; disconnect each side during a pending transaction, - provoke timeout/late replies, and repeat reconnect plus control acquisition. -5. The upstream init/deinit timer fix is not exercised by this firmware's normal - reconnect. A separate upstream reproduction is needed to validate that fix on - hardware; the host harness cannot certify the NimBLE timer or event queue. diff --git a/tests/firmware_test.cpp b/tests/firmware_test.cpp index cb239cc..448510d 100644 --- a/tests/firmware_test.cpp +++ b/tests/firmware_test.cpp @@ -1,189 +1,328 @@ -#include -#include +// Host regression tests: runs the real sketch and helper modules against Arduino/NimBLE +// test doubles. Tests share the firmware's global state and run in order, as one session: +// boot, scan, connect, ride, then repeated trainer disconnects. +#include +#include +#include +#include #include "../TrainerBridge/TrainerBridge.ino" -// Call private overrides through the public NimBLE callback interface. +// Unlike assert(), stays active under NDEBUG and names the failing test. +static const char* currentTest = ""; +#define CHECK(cond) do { if(!(cond)) { \ + std::fprintf(stderr,"FAIL %s\n %s:%d: CHECK(%s)\n",currentTest,__FILE__,__LINE__,#cond); \ + std::exit(1); } } while(0) + +using Bytes = std::vector; +static constexpr uint16_t APP = 11; // e.g. Zwift +static constexpr uint16_t GARMIN = 22; // second subscriber + +// ---------- helpers: drive firmware callbacks through the public NimBLE interfaces ---------- + +static NimBLEScan* scanner() { return NimBLEDevice::getScan(); } +static NimBLEAdvertisedDevice trainer; + +static void advertise(const NimBLEAdvertisedDevice& device) { + static_cast(&realScanCallbacks)->onResult(&device); +} +static void completeScan() { + static_cast(&realScanCallbacks)->onScanEnd(NimBLEScanResults{},0); +} static void disconnectTrainer() { static_cast(&realClientCallbacks)->onDisconnect(realClient,19); } -static void writeCp(std::initializer_list bytes,uint16_t handle=11) { - std::vector data(bytes); +static void disconnectApp(uint16_t handle) { + NimBLEConnInfo info(handle); + static_cast(&proxyServerCallbacks)->onDisconnect(proxyServer,info,19); +} +static void writeCp(const Bytes& data,uint16_t handle=APP) { virtualControlPointChr->setValue(data.data(),data.size()); NimBLEConnInfo info(handle); static_cast(&virtualControlPointCallbacks)->onWrite(virtualControlPointChr,info); } +static void reply(uint8_t opcode,uint8_t result=FTMS_CP_RES_SUCCESS) { + uint8_t bytes[]={FTMS_CP_OP_RESPONSE_CODE,opcode,result}; + realControlPointCallback(nullptr,bytes,sizeof(bytes),false); +} static void clearDeliveries() { for(auto* c:{virtualCpsMeasurementChr,virtualCscMeasurementChr,virtualIndoorBikeDataChr,virtualStatusChr,virtualControlPointChr}) { c->notifications.clear(); c->indications.clear(); } } -static void reply(uint8_t opcode,uint8_t result=FTMS_CP_RES_SUCCESS) { - uint8_t bytes[]={FTMS_CP_OP_RESPONSE_CODE,opcode,result}; - realControlPointCallback(nullptr,bytes,sizeof(bytes),false); -} -static void assertCp(uint8_t opcode,uint8_t result,uint16_t handle) { +static size_t cpIndications() { return virtualControlPointChr->indications.size(); } +static bool lastCpIs(uint8_t opcode,uint8_t result,uint16_t handle) { + if(virtualControlPointChr->indications.empty()) return false; const auto& d=virtualControlPointChr->indications.back(); - assert(d.handle==handle); - assert((d.bytes==std::vector{FTMS_CP_OP_RESPONSE_CODE,opcode,result})); + return d.handle==handle && d.bytes==Bytes{FTMS_CP_OP_RESPONSE_CODE,opcode,result}; } -static void completeScan() { - static_cast(&realScanCallbacks)->onScanEnd(NimBLEScanResults{},0); +static const Bytes& lastTrainerWrite() { return realControlPointChr->writes.back(); } + +// ---------- tests ---------- + +static void moduleSelfChecks() { + CHECK(TrainerMatcher::selfCheck()); + CHECK(StatusLed::selfCheck()); + CHECK(VirtualSpeedModel::selfCheck()); } -int main() { - assert(TrainerMatcher::selfCheck()); - assert(StatusLed::selfCheck()); - assert(VirtualSpeedModel::selfCheck()); + +static void bootStartsAnEightSecondScan() { setup(); - assert(NimBLEDevice::initCalls==1); + CHECK(NimBLEDevice::initCalls==1); loop(); - auto* scan=NimBLEDevice::getScan(); - assert(scanning && scan->starts==1 && scan->duration==8000 && scan->clears==1); + CHECK(scanning && scanner()->starts==1 && scanner()->duration==8000 && scanner()->clears==1); +} + +static void ignoresOtherDevicesAndRetriesAfterThreeSeconds() { NimBLEAdvertisedDevice other; other.name="Other trainer"; - static_cast(&realScanCallbacks)->onResult(&other); - assert(!doConnectReal && scanning); + advertise(other); + CHECK(!doConnectReal && scanning); completeScan(); - assert(doScan && !scanning); + CHECK(doScan && !scanning); const auto retry=lastScanAt; - testNow=retry-1; loop(); assert(scan->starts==1); - testNow=retry; loop(); assert(scan->starts==2); - NimBLEAdvertisedDevice trainer; - static_cast(&realScanCallbacks)->onResult(&trainer); - assert(doConnectReal && !doScan && !scanning); - assert(realTrainerAddress.toString()==trainer.address.toString()); - completeScan(); assert(!doScan); // Stop completion must not schedule another scan after match. + testNow=retry-1; loop(); CHECK(scanner()->starts==1); + testNow=retry; loop(); CHECK(scanner()->starts==2); +} + +static void connectsToMatchingTrainer() { + advertise(trainer); + CHECK(doConnectReal && !doScan && !scanning); + CHECK(realTrainerAddress.toString()==trainer.address.toString()); + completeScan(); CHECK(!doScan); // The stop after a match must not schedule another scan. loop(); - assert(realConnected && proxyReady && realClient); - assert(realIndoorBikeDataChr->callback==realIndoorBikeDataCallback); - assert(realControlPointChr->callback==realControlPointCallback); - // The bridge takes control of the trainer once connected; the reply is not forwarded. - assert((realControlPointChr->writes==std::vector>{{FTMS_CP_OP_REQUEST_CONTROL}})); - reply(FTMS_CP_OP_REQUEST_CONTROL); - assert(virtualControlPointChr->indications.empty() && cpTxState==CpTxState::IDLE); - proxyServer->connectedCount=2; loop(); assert(!NimBLEDevice::advertising.active); - proxyServer->connectedCount=1; testNow+=500; loop(); assert(NimBLEDevice::advertising.active); + CHECK(realConnected && proxyReady && realClient); + CHECK(realIndoorBikeDataChr->callback==realIndoorBikeDataCallback); + CHECK(realControlPointChr->callback==realControlPointCallback); +} + +static void requestsTrainerControlOnConnect() { + CHECK((realControlPointChr->writes==std::vector{{FTMS_CP_OP_REQUEST_CONTROL}})); + reply(FTMS_CP_OP_REQUEST_CONTROL); // Not an app transaction: nothing is forwarded. + CHECK(cpIndications()==0 && cpTxState==CpTxState::IDLE); +} + +static void pausesAdvertisingAtTwoClients() { + proxyServer->connectedCount=2; loop(); CHECK(!NimBLEDevice::advertising.active); + proxyServer->connectedCount=1; testNow+=500; loop(); CHECK(NimBLEDevice::advertising.active); +} +static void sendsIdenticalTelemetryToEverySubscriber() { clearDeliveries(); uint8_t telemetry[]={0x40,0,0,0,200,0}; // Speed and instantaneous power. realIndoorBikeDataCallback(nullptr,telemetry,sizeof(telemetry),true); for(auto* c:{virtualCpsMeasurementChr,virtualCscMeasurementChr,virtualIndoorBikeDataChr}) { - assert(c->notifications.size()==2); - assert(c->notifications[0].handle==11 && c->notifications[1].handle==22); - assert(c->notifications[0].bytes==c->notifications[1].bytes); + CHECK(c->notifications.size()==2); + CHECK(c->notifications[0].handle==APP && c->notifications[1].handle==GARMIN); + CHECK(c->notifications[0].bytes==c->notifications[1].bytes); } - assert(readS16(virtualCpsMeasurementChr->notifications[0].bytes.data(),2)==200); - assert(readS16(virtualIndoorBikeDataChr->notifications[0].bytes.data(),7)==200); + CHECK(readS16(virtualCpsMeasurementChr->notifications[0].bytes.data(),2)==200); + CHECK(readS16(virtualIndoorBikeDataChr->notifications[0].bytes.data(),7)==200); +} + +static void rejectsTruncatedTelemetry() { + uint8_t telemetry[]={0x40,0,0,0,200,0}; int16_t parsed=0; - assert(!parseRealIndoorBikeData(telemetry,5,parsed)); - assert(!parseRealIndoorBikeData(telemetry,1,parsed)); + CHECK(!parseRealIndoorBikeData(telemetry,5,parsed)); + CHECK(!parseRealIndoorBikeData(telemetry,1,parsed)); +} +static void answersControlAndResetLocally() { clearDeliveries(); writeCp({FTMS_CP_OP_REQUEST_CONTROL}); - assertCp(FTMS_CP_OP_REQUEST_CONTROL,FTMS_CP_RES_SUCCESS,11); - assert(commandQueue.isEmpty()); - writeCp({FTMS_CP_OP_START_RESUME}); - assertCp(FTMS_CP_OP_START_RESUME,FTMS_CP_RES_SUCCESS,11); + CHECK(lastCpIs(FTMS_CP_OP_REQUEST_CONTROL,FTMS_CP_RES_SUCCESS,APP)); writeCp({FTMS_CP_OP_RESET}); - assertCp(FTMS_CP_OP_RESET,FTMS_CP_RES_SUCCESS,11); + CHECK(lastCpIs(FTMS_CP_OP_RESET,FTMS_CP_RES_SUCCESS,APP)); + CHECK(commandQueue.isEmpty()); +} + +static void startIsAnsweredLocallyAndForwardedToTrainer() { + writeCp({FTMS_CP_OP_START_RESUME}); + CHECK(lastCpIs(FTMS_CP_OP_START_RESUME,FTMS_CP_RES_SUCCESS,APP)); + const auto indications=cpIndications(); + processQueuedCommands(); + CHECK(lastTrainerWrite()==Bytes{FTMS_CP_OP_START_RESUME}); + reply(FTMS_CP_OP_START_RESUME); + CHECK(cpIndications()==indications && cpTxState==CpTxState::IDLE); // App already has its answer. +} + +static void rejectsEmptyAndOversizedWrites() { + const auto indications=cpIndications(); writeCp({}); - std::vector oversized(21,FTMS_CP_OP_SET_TARGET_POWER); - virtualControlPointChr->setValue(oversized.data(),oversized.size()); - NimBLEConnInfo info(22); - static_cast(&virtualControlPointCallbacks)->onWrite(virtualControlPointChr,info); - assertCp(FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_INVALID_PARAMETER,22); - writeCp({FTMS_CP_OP_SET_TARGET_POWER,250,0},22); + CHECK(cpIndications()==indications); + writeCp(Bytes(21,FTMS_CP_OP_SET_TARGET_POWER),GARMIN); + CHECK(lastCpIs(FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_INVALID_PARAMETER,GARMIN)); +} + +static void forwardsErgTargetAndRoutesResponseToSender() { + writeCp({FTMS_CP_OP_SET_TARGET_POWER,250,0},GARMIN); processQueuedCommands(); - assert(cpTxState==CpTxState::WAITING_RESPONSE); - assert(realControlPointChr->writes.back()==std::vector({FTMS_CP_OP_SET_TARGET_POWER,250,0})); - auto before=virtualControlPointChr->indications.size(); - reply(FTMS_CP_OP_START_RESUME); // Mismatched opcode must not complete the transaction. - assert(cpTxState==CpTxState::WAITING_RESPONSE && virtualControlPointChr->indications.size()==before); - auto statuses=virtualStatusChr->notifications.size(); + CHECK(cpTxState==CpTxState::WAITING_RESPONSE); + CHECK((lastTrainerWrite()==Bytes{FTMS_CP_OP_SET_TARGET_POWER,250,0})); + const auto indications=cpIndications(); + reply(FTMS_CP_OP_START_RESUME); // Wrong opcode: does not complete the transaction. + CHECK(cpTxState==CpTxState::WAITING_RESPONSE && cpIndications()==indications); + const auto statuses=virtualStatusChr->notifications.size(); reply(FTMS_CP_OP_SET_TARGET_POWER); - assertCp(FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_SUCCESS,22); - assert(cpTxState==CpTxState::IDLE && activeTargetPower==250); - assert(virtualStatusChr->notifications.size()==statuses+2); + CHECK(lastCpIs(FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_SUCCESS,GARMIN)); + CHECK(cpTxState==CpTxState::IDLE && activeTargetPower==250); + CHECK(virtualStatusChr->notifications.size()==statuses+2); +} + +static void forwardsSimulationParameters() { writeCp({FTMS_CP_OP_SET_INDOOR_BIKE_SIM,0,0,1,0,4,32}); processQueuedCommands(); reply(FTMS_CP_OP_SET_INDOOR_BIKE_SIM); - assertCp(FTMS_CP_OP_SET_INDOOR_BIKE_SIM,FTMS_CP_RES_SUCCESS,11); - assert(virtualStatusChr->notifications.back().bytes.size()==7); + CHECK(lastCpIs(FTMS_CP_OP_SET_INDOOR_BIKE_SIM,FTMS_CP_RES_SUCCESS,APP)); + CHECK(virtualStatusChr->notifications.back().bytes.size()==7); +} + +static void handlesResponseArrivingBeforeWriteReturns() { + realControlPointChr->afterWrite=[]{ reply(FTMS_CP_OP_SET_TARGET_POWER); }; + writeCp({FTMS_CP_OP_SET_TARGET_POWER,220,0}); + processQueuedCommands(); + CHECK(lastCpIs(FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_SUCCESS,APP)); + CHECK(cpTxState==CpTxState::IDLE && activeTargetPower==220); +} + +static void forwardsTrainerFailureWithoutStatusChange() { writeCp({FTMS_CP_OP_SET_TARGET_POWER,100,0}); - processQueuedCommands(); statuses=virtualStatusChr->notifications.size(); + processQueuedCommands(); + const auto statuses=virtualStatusChr->notifications.size(); reply(FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_OPERATION_FAILED); - assertCp(FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_OPERATION_FAILED,11); - assert(virtualStatusChr->notifications.size()==statuses && activeTargetPower==250); + CHECK(lastCpIs(FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_OPERATION_FAILED,APP)); + CHECK(virtualStatusChr->notifications.size()==statuses && activeTargetPower==220); +} + +static void failsWhenTrainerWriteFails() { realControlPointChr->writeOk=false; writeCp({FTMS_CP_OP_STOP_PAUSE,1}); processQueuedCommands(); - assertCp(FTMS_CP_OP_STOP_PAUSE,FTMS_CP_RES_OPERATION_FAILED,11); + CHECK(lastCpIs(FTMS_CP_OP_STOP_PAUSE,FTMS_CP_RES_OPERATION_FAILED,APP)); + CHECK(cpTxState==CpTxState::IDLE); realControlPointChr->writeOk=true; +} + +static void timesOutAndDropsLateResponse() { writeCp({FTMS_CP_OP_STOP_PAUSE,1}); processQueuedCommands(); testNow+=CP_TIMEOUT_MS+1; processQueuedCommands(); - assertCp(FTMS_CP_OP_STOP_PAUSE,FTMS_CP_RES_OPERATION_FAILED,11); - const auto indications=virtualControlPointChr->indications.size(); + CHECK(lastCpIs(FTMS_CP_OP_STOP_PAUSE,FTMS_CP_RES_OPERATION_FAILED,APP)); + const auto indications=cpIndications(); reply(FTMS_CP_OP_STOP_PAUSE); - assert(virtualControlPointChr->indications.size()==indications && cpTxState==CpTxState::IDLE); + CHECK(cpIndications()==indications && cpTxState==CpTxState::IDLE); +} - // A departing app must not receive the pending trainer response. +static void departedAppGetsNoResponseAndOthersGetNoBroadcast() { + const auto indications=cpIndications(); writeCp({FTMS_CP_OP_SET_TARGET_POWER,150,0}); processQueuedCommands(); - NimBLEConnInfo departing(11); - static_cast(&proxyServerCallbacks)->onDisconnect(proxyServer,departing,19); + disconnectApp(APP); reply(FTMS_CP_OP_SET_TARGET_POWER); - assert(virtualControlPointChr->indications.size()==indications); - // A departing app's timeout is not broadcast to the remaining clients. + CHECK(cpIndications()==indications); + writeCp({FTMS_CP_OP_SET_TARGET_POWER,160,0}); processQueuedCommands(); - static_cast(&proxyServerCallbacks)->onDisconnect(proxyServer,departing,19); + disconnectApp(APP); testNow+=CP_TIMEOUT_MS+1; processQueuedCommands(); - assert(virtualControlPointChr->indications.size()==indications && cpTxState==CpTxState::TIMED_OUT); + CHECK(cpIndications()==indications && cpTxState==CpTxState::TIMED_OUT); reply(FTMS_CP_OP_SET_TARGET_POWER); + CHECK(cpTxState==CpTxState::IDLE); +} - // Repeat disconnect -> delayed scan -> rediscovery -> reconnect, keeping the same host/client. +static void survivesTwentyTrainerReconnects() { auto* client=realClient; for(int cycle=0;cycle<20;++cycle) { writeCp({FTMS_CP_OP_SET_TARGET_POWER,180,0}); processQueuedCommands(); - assert(cpTxState==CpTxState::WAITING_RESPONSE); - writeCp({FTMS_CP_OP_STOP_PAUSE,1},22); - assert(!commandQueue.isEmpty()); + CHECK(cpTxState==CpTxState::WAITING_RESPONSE); + writeCp({FTMS_CP_OP_STOP_PAUSE,1},GARMIN); + CHECK(!commandQueue.isEmpty()); clearDeliveries(); disconnectTrainer(); - // In-flight and queued commands fail immediately, each to its originating client. - assert(virtualControlPointChr->indications.size()==2); - assert((virtualControlPointChr->indications[0].bytes==std::vector{FTMS_CP_OP_RESPONSE_CODE,FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_OPERATION_FAILED})); - assert(virtualControlPointChr->indications[0].handle==11); - assertCp(FTMS_CP_OP_STOP_PAUSE,FTMS_CP_RES_OPERATION_FAILED,22); - assert(!realConnected && !proxyReady && !realIndoorBikeDataChr && !realControlPointChr); - assert(commandQueue.isEmpty() && cpTxState==CpTxState::IDLE); - assert(powerFilter.getLastOutput()==0 && virtualSpeedModel.getSpeedMps()==0); - assert(!NimBLEDevice::advertising.active && doScan); - assert(readS16(virtualCpsMeasurementChr->notifications.back().bytes.data(),2)==0); - assert(readS16(virtualIndoorBikeDataChr->notifications.back().bytes.data(),7)==0); + CHECK(!realConnected && !proxyReady && !realIndoorBikeDataChr && !realControlPointChr); + CHECK(commandQueue.isEmpty() && cpTxState==CpTxState::IDLE); + + // In-flight and queued commands fail immediately, each to its sender. + CHECK(cpIndications()==2); + CHECK((virtualControlPointChr->indications[0].bytes==Bytes{FTMS_CP_OP_RESPONSE_CODE,FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_OPERATION_FAILED})); + CHECK(virtualControlPointChr->indications[0].handle==APP); + CHECK(lastCpIs(FTMS_CP_OP_STOP_PAUSE,FTMS_CP_RES_OPERATION_FAILED,GARMIN)); + + // Riders see zero power instead of a frozen value. + CHECK(powerFilter.getLastOutput()==0 && virtualSpeedModel.getSpeedMps()==0); + CHECK(readS16(virtualCpsMeasurementChr->notifications.back().bytes.data(),2)==0); + CHECK(readS16(virtualIndoorBikeDataChr->notifications.back().bytes.data(),7)==0); + CHECK(!NimBLEDevice::advertising.active && doScan); + writeCp({FTMS_CP_OP_SET_TARGET_POWER,200,0}); - assertCp(FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_OPERATION_FAILED,11); - const auto starts=scan->starts; - testNow=lastScanAt-1; loop(); assert(scan->starts==starts); - testNow=lastScanAt; loop(); assert(scan->starts==starts+1); - static_cast(&realScanCallbacks)->onResult(&trainer); - loop(); assert(realConnected && proxyReady && realClient==client); - assert(NimBLEDevice::initCalls==1); + CHECK(lastCpIs(FTMS_CP_OP_SET_TARGET_POWER,FTMS_CP_RES_OPERATION_FAILED,APP)); + + const auto starts=scanner()->starts; + testNow=lastScanAt-1; loop(); CHECK(scanner()->starts==starts); + testNow=lastScanAt; loop(); CHECK(scanner()->starts==starts+1); + advertise(trainer); + loop(); + CHECK(realConnected && proxyReady && realClient==client); + CHECK(NimBLEDevice::initCalls==1); } - CommandQueue queue; - uint8_t command[]={FTMS_CP_OP_SET_TARGET_POWER,1,0}; - assert(queue.push(command,3,command[0],1,11)); - command[1]=2; assert(queue.push(command,3,command[0],2,11)); - command[1]=3; assert(queue.push(command,3,command[0],3,22)); - QueuedCommand out{}; assert(queue.count()==2 && queue.pop(out)); - assert(out.targetValue==2 && out.connHandle==11); // Coalesces only within one client. - assert(queue.pop(out) && out.targetValue==3 && out.connHandle==22); - assert(!queue.push(nullptr,3,command[0])); - assert(!queue.push(command,21,command[0])); - // Failed reconnect and failed telemetry subscription return to the scan retry path. +} + +static void failedConnectOrSubscribeReturnsToScanning() { disconnectTrainer(); realClient->connectOk=false; doConnectReal=true; - loop(); assert(!realClient && !realConnected && doScan); + loop(); CHECK(!realClient && !realConnected && doScan); realClient=NimBLEDevice::createClient(); realClient->service.data.subscribeOk=false; doConnectReal=true; - loop(); assert(!realClient && !proxyReady && doScan); - assert(queue.push(command,3,FTMS_CP_OP_STOP_PAUSE)); - for(size_t i=1;i> tests={ +#define T(fn) {#fn,fn} + T(moduleSelfChecks), + T(bootStartsAnEightSecondScan), + T(ignoresOtherDevicesAndRetriesAfterThreeSeconds), + T(connectsToMatchingTrainer), + T(requestsTrainerControlOnConnect), + T(pausesAdvertisingAtTwoClients), + T(sendsIdenticalTelemetryToEverySubscriber), + T(rejectsTruncatedTelemetry), + T(answersControlAndResetLocally), + T(startIsAnsweredLocallyAndForwardedToTrainer), + T(rejectsEmptyAndOversizedWrites), + T(forwardsErgTargetAndRoutesResponseToSender), + T(forwardsSimulationParameters), + T(handlesResponseArrivingBeforeWriteReturns), + T(forwardsTrainerFailureWithoutStatusChange), + T(failsWhenTrainerWriteFails), + T(timesOutAndDropsLateResponse), + T(departedAppGetsNoResponseAndOthersGetNoBroadcast), + T(survivesTwentyTrainerReconnects), + T(failedConnectOrSubscribeReturnsToScanning), + T(queueCoalescesPerClient), + T(queueEnforcesBoundsAndCapacity), +#undef T + }; + for(const auto& [name,fn]:tests) { + currentTest=name; + fn(); + std::printf("ok %s\n",name); + } + std::printf("PASS: %zu tests\n",tests.size()); } diff --git a/tests/run.sh b/tests/run.sh index 9983968..cbf0d77 100755 --- a/tests/run.sh +++ b/tests/run.sh @@ -1,9 +1,14 @@ #!/usr/bin/env bash +# Build and run the host regression tests. SANITIZE=1 adds AddressSanitizer + UBSan. set -euo pipefail cd "$(dirname "$0")/.." build_dir=$(mktemp -d) trap 'rm -rf "$build_dir"' EXIT -"${CXX:-g++}" -std=c++17 -Wall -Wextra -Wno-unused-parameter \ +flags=(-std=c++17 -Wall -Wextra -Werror -Wno-unused-parameter -g) +if [[ "${SANITIZE:-0}" == 1 ]]; then + flags+=(-fsanitize=address,undefined -fno-sanitize-recover=all) +fi +"${CXX:-g++}" "${flags[@]}" \ -Itests/stubs -ITrainerBridge tests/firmware_test.cpp \ TrainerBridge/{CommandQueue,PowerFilter,TrainerMatcher,VirtualSpeedModel,StatusLed}.cpp \ -o "$build_dir/firmware_test" diff --git a/tests/stubs/NimBLEDevice.h b/tests/stubs/NimBLEDevice.h index ea1a98c..56f8fe4 100644 --- a/tests/stubs/NimBLEDevice.h +++ b/tests/stubs/NimBLEDevice.h @@ -5,6 +5,7 @@ #include #include #include +#include constexpr int ESP_PWR_LVL_P9=9; namespace NIMBLE_PROPERTY { constexpr int READ=1, WRITE=2, NOTIFY=4, INDICATE=8; } class NimBLEUUID { @@ -127,11 +128,16 @@ struct NimBLERemoteCharacteristic { bool writable=true,notifiable=true,indicatable=true,subscribeOk=true,writeOk=true; RemoteCallback callback=nullptr; std::vector> writes; + std::function afterWrite; // Simulates a peer response arriving before writeValue returns. bool canWrite() const {return writable;} bool canNotify() const {return notifiable;} bool canIndicate() const {return indicatable;} bool subscribe(bool,RemoteCallback c) {callback=c;return subscribeOk;} - bool writeValue(uint8_t* d,size_t n,bool) {writes.push_back({d,d+n});return writeOk;} + bool writeValue(uint8_t* d,size_t n,bool) { + writes.push_back({d,d+n}); + if(afterWrite) {auto hook=afterWrite; afterWrite=nullptr; hook();} + return writeOk; + } }; struct NimBLERemoteService { NimBLERemoteCharacteristic data,control;