ESP32-based IMU sensor device for the natKit BCI toolkit. This device collects inertial measurement data and streams it to the natKit backend via MQTT.
This repository holds two node firmwares. They speak to the same broker and the same topic names, so knowing which one is on a board is not optional.
embeded/ |
firmware-idf/ |
|
|---|---|---|
| What | the ORIGINAL firmware, now a rollback path — every node a full WiFi/MQTT/NTP client | what the rig actually runs (EPIC TEC-NATKIT-20): leaf/primary/gateway roles, ESP-NOW between nodes, one networked hub |
| Framework | Arduino via pioarduino (arduino-esp32 3.3.11 / ESP-IDF 5.5.5) | native ESP-IDF (idf.py), v5.5.3 |
| Build | cd embeded && pio run -e release |
cd firmware-idf && ./build-role.sh leaf esp32 |
| Status | buildable, kept as the rollback path; benched head-to-head on 2026-08-17 (TEC-NATKIT-27) | in use on every board, streaming 100 samples/s per leaf |
✅ THE DECISION IS MADE: the fork was ADOPTED on 2026-08-17 (TEC-NATKIT-27), on
a head-to-head bench of both firmwares on the same two boards, the same broker and
the same three hours. Per node, two nodes: 98 fresh samples/s against 56–80, in
half the bandwidth, with 1.4 ms of inter-arrival jitter against 17 ms and
node-to-node clock agreement of 0.1–0.2 ms against 2.7–5.5 — plus the magnetometer,
which embeded/ cannot carry.
embeded/ is kept as a rollback path, not retired, and reviewed on
2026-09-15. For it to stay a real rollback rather than a tree that merely
compiles, its libnatkit-core pin has to be bumped when the core moves, and it has
to be flashed onto a board and measured once per review cycle — 2026-08-17 was the
first time in months, and it immediately turned out to be incapable of running two
nodes at once (see below).
So new work goes in firmware-idf/. Do not edit embeded/ unless the change
actually requires it — a change there cannot be verified, because no board runs
it, and it does not even build against the sibling submodule (see the wire-format
warning below). If a fix would be needed after a rollback, file a ticket saying so
rather than porting it pre-emptively.
embeded/ before
the migration were not all carried across. The EXECUTION_COMMAND / LOGGING_LOG
channel (embeded/include/CommandChannel.hpp) has no counterpart in
firmware-idf/ at all, and it was verified on hardware a week before the switch.
Nothing errors — the backend half still works, so a command is published and
simply goes unsubscribed. TEC-NATKIT-39.
embeded/ COULD NEVER RUN TWO NODES AT ONCE UNTIL 2026-08-17. All three of
its connect() calls passed the literal MQTT client id "natKit-IMU", and a broker
must evict an existing session when a second client presents the same id — so two of
these nodes took turns kicking each other off ~700 times a minute and lost 41% of
their frames. Fixed (natkitMqttClientId()); if you are on a checkout older than
that, do not benchmark two of them. Measured on TEC-NATKIT-27.
firmware-idf/
emits IMU frame version 2 (13 floats, 62-byte samples, 644-byte frames, with
the magnetometer). embeded/ emits version 1 (10 floats, 50-byte samples, 524
bytes) and will keep doing so until its platformio.ini libnatkit-core pin is
bumped past the version-2 commit — it pins a GitHub commit rather than the sibling
submodule, so editing libnatkit/lib/libnatkit-core does nothing for it. Decoders
read both, so a rollback still produces valid recordings; they simply have no
magnetic field in them.
Last recorded state — update this table when you flash something, and note that it is a record rather than a measurement (nothing is read back off a board):
| Board | Port (by-id is the stable name) | Firmware | Recorded |
|---|---|---|---|
ESP32-D0WD-V3 rev 3.1, MAC 30:c9:22:33:0c:ec, id 53640420330732 |
CP2102, serial 0001 |
firmware-idf/ primary — the WiFi rig's hub. ESP-NOW + serial uplink, no network of its own. |
2026-08-25 |
ESP32-C3 (QFN32) rev v0.4, MAC dc:da:0c:d1:49:38, id 242829076023608 |
CP2102N, serial f46f1cbd859ded11a31c5f84e259fb3e |
firmware-idf/ gateway — serial in, WiFi + MQTT out. main/DevConfig.hpp). |
2026-08-25 |
ESP32-S3 (ESP Thread Border Router + W5500 Ethernet), MAC b8:f8:62:62:f7:3c, id 203376942053180 |
Espressif native USB, serial = its MAC | firmware-idf/ primary (Ethernet uplink). capture.py returns an empty file — diagnose it from the published status. |
2026-08-25 |
ESP32-PICO-V3-02, MAC 0c:8b:95:96:bc:4c, id 13793649671244, BNO08x |
CH340, serial 5185026888 |
firmware-idf/ leaf |
2026-08-17 |
ESP32, MAC 4c:75:25:a4:45:3c, id 84066026407228, BNO08x |
CH340, serial 5185027828 |
firmware-idf/ leaf. |
2026-08-25 |
ESP32, MAC 0c:8b:95:96:b9:f4, id 13793649670644 — the board previously believed damaged |
CH340, serial 5185027171 or 5185027831, |
firmware-idf/ leaf |
2026-08-18 |
ESP32, MAC 0c:8b:95:94:ef:d0, id 13793649553360 |
CH340, the other of 5185027171 / 5185027831 |
firmware-idf/ leaf |
2026-08-18 |
ESP32-PICO-V3-02 rev 3.0, MAC 0c:8b:95:94:f0:78, id 13793649553528 |
CH340, serial 5185027088 |
added to the bench 2026-08-25; streamed briefly, not currently on the radio | 2026-08-25 |
| ESP32 (unknown revision) | CH340, serial 5185027373 |
2026-08-25 |
5185027828 WAS WRONGLY SUSPECTED, and the evidence against it was confounded.
On 2026-08-25 it showed a NatKitNodeStatusV1 topic with no Data topic, which looks
exactly like a node that is registered and delivering nothing. It was simply
unplugged — the hub keeps publishing a registry entry for a leaf that is gone
(TEC-NATKIT-81). Reconnected, it delivers 10.0 frames/s with zero sequence gaps
and the strongest RSSI of the four (−43 dBm mean, −57 worst). A missing Data topic
is not evidence about a board until you have checked the board is powered.
For a bench with no Ethernet port. The hub does ESP-NOW only and reaches the broker through a second chip over a wire, which keeps the uplink off the radio — the thing TEC-NATKIT-30 measured as costing ~85% of ESP-NOW frames when one chip tried to do both.
# hub: ESP-NOW + serial uplink, no network of its own
./build-role.sh primary esp32 \
-p /dev/serial/by-id/usb-Silicon_Labs_CP2102_USB_to_UART_Bridge_Controller_0001-if00-port0 flash
# gateway: serial in, WiFi + MQTT out. Needs main/DevConfig.hpp (gitignored).
./build-role.sh gateway esp32c3 \
-p /dev/serial/by-id/usb-Silicon_Labs_CP2102N_USB_to_UART_Bridge_Controller_f46f1cbd859ded11a31c5f84e259fb3e-if00-port0 flashThree wires, and it is a CROSSOVER:
| From | To | Carries |
|---|---|---|
| ESP32 primary GPIO 26 (UART1 TX) | C3 gateway GPIO 6 (UART1 RX) | data and status, upward |
| C3 gateway GPIO 5 (UART1 TX) | ESP32 primary GPIO 25 (UART1 RX) | device commands, downward |
| GND | GND | mandatory shared reference |
0 bytes and 0 frames are different faults. A baud
mismatch, a floating line or a missing ground all produce edges, which land in the
gateway's bytes_skipped as resync garbage. Exactly zero bytes read means the RX
pin saw no transitions at all — an open circuit or the wrong pin, and nothing else.
So read bytes_read before suspecting the baud rate. That is what found a jumper on
the wrong pin on 2026-08-25 while the primary was reporting 45,662 bytes sent.
uart_set_pin rejects a GPIO the chip does not have, and the board reboots in a
loop before there is any console output to say why. The S3 pair is reasoned rather
than measured — that board runs Ethernet and has never called uart_set_pin.
udevadm info -q property -n /dev/ttyACM0 | grep -E 'ID_MODEL=|ID_SERIAL_SHORT='
ls -l /dev/serial/by-id/ # the stable names, immune to ttyACM renumbering/dev/serial/by-id/..., NOT BY ttyACM NUMBER. -p /dev/ttyACM1
aims at a port, not a board. esptool refuses a chip-type mismatch, so aiming a
leaf image at the S3 fails loudly — but two ESP32 leaves can be flashed in the
wrong order with no error whatsoever, which silently invalidates any A/B between
them. The by-id path names the board:
./build-role.sh leaf esp32 -p /dev/serial/by-id/usb-1a86_USB_Single_Serial_5185026888-if00 flash(Measured 2026-08-17/18: the ttyACM numbers did NOT in fact move across a full day of resets, reflashes and bootloader parks, nor when two more boards were plugged in — a DTR/RTS reset does not re-enumerate a CH340. Use by-id anyway; the cost is zero and the failure is silent.)
The S3 primary reports Espressif / USB_JTAG_serial_debug_unit and its MAC as the
USB serial number, so it is unambiguous. The ESP32 leaves report a 1a86 CH340
bridge with an opaque serial — but the serials are distinct and stable, so the
two leaves can be told apart without flashing anything after all. Mapped on
2026-08-17 by flashing one and watching which device id changed firmware:
ID_SERIAL_SHORT |
board | device id |
|---|---|---|
5185026888 |
ESP32-PICO-V3-02, 0c:8b:95:96:bc:4c |
13793649671244 |
5185027828 |
ESP32, 4c:75:25:a4:45:3c |
84066026407228 |
embeded/ by contrast was back at full rate in ~8 s.
beacons_missed and leaf_send_failures
from its NatKitNodeStatusV1 — the bad node was missing 616 of 913 beacons with
1800 send failures, while the healthy one missed 22 and failed 4. The clock-fit
figures say nothing about it: both nodes had a fully saturated residual_rms_ns and
one of them was delivering perfectly.
0c:8b:95:96:b9:f4 IS BACK, AND IT IS NOT OBVIOUSLY BROKEN. It was
removed on 2026-08-14 as "physically banged up" after delivering 0-22 samples/s with
274 sequence gaps against another leaf's clean 10/s, and TEC-NATKIT-46 was closed on
that. Reconnected and reflashed on 2026-08-18 it streams at ~10 frames/s and the hub
hears it at −36 dBm, the strongest of all four nodes.
Treat the original diagnosis as unsafe rather than wrong: the evidence that condemned it — one leaf delivering badly while its neighbour is clean, on identical firmware and power — is exactly what the two undamaged boards have since been doing to each other, alternating every 10-30 minutes with nothing touched (TEC-NATKIT-50). Physical damage was a plausible story for a symptom that turns out not to need one.
A pre-flash 4 MB dump of the working embeded/ firmware from board …b9:f4 is
kept at ~/natkit-verification/598a800/embeded-preflash-backup.bin (sha256 in
backup.sha256) if a byte-exact restore is ever wanted:
esptool write_flash 0 embeded-preflash-backup.bin.
cd embeded && pio run -e release -t uploadIt rebuilds and flashes the Arduino firmware from whatever
commit is checked out, so git -C . checkout trunk first if the working tree has
moved on; add --upload-port /dev/ttyUSB0 if more than one board is attached.
Nothing needs to be uninstalled or undone on the ESP-IDF side, because the two
trees share no toolchain, no build directory and no configuration.
Trap:
espressif32andpioarduinoshare~/.platformio/packages/framework-arduinoespressif32and cannot coexist. If the official platform has been installed since, remove that directory before the rollback build. (The IDF fork sidesteps this entirely — it never touches~/.platformio— butembeded/still lives under PlatformIO.)
- ESP32 development board (tested with Pico32)
- BNO08x IMU sensor (connected via I2C/SPI)
- USB cable for programming and power
- PlatformIO (VS Code extension recommended)
- natKit backend running (see main repository README)
natKit-IMU/
├── embeded/
│ ├── include/ # Header files
│ │ ├── DevConfig.hpp.example # Configuration template
│ │ ├── ConnectionConfig.hpp # Network configuration struct
│ │ ├── ImuReader.hpp # IMU sensor interface
│ │ ├── ImuData.hpp # IMU data structures
│ │ └── ...
│ ├── src/
│ │ └── main.cpp # Main application code
│ └── platformio.ini # PlatformIO configuration
├── firmware-idf/ # the native ESP-IDF fork (see its own README)
└── board/ # Hardware design files
Everything below this line describes embeded/, the PlatformIO firmware.
Copy the example configuration file and update with your local settings:
cd embeded/include
cp DevConfig.hpp.example DevConfig.hppEdit DevConfig.hpp with your WiFi credentials and natKit server address:
#define DEV_WIFI_SSID "your_wifi_ssid"
#define DEV_WIFI_PASSWORD "your_wifi_password"
#define DEV_NATKIT_SERVER_ADDRESS "192.168.1.100" // IP of machine running docker-compose
#define DEV_NATKIT_SERVER_PORT "38082"Note: DevConfig.hpp is gitignored and should never be committed.
Open the project in VS Code with PlatformIO extension, then:
# Build for release
pio run -e release
# Build for debug (with serial output)
pio run -e debug_verbose
# Upload to device
pio run -e release -t upload
# Monitor serial output
pio device monitor| Environment | Description |
|---|---|
release |
Optimized build, minimal serial output |
debug |
Release build with serial disabled |
debug_verbose |
Debug build with full serial output |
When ENABLE_CAPTIVE_PORTAL is not defined, the device uses credentials from DevConfig.hpp and connects automatically.
When built with ENABLE_CAPTIVE_PORTAL defined, the device:
- Creates a WiFi access point named
natKit-ESP32-<unique_id> - Serves a configuration web page at
http://4.3.2.1 - Allows users to enter WiFi credentials and server address via browser
- IMU sensor data is read at configured intervals
- Data is timestamped using NTP-synchronized time
- Data is buffered and sent in bulk via MQTT to the natKit broker
- The natKit bridge forwards MQTT messages to Kafka for storage/processing
Key configuration values are defined at the top of main.cpp:
| Constant | Default | Description |
|---|---|---|
MQTT_BUFFER_SIZE |
16384 | MQTT message buffer size |
MQTT_MAX_CONNECT_RETRIES |
30 | Max MQTT connection attempts |
SEND_MESSAGE_TASK_STACK_SIZE |
18432 | FreeRTOS task stack size |
- Verify WiFi credentials in
DevConfig.hpp - Ensure the WiFi network is 2.4GHz (ESP32 doesn't support 5GHz)
- Check serial output in
debug_verbosemode
- Verify the natKit server is running (
docker-compose up -d) - Check that the server IP address is correct and reachable
- Ensure port 1883 is accessible
- Check I2C/SPI connections
- Verify BNO08x sensor is properly powered
- Check serial output for sensor initialization errors
Default pin assignments (may vary by board):
| Function | Pin |
|---|---|
| Status LED | 2 |
| Debug Pin 1 | 12 |
| Debug Pin 2 | 13 |
| Debug Pin 3 | 27 |
See main natKit repository for license information.