firmware: harden XIAO RS485 telemetry
This commit is contained in:
@@ -9,6 +9,7 @@
|
|||||||
- Replaced sensor auto-detection with the verified installation settings: BNO086 address `0x4B` and MAX-M10S UART at 38400 baud.
|
- Replaced sensor auto-detection with the verified installation settings: BNO086 address `0x4B` and MAX-M10S UART at 38400 baud.
|
||||||
- Added fixed-address BNO086 power-up retries, runtime recovery, and report restoration after sensor resets.
|
- Added fixed-address BNO086 power-up retries, runtime recovery, and report restoration after sensor resets.
|
||||||
- Expanded telemetry with BNO086 motion/gravity/stability reports and GPS HDOP/data ages.
|
- Expanded telemetry with BNO086 motion/gravity/stability reports and GPS HDOP/data ages.
|
||||||
|
- Hardened RS485 transmission with local-echo draining, frame-size guards, a larger RX buffer, and boot/frame sequence identifiers.
|
||||||
|
|
||||||
## Unreleased
|
## Unreleased
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
# XIAO ESP32-C6 Sensor Node Protocol
|
# 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 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 at 4 Hz. 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.
|
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
|
## Schema version 1
|
||||||
|
|
||||||
`sensor_status` includes `node_id`, `firmware_version`, `uptime_ms`, and:
|
`sensor_status` includes `node_id`, `firmware_version`, `boot_id`, monotonically increasing `frame_sequence`, `uptime_ms`, and:
|
||||||
|
|
||||||
- `imu`: rotation data plus linear acceleration, angular velocity, gravity, stability classification, and per-report accuracy/age.
|
- `imu`: rotation data plus linear acceleration, angular velocity, gravity, stability classification, and per-report accuracy/age.
|
||||||
- `gps`: existing navigation data plus HDOP and location/time ages.
|
- `gps`: existing navigation data plus HDOP and location/time ages.
|
||||||
@@ -15,6 +15,17 @@ The expansion-board pins are D2 driver enable, D4 TX, and D5 RX. Only physical b
|
|||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
|
The receiver should parse only newline-complete JSON objects. A changed `boot_id` indicates a node restart. A gap in `frame_sequence` indicates one or more dropped/corrupt frames. The node refuses to transmit an overflowed frame or one larger than 1900 bytes. At 115200 baud and 4 Hz, the maximum configured frame size consumes about 66% of the bus, leaving receive windows for commands and responses.
|
||||||
|
|
||||||
|
## RS485 bench check
|
||||||
|
|
||||||
|
1. Connect XIAO A to receiver A, B to B, and share ground. If no valid frames appear, swap A/B once because vendor labels vary.
|
||||||
|
2. Use 115200 baud, 8-N-1, and read through newline.
|
||||||
|
3. Confirm every received line parses as JSON with `type: sensor_status`.
|
||||||
|
4. Confirm `boot_id` stays constant, `frame_sequence` increments by one, and sensor ages remain low.
|
||||||
|
5. Disconnect/reconnect one bus conductor and verify the receiver detects sequence gaps rather than accepting partial JSON.
|
||||||
|
6. Terminate only the two physical ends of a long installed bus; a short bench cable normally does not need termination.
|
||||||
|
|
||||||
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.
|
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.
|
||||||
|
|
||||||
## Level calibration commands
|
## Level calibration commands
|
||||||
|
|||||||
@@ -14,6 +14,6 @@ Power both sensors from 3.3V with shared ground. Required Arduino libraries are
|
|||||||
The BNO086 uses the verified `0x4B` I2C address on D7/D8. If initialization fails, verify 3.3V/GND, SDA/SCL wiring, and the breakout's PS0/PS1 configuration.
|
The BNO086 uses the verified `0x4B` I2C address on D7/D8. If initialization fails, verify 3.3V/GND, SDA/SCL wiring, and the breakout's PS0/PS1 configuration.
|
||||||
Firmware retries the fixed address during power-up and periodically retries afterward, so a slow BNO086 power-up does not require cycling the XIAO. If the BNO086 resets at runtime, rotation-vector reporting is enabled again automatically.
|
Firmware retries the fixed address during power-up and periodically retries afterward, so a slow BNO086 power-up does not require cycling the XIAO. If the BNO086 resets at runtime, rotation-vector reporting is enabled again automatically.
|
||||||
|
|
||||||
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.
|
The node sends `sensor_status` at 115200 baud every 250 ms. See `docs/SENSOR_NODE_PROTOCOL.md`. It also listens between frames for the documented level-calibration commands. Add a dashboard receiver after bench-testing the node, RS485 polarity, and sensor axes.
|
||||||
|
|
||||||
During bring-up, `USB_TELEMETRY_ENABLED` mirrors each RS485 JSON frame to the Arduino Serial Monitor at 115200 baud. Set it to `0` in `sensor_node_config.h` when continuous USB output is no longer wanted.
|
During bring-up, `USB_TELEMETRY_ENABLED` mirrors each RS485 JSON frame to the Arduino Serial Monitor at 115200 baud. Set it to `0` in `sensor_node_config.h` when continuous USB output is no longer wanted.
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
#include <ArduinoJson.h>
|
#include <ArduinoJson.h>
|
||||||
|
#include <esp_system.h>
|
||||||
#include "rs485_transport.h"
|
#include "rs485_transport.h"
|
||||||
#include "sensor_node_config.h"
|
#include "sensor_node_config.h"
|
||||||
#include "calibration.h"
|
#include "calibration.h"
|
||||||
@@ -6,9 +7,28 @@
|
|||||||
namespace {
|
namespace {
|
||||||
String commandBuffer;
|
String commandBuffer;
|
||||||
bool discardCommandUntilNewline = false;
|
bool discardCommandUntilNewline = false;
|
||||||
|
uint32_t bootId = 0;
|
||||||
|
uint32_t frameSequence = 0;
|
||||||
|
|
||||||
|
void drainTransmitEcho(HardwareSerial& serial) {
|
||||||
|
while (serial.available()) serial.read();
|
||||||
|
}
|
||||||
|
|
||||||
void sendJson(HardwareSerial& serial, JsonDocument& doc) {
|
void sendJson(HardwareSerial& serial, JsonDocument& doc) {
|
||||||
digitalWrite(RS485_DE_PIN, HIGH); delayMicroseconds(20);
|
if (doc.overflowed() || measureJson(doc) + 1 > RS485_MAX_FRAME_BYTES) {
|
||||||
|
#if USB_TELEMETRY_ENABLED
|
||||||
|
Serial.println("{\"type\":\"rs485_error\",\"error\":\"frame_too_large\"}");
|
||||||
|
#endif
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
digitalWrite(RS485_DE_PIN, HIGH);
|
||||||
|
delayMicroseconds(RS485_DRIVER_ENABLE_SETTLE_US);
|
||||||
serializeJson(doc, serial); serial.write('\n'); serial.flush();
|
serializeJson(doc, serial); serial.write('\n'); serial.flush();
|
||||||
|
// This expansion board can echo TX into RX. At flush completion the
|
||||||
|
// entire transmitted line has left the UART, so discard that local echo
|
||||||
|
// before returning the transceiver to receive mode.
|
||||||
|
drainTransmitEcho(serial);
|
||||||
digitalWrite(RS485_DE_PIN, LOW);
|
digitalWrite(RS485_DE_PIN, LOW);
|
||||||
#if USB_TELEMETRY_ENABLED
|
#if USB_TELEMETRY_ENABLED
|
||||||
serializeJson(doc, Serial); Serial.println();
|
serializeJson(doc, Serial); Serial.println();
|
||||||
@@ -18,13 +38,17 @@ void sendJson(HardwareSerial& serial, JsonDocument& doc) {
|
|||||||
|
|
||||||
void initRs485(HardwareSerial& serial) {
|
void initRs485(HardwareSerial& serial) {
|
||||||
pinMode(RS485_DE_PIN, OUTPUT); digitalWrite(RS485_DE_PIN, LOW);
|
pinMode(RS485_DE_PIN, OUTPUT); digitalWrite(RS485_DE_PIN, LOW);
|
||||||
|
serial.setRxBufferSize(RS485_RX_BUFFER_BYTES);
|
||||||
serial.begin(RS485_BAUD, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN);
|
serial.begin(RS485_BAUD, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN);
|
||||||
|
bootId = esp_random();
|
||||||
}
|
}
|
||||||
|
|
||||||
void publishSensorStatus(HardwareSerial& serial, const ImuState& imu, const GpsState& gps) {
|
void publishSensorStatus(HardwareSerial& serial, const ImuState& imu, const GpsState& gps) {
|
||||||
StaticJsonDocument<2048> doc;
|
StaticJsonDocument<2048> doc;
|
||||||
doc["type"] = "sensor_status"; doc["schema_version"] = SENSOR_NODE_SCHEMA_VERSION;
|
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["node_id"] = SENSOR_NODE_NAME; doc["firmware_version"] = SENSOR_NODE_FIRMWARE_VERSION;
|
||||||
|
doc["boot_id"] = bootId;
|
||||||
|
doc["frame_sequence"] = frameSequence++;
|
||||||
doc["uptime_ms"] = millis();
|
doc["uptime_ms"] = millis();
|
||||||
JsonObject i = doc.createNestedObject("imu");
|
JsonObject i = doc.createNestedObject("imu");
|
||||||
i["online"] = imu.online; i["valid"] = imu.valid && millis() - imu.lastUpdateMs <= SENSOR_STALE_MS;
|
i["online"] = imu.online; i["valid"] = imu.valid && millis() - imu.lastUpdateMs <= SENSOR_STALE_MS;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
#include <Arduino.h>
|
#include <Arduino.h>
|
||||||
|
|
||||||
#define SENSOR_NODE_NAME "xiao_c6_sensor_node"
|
#define SENSOR_NODE_NAME "xiao_c6_sensor_node"
|
||||||
#define SENSOR_NODE_FIRMWARE_VERSION "0.1.0"
|
#define SENSOR_NODE_FIRMWARE_VERSION "0.2.0"
|
||||||
#define SENSOR_NODE_SCHEMA_VERSION 1
|
#define SENSOR_NODE_SCHEMA_VERSION 1
|
||||||
#define RS485_DE_PIN D2
|
#define RS485_DE_PIN D2
|
||||||
#define RS485_TX_PIN D4
|
#define RS485_TX_PIN D4
|
||||||
@@ -18,9 +18,12 @@
|
|||||||
#define BNO086_RUNTIME_RETRY_MS 5000
|
#define BNO086_RUNTIME_RETRY_MS 5000
|
||||||
#define GPS_BAUD 38400
|
#define GPS_BAUD 38400
|
||||||
#define RS485_BAUD 115200
|
#define RS485_BAUD 115200
|
||||||
|
#define RS485_RX_BUFFER_BYTES 512
|
||||||
|
#define RS485_MAX_FRAME_BYTES 1900
|
||||||
|
#define RS485_DRIVER_ENABLE_SETTLE_US 20
|
||||||
#define IMU_REPORT_INTERVAL_US 20000
|
#define IMU_REPORT_INTERVAL_US 20000
|
||||||
#define IMU_STABILITY_INTERVAL_US 100000
|
#define IMU_STABILITY_INTERVAL_US 100000
|
||||||
#define TELEMETRY_INTERVAL_MS 200
|
#define TELEMETRY_INTERVAL_MS 250
|
||||||
#define SENSOR_STALE_MS 2000
|
#define SENSOR_STALE_MS 2000
|
||||||
#define USB_TELEMETRY_ENABLED 1
|
#define USB_TELEMETRY_ENABLED 1
|
||||||
#define LEVEL_CALIBRATION_DURATION_MS 5000
|
#define LEVEL_CALIBRATION_DURATION_MS 5000
|
||||||
|
|||||||
@@ -19,6 +19,9 @@ def test_transport_contract():
|
|||||||
assert "serializeJson(doc, Serial)" in text
|
assert "serializeJson(doc, Serial)" in text
|
||||||
assert 'createNestedObject("nmea")' in text
|
assert 'createNestedObject("nmea")' in text
|
||||||
assert 'nmea["sentences_passed"]' in text
|
assert 'nmea["sentences_passed"]' in text
|
||||||
|
assert 'doc["boot_id"]' in text
|
||||||
|
assert 'doc["frame_sequence"]' in text
|
||||||
|
assert "measureJson(doc)" in text
|
||||||
for field in ["linear_acceleration_mps2", "angular_velocity_rads", "gravity_mps2", "stability", "hdop", "location_age_ms"]:
|
for field in ["linear_acceleration_mps2", "angular_velocity_rads", "gravity_mps2", "stability", "hdop", "location_age_ms"]:
|
||||||
assert field in text
|
assert field in text
|
||||||
|
|
||||||
@@ -67,6 +70,8 @@ def test_rs485_echo_cannot_starve_sensor_loop():
|
|||||||
assert "discardCommandUntilNewline" in transport
|
assert "discardCommandUntilNewline" in transport
|
||||||
assert 'type == "sensor_status"' in transport
|
assert 'type == "sensor_status"' in transport
|
||||||
assert 'publishCalibrationResult(serial, "invalid_json")' not in transport
|
assert 'publishCalibrationResult(serial, "invalid_json")' not in transport
|
||||||
|
assert "drainTransmitEcho(serial)" in transport
|
||||||
|
assert transport.index("drainTransmitEcho(serial)") < transport.index("digitalWrite(RS485_DE_PIN, LOW);", transport.index("void sendJson"))
|
||||||
|
|
||||||
def test_node_does_not_take_cargo_or_ble_authority():
|
def test_node_does_not_take_cargo_or_ble_authority():
|
||||||
combined = "\n".join(p.read_text() for p in NODE.glob("*.*"))
|
combined = "\n".join(p.read_text() for p in NODE.glob("*.*"))
|
||||||
|
|||||||
Reference in New Issue
Block a user