From a701059378a6575a21b198b934b77abedfd71b77 Mon Sep 17 00:00:00 2001 From: nick Date: Fri, 17 Jul 2026 01:46:37 -0600 Subject: [PATCH] firmware: add XIAO C6 sensor node --- docs/CHANGELOG.md | 4 ++ docs/README.md | 1 + docs/SENSOR_NODE_PROTOCOL.md | 17 ++++++++ firmware/xiao-esp32c6-sensor-node/README.md | 15 +++++++ firmware/xiao-esp32c6-sensor-node/gps.cpp | 19 +++++++++ firmware/xiao-esp32c6-sensor-node/gps.h | 6 +++ firmware/xiao-esp32c6-sensor-node/imu.cpp | 39 +++++++++++++++++++ firmware/xiao-esp32c6-sensor-node/imu.h | 5 +++ .../rs485_transport.cpp | 30 ++++++++++++++ .../rs485_transport.h | 6 +++ .../sensor_node_config.h | 20 ++++++++++ .../xiao-esp32c6-sensor-node/sensor_state.h | 21 ++++++++++ .../xiao-esp32c6-sensor-node.ino | 28 +++++++++++++ run_tests.py | 1 + tests/test_xiao_sensor_node_contract.py | 27 +++++++++++++ 15 files changed, 239 insertions(+) create mode 100644 docs/SENSOR_NODE_PROTOCOL.md create mode 100644 firmware/xiao-esp32c6-sensor-node/README.md create mode 100644 firmware/xiao-esp32c6-sensor-node/gps.cpp create mode 100644 firmware/xiao-esp32c6-sensor-node/gps.h create mode 100644 firmware/xiao-esp32c6-sensor-node/imu.cpp create mode 100644 firmware/xiao-esp32c6-sensor-node/imu.h create mode 100644 firmware/xiao-esp32c6-sensor-node/rs485_transport.cpp create mode 100644 firmware/xiao-esp32c6-sensor-node/rs485_transport.h create mode 100644 firmware/xiao-esp32c6-sensor-node/sensor_node_config.h create mode 100644 firmware/xiao-esp32c6-sensor-node/sensor_state.h create mode 100644 firmware/xiao-esp32c6-sensor-node/xiao-esp32c6-sensor-node.ino create mode 100644 tests/test_xiao_sensor_node_contract.py diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 48394ed..16b8ebf 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -2,6 +2,10 @@ ## Unreleased +- Added standalone XIAO ESP32-C6 BNO086/MAX-M10S sensor-node firmware and a versioned RS485 telemetry contract without changing Cargo `/api/v1` or JBD/NimBLE behavior. + +## Unreleased + ### Added - Cargo ESP local OTA firmware upload page at `/ota`. - Cargo ESP OTA API endpoint at `POST /api/v1/system/ota`. diff --git a/docs/README.md b/docs/README.md index 7521e78..96da645 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,5 +53,6 @@ The LILYGO T-Relay-S3 6-way board has been ordered as a future Cargo ESP candida Current development remains on the 2-channel relay board while the API and docs are prepared for output-count-agnostic operation. - `api-v1-audit.md` - Audit of legacy API paths vs `/api/v1` namespace. +- `SENSOR_NODE_PROTOCOL.md` - XIAO ESP32-C6 BNO086/MAX-M10S RS485 telemetry contract. - `waveshare-5b-bringup-checklist.md` - Bench bring-up checklist for the Waveshare ESP32-S3 Touch LCD 5B dashboard. diff --git a/docs/SENSOR_NODE_PROTOCOL.md b/docs/SENSOR_NODE_PROTOCOL.md new file mode 100644 index 0000000..805f9d9 --- /dev/null +++ b/docs/SENSOR_NODE_PROTOCOL.md @@ -0,0 +1,17 @@ +# XIAO ESP32-C6 Sensor Node Protocol + +The XIAO sensor node reads the BNO086 and MAX-M10S and publishes vehicle telemetry over half-duplex RS485 at 115200 8-N-1, one JSON object per line. It owns no control state. Cargo remains the controller, HTTP server, and `/api/v1` source of truth; JBD/NimBLE behavior is unchanged. + +The expansion-board pins are D2 driver enable, D4 TX, and D5 RX. Only physical bus ends should be terminated. Confirm A/B polarity because vendor labels vary. + +## Schema version 1 + +`sensor_status` includes `node_id`, `firmware_version`, `uptime_ms`, and: + +- `imu`: `online`, `valid`, `accuracy`, quaternion, yaw, pitch, and roll in degrees. +- `gps`: `online`, `fix`, satellites, latitude, longitude, altitude meters, speed kph, course degrees, and structured UTC. + +Consumers must check `valid` or `fix` before displaying measurements and ignore unknown fields. The native breakout coordinate frame is reported initially; installation-specific axis remapping should follow physical calibration. + +This link does not replace dashboard-to-Cargo WiFi/HTTP. A future dashboard receiver may display this telemetry without moving relay, BMS, alarm, or configuration authority away from Cargo. + diff --git a/firmware/xiao-esp32c6-sensor-node/README.md b/firmware/xiao-esp32c6-sensor-node/README.md new file mode 100644 index 0000000..bd15350 --- /dev/null +++ b/firmware/xiao-esp32c6-sensor-node/README.md @@ -0,0 +1,15 @@ +# XIAO ESP32-C6 Sensor Node + +This Arduino sketch reads a BNO086 IMU and MAX-M10S GPS and publishes versioned, newline-delimited JSON over the Seeed RS485 expansion board. It produces vehicle telemetry only; Cargo remains the controller and `/api/v1` authority. + +| Function | XIAO pin | +| --- | --- | +| RS485 DE / TX / RX | D2 / D4 / D5 | +| BNO086 SDA / SCL | D7 / D8 | +| GPS TX to XIAO RX | D9 | +| GPS RX from XIAO TX | D10 | + +Power both sensors from 3.3V with shared ground. Required Arduino libraries are Adafruit BNO08x, TinyGPSPlus, and ArduinoJson 6.x. Enable **USB CDC On Boot** for diagnostics. GPS uses hardware UART0 and RS485 uses hardware UART1. + +The node sends `sensor_status` at 115200 baud every 200 ms. See `docs/SENSOR_NODE_PROTOCOL.md`. The initial firmware is transmit-only; add a dashboard receiver after bench-testing the node, RS485 polarity, and sensor axes. + diff --git a/firmware/xiao-esp32c6-sensor-node/gps.cpp b/firmware/xiao-esp32c6-sensor-node/gps.cpp new file mode 100644 index 0000000..6f2d718 --- /dev/null +++ b/firmware/xiao-esp32c6-sensor-node/gps.cpp @@ -0,0 +1,19 @@ +#include +#include "gps.h" +#include "sensor_node_config.h" +namespace { TinyGPSPlus parser; } + +void initGps(HardwareSerial& serial) { serial.begin(GPS_BAUD, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); } +void updateGps(HardwareSerial& serial, GpsState& s) { + while (serial.available()) { s.lastByteMs = millis(); parser.encode(serial.read()); } + s.online = s.lastByteMs && millis() - s.lastByteMs <= SENSOR_STALE_MS; + s.fix = parser.location.isValid() && parser.location.age() <= SENSOR_STALE_MS; + s.satellites = parser.satellites.isValid() ? parser.satellites.value() : 0; + if (parser.location.isValid()) { s.latitude = parser.location.lat(); s.longitude = parser.location.lng(); } + if (parser.altitude.isValid()) s.altitudeMeters = parser.altitude.meters(); + if (parser.speed.isValid()) s.speedKph = parser.speed.kmph(); + if (parser.course.isValid()) s.courseDegrees = parser.course.deg(); + if (parser.date.isValid()) { s.year = parser.date.year(); s.month = parser.date.month(); s.day = parser.date.day(); } + if (parser.time.isValid()) { s.hour = parser.time.hour(); s.minute = parser.time.minute(); s.second = parser.time.second(); } +} + diff --git a/firmware/xiao-esp32c6-sensor-node/gps.h b/firmware/xiao-esp32c6-sensor-node/gps.h new file mode 100644 index 0000000..be39766 --- /dev/null +++ b/firmware/xiao-esp32c6-sensor-node/gps.h @@ -0,0 +1,6 @@ +#pragma once +#include +#include "sensor_state.h" +void initGps(HardwareSerial& serial); +void updateGps(HardwareSerial& serial, GpsState& state); + diff --git a/firmware/xiao-esp32c6-sensor-node/imu.cpp b/firmware/xiao-esp32c6-sensor-node/imu.cpp new file mode 100644 index 0000000..66f8819 --- /dev/null +++ b/firmware/xiao-esp32c6-sensor-node/imu.cpp @@ -0,0 +1,39 @@ +#include +#include +#include +#include "imu.h" +#include "sensor_node_config.h" + +namespace { +Adafruit_BNO08x bno086(-1); +sh2_SensorValue_t value; +bool initialized = false; +float degrees(float r) { return r * 180.0f / PI; } +void updateEuler(ImuState& s) { + float sinr = 2 * (s.real * s.i + s.j * s.k); + float cosr = 1 - 2 * (s.i * s.i + s.j * s.j); + s.rollDegrees = degrees(atan2f(sinr, cosr)); + float sinp = 2 * (s.real * s.j - s.k * s.i); + s.pitchDegrees = degrees(fabsf(sinp) >= 1 ? copysignf(PI / 2, sinp) : asinf(sinp)); + float siny = 2 * (s.real * s.k + s.i * s.j); + float cosy = 1 - 2 * (s.j * s.j + s.k * s.k); + s.yawDegrees = degrees(atan2f(siny, cosy)); +} +} + +bool initImu() { + Wire.begin(BNO086_SDA_PIN, BNO086_SCL_PIN); + initialized = bno086.begin_I2C(BNO086_I2C_ADDRESS, &Wire); + if (initialized) initialized = bno086.enableReport(SH2_ROTATION_VECTOR, IMU_REPORT_INTERVAL_US); + return initialized; +} + +void updateImu(ImuState& s) { + s.online = initialized; + if (!initialized || !bno086.getSensorEvent(&value) || value.sensorId != SH2_ROTATION_VECTOR) return; + s.i = value.un.rotationVector.i; s.j = value.un.rotationVector.j; + s.k = value.un.rotationVector.k; s.real = value.un.rotationVector.real; + s.accuracy = value.status; s.valid = true; s.lastUpdateMs = millis(); + updateEuler(s); +} + diff --git a/firmware/xiao-esp32c6-sensor-node/imu.h b/firmware/xiao-esp32c6-sensor-node/imu.h new file mode 100644 index 0000000..22e97fd --- /dev/null +++ b/firmware/xiao-esp32c6-sensor-node/imu.h @@ -0,0 +1,5 @@ +#pragma once +#include "sensor_state.h" +bool initImu(); +void updateImu(ImuState& state); + diff --git a/firmware/xiao-esp32c6-sensor-node/rs485_transport.cpp b/firmware/xiao-esp32c6-sensor-node/rs485_transport.cpp new file mode 100644 index 0000000..a13b223 --- /dev/null +++ b/firmware/xiao-esp32c6-sensor-node/rs485_transport.cpp @@ -0,0 +1,30 @@ +#include +#include "rs485_transport.h" +#include "sensor_node_config.h" + +void initRs485(HardwareSerial& serial) { + pinMode(RS485_DE_PIN, OUTPUT); digitalWrite(RS485_DE_PIN, LOW); + serial.begin(RS485_BAUD, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN); +} + +void publishSensorStatus(HardwareSerial& serial, const ImuState& imu, const GpsState& gps) { + StaticJsonDocument<1024> doc; + doc["type"] = "sensor_status"; doc["schema_version"] = SENSOR_NODE_SCHEMA_VERSION; + doc["node_id"] = SENSOR_NODE_NAME; doc["firmware_version"] = SENSOR_NODE_FIRMWARE_VERSION; + doc["uptime_ms"] = millis(); + JsonObject i = doc.createNestedObject("imu"); + i["online"] = imu.online; i["valid"] = imu.valid && millis() - imu.lastUpdateMs <= SENSOR_STALE_MS; + i["accuracy"] = imu.accuracy; + JsonObject q = i.createNestedObject("quaternion"); q["i"] = imu.i; q["j"] = imu.j; q["k"] = imu.k; q["real"] = imu.real; + i["yaw_degrees"] = imu.yawDegrees; i["pitch_degrees"] = imu.pitchDegrees; i["roll_degrees"] = imu.rollDegrees; + JsonObject g = doc.createNestedObject("gps"); + g["online"] = gps.online; g["fix"] = gps.fix; g["satellites"] = gps.satellites; + g["latitude"] = gps.latitude; g["longitude"] = gps.longitude; g["altitude_meters"] = gps.altitudeMeters; + g["speed_kph"] = gps.speedKph; g["course_degrees"] = gps.courseDegrees; + JsonObject utc = g.createNestedObject("utc"); utc["year"] = gps.year; utc["month"] = gps.month; utc["day"] = gps.day; + utc["hour"] = gps.hour; utc["minute"] = gps.minute; utc["second"] = gps.second; + digitalWrite(RS485_DE_PIN, HIGH); delayMicroseconds(20); + serializeJson(doc, serial); serial.write('\n'); serial.flush(); + digitalWrite(RS485_DE_PIN, LOW); +} + diff --git a/firmware/xiao-esp32c6-sensor-node/rs485_transport.h b/firmware/xiao-esp32c6-sensor-node/rs485_transport.h new file mode 100644 index 0000000..7bbb2b1 --- /dev/null +++ b/firmware/xiao-esp32c6-sensor-node/rs485_transport.h @@ -0,0 +1,6 @@ +#pragma once +#include +#include "sensor_state.h" +void initRs485(HardwareSerial& serial); +void publishSensorStatus(HardwareSerial& serial, const ImuState& imu, const GpsState& gps); + diff --git a/firmware/xiao-esp32c6-sensor-node/sensor_node_config.h b/firmware/xiao-esp32c6-sensor-node/sensor_node_config.h new file mode 100644 index 0000000..716f125 --- /dev/null +++ b/firmware/xiao-esp32c6-sensor-node/sensor_node_config.h @@ -0,0 +1,20 @@ +#pragma once +#include + +#define SENSOR_NODE_NAME "xiao_c6_sensor_node" +#define SENSOR_NODE_FIRMWARE_VERSION "0.1.0" +#define SENSOR_NODE_SCHEMA_VERSION 1 +#define RS485_DE_PIN D2 +#define RS485_TX_PIN D4 +#define RS485_RX_PIN D5 +#define BNO086_SDA_PIN D7 +#define BNO086_SCL_PIN D8 +#define GPS_RX_PIN D9 +#define GPS_TX_PIN D10 +#define BNO086_I2C_ADDRESS 0x4A +#define GPS_BAUD 9600 +#define RS485_BAUD 115200 +#define IMU_REPORT_INTERVAL_US 20000 +#define TELEMETRY_INTERVAL_MS 200 +#define SENSOR_STALE_MS 2000 + diff --git a/firmware/xiao-esp32c6-sensor-node/sensor_state.h b/firmware/xiao-esp32c6-sensor-node/sensor_state.h new file mode 100644 index 0000000..eb3bd56 --- /dev/null +++ b/firmware/xiao-esp32c6-sensor-node/sensor_state.h @@ -0,0 +1,21 @@ +#pragma once +#include + +struct ImuState { + bool online = false, valid = false; + uint8_t accuracy = 0; + float i = 0, j = 0, k = 0, real = 1; + float yawDegrees = 0, pitchDegrees = 0, rollDegrees = 0; + unsigned long lastUpdateMs = 0; +}; + +struct GpsState { + bool online = false, fix = false; + uint32_t satellites = 0; + double latitude = 0, longitude = 0, altitudeMeters = 0; + double speedKph = 0, courseDegrees = 0; + uint16_t year = 0; + uint8_t month = 0, day = 0, hour = 0, minute = 0, second = 0; + unsigned long lastByteMs = 0; +}; + diff --git a/firmware/xiao-esp32c6-sensor-node/xiao-esp32c6-sensor-node.ino b/firmware/xiao-esp32c6-sensor-node/xiao-esp32c6-sensor-node.ino new file mode 100644 index 0000000..74910d8 --- /dev/null +++ b/firmware/xiao-esp32c6-sensor-node/xiao-esp32c6-sensor-node.ino @@ -0,0 +1,28 @@ +#include +#include "gps.h" +#include "imu.h" +#include "rs485_transport.h" +#include "sensor_node_config.h" + +HardwareSerial gpsSerial(0), rs485Serial(1); +ImuState imuState; +GpsState gpsState; +unsigned long lastTelemetryMs = 0; + +void setup() { + Serial.begin(115200); delay(250); + Serial.println("XIAO ESP32-C6 sensor node booting"); + initGps(gpsSerial); initRs485(rs485Serial); + imuState.online = initImu(); + Serial.println(imuState.online ? "BNO086 ready" : "BNO086 not detected"); +} + +void loop() { + updateGps(gpsSerial, gpsState); updateImu(imuState); + unsigned long now = millis(); + if (now - lastTelemetryMs >= TELEMETRY_INTERVAL_MS) { + publishSensorStatus(rs485Serial, imuState, gpsState); lastTelemetryMs = now; + } + delay(1); +} + diff --git a/run_tests.py b/run_tests.py index d221df2..4f07906 100644 --- a/run_tests.py +++ b/run_tests.py @@ -4,6 +4,7 @@ import sys COMMANDS = [ [sys.executable, "-m", "pytest", "tests/test_http_api_contract.py"], + [sys.executable, "-m", "pytest", "tests/test_xiao_sensor_node_contract.py"], ] diff --git a/tests/test_xiao_sensor_node_contract.py b/tests/test_xiao_sensor_node_contract.py new file mode 100644 index 0000000..671eb32 --- /dev/null +++ b/tests/test_xiao_sensor_node_contract.py @@ -0,0 +1,27 @@ +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +NODE = ROOT / "firmware" / "xiao-esp32c6-sensor-node" +def source(name): return (NODE / name).read_text() + +def test_wiring_matches_assembly(): + config = source("sensor_node_config.h") + expected = {"RS485_DE_PIN":"D2", "RS485_TX_PIN":"D4", "RS485_RX_PIN":"D5", + "BNO086_SDA_PIN":"D7", "BNO086_SCL_PIN":"D8", "GPS_RX_PIN":"D9", "GPS_TX_PIN":"D10"} + for name, pin in expected.items(): assert re.search(rf"#define\s+{name}\s+{pin}\b", config) + +def test_transport_contract(): + text = source("rs485_transport.cpp") + for token in ['doc["type"] = "sensor_status"', 'doc["schema_version"]', 'createNestedObject("imu")', 'createNestedObject("gps")', "serial.write('\\n')", "serial.flush()"]: + assert token in text + +def test_loop_is_non_blocking(): + loop = source("xiao-esp32c6-sensor-node.ino").split("void loop()", 1)[1] + assert "updateGps" in loop and "updateImu" in loop and "TELEMETRY_INTERVAL_MS" in loop + assert not re.search(r"delay\((?:[2-9]|[1-9]\d+)\)", loop) + +def test_node_does_not_take_cargo_or_ble_authority(): + combined = "\n".join(p.read_text() for p in NODE.glob("*.*")) + assert "NimBLE" not in combined and "JBD" not in combined and "server.on(" not in combined +