Compare commits
20
Commits
22a6d77be8
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ab5424d82e | ||
|
|
753d13157e | ||
|
|
fea549d1e2 | ||
|
|
f145730b14 | ||
|
|
35c0721b47 | ||
|
|
8c2375d402 | ||
|
|
ed9611310e | ||
|
|
6d61078484 | ||
|
|
b1a71e7bd1 | ||
|
|
dc7a7223fb | ||
|
|
d5578ce796 | ||
|
|
75845a18e3 | ||
|
|
4f96902068 | ||
|
|
fb2ca464b9 | ||
|
|
1bf4530517 | ||
|
|
07559da6c0 | ||
|
|
a1a2f399d1 | ||
|
|
1ada42b9fe | ||
|
|
4f5146eb82 | ||
|
|
a701059378 |
@@ -2,6 +2,19 @@
|
||||
|
||||
## 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.
|
||||
- Added XIAO-side persistent level calibration with stability checking and RS485 start/clear commands; no dashboard behavior changed.
|
||||
- Prevented echoed or oversized RS485 frames from starving the XIAO sensor loop.
|
||||
- Silently discard malformed partial RS485 echoes instead of emitting recursive `invalid_json` responses.
|
||||
- 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.
|
||||
- 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.
|
||||
- Added dashboard reception of XIAO sensor-node RS485 telemetry and a basic diagnostics-overlay view without changing Cargo `/api/v1` behavior.
|
||||
- Expanded dashboard sensor-node diagnostics with yaw, GPS altitude, and UTC.
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- Cargo ESP local OTA firmware upload page at `/ota`.
|
||||
- Cargo ESP OTA API endpoint at `POST /api/v1/system/ota`.
|
||||
|
||||
@@ -240,6 +240,17 @@ Important guardrails:
|
||||
|
||||
The dashboard enclosure will include an M9N GPS module connected to the ESP32-S3 dashboard over UART. It will provide GPS fix status, satellite count, position, speed, and UTC time. Future work will use GPS time/location for automatic timezone and DST handling.
|
||||
|
||||
## XIAO sensor-node RS485 link
|
||||
|
||||
The dashboard uses the Waveshare ESP32-S3-Touch-LCD-5B onboard RS485 receiver for the XIAO BNO086/MAX-M10S node:
|
||||
|
||||
| Function | Dashboard GPIO |
|
||||
| --- | --- |
|
||||
| RS485 RX | GPIO43 |
|
||||
| RS485 TX | GPIO44 |
|
||||
|
||||
The onboard transceiver handles direction automatically. Configure 115200 baud, 8-N-1, connect A-to-A, B-to-B, and share ground. Keep **USB CDC On Boot** enabled so USB diagnostics do not claim the RS485 UART pins.
|
||||
|
||||
|
||||
### LilyGO T-Relay-S3 6-relay cargo controller
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
# 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 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. This matches Seeed's working example call `begin(115200, SERIAL_8N1, 7, 6)`: on XIAO, GPIO7 is D5/RX and GPIO6 is D4/TX. 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`, `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.
|
||||
- `gps`: existing navigation data plus HDOP and location/time ages.
|
||||
|
||||
`online` only means UART bytes arrived recently. In `gps.nmea`, a rising `sentences_passed` count proves valid NMEA at the configured baud rate. A rising character count with no passed sentences indicates wrong-baud noise or malformed data. A consistently rising failed count indicates corrupt UART data or a baud mismatch.
|
||||
|
||||
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.
|
||||
|
||||
The Waveshare dashboard receiver uses GPIO43 RX and GPIO44 TX through its onboard automatic-direction RS485 transceiver. Its diagnostics overlay displays the initial parsed sensor model and transport counters; this link is separate from Cargo WiFi/HTTP control.
|
||||
|
||||
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
|
||||
|
||||
The receiver may send `{"type":"calibrate_level"}` followed by a newline. The node immediately responds with `calibration_response` result `started`, then samples the IMU for five seconds. If pitch or roll moves by more than two degrees, it responds with `vehicle_moved` and retains the previous calibration. On success it saves averaged pitch and roll offsets in NVS and responds with `complete`.
|
||||
|
||||
Send `{"type":"clear_level_calibration"}` to erase the saved offsets. Telemetry retains native `imu.pitch_degrees` and `imu.roll_degrees` and adds `imu.level_calibration` containing status, offsets, and calibrated pitch/roll. Level calibration intentionally does not alter yaw.
|
||||
@@ -303,6 +303,8 @@ The dashboard enclosure will include an M9N GPS module connected to the ESP32-S3
|
||||
|
||||
The dashboard has a hidden diagnostics overlay. Tap the top status card five times within roughly three seconds to open it. Double-tap the diagnostics overlay to return to the main dashboard.
|
||||
|
||||
The overlay also provides the initial XIAO sensor-node bring-up view. The dashboard receives newline-delimited JSON through its onboard automatic-direction RS485 transceiver on GPIO43 RX / GPIO44 TX at 115200 baud. It shows link age, received/dropped/error counts, IMU pitch/roll/yaw/accuracy/stability, and GPS fix/satellites/HDOP/location/altitude/speed/UTC. Cargo communication remains WiFi/HTTP through `/api/v1`.
|
||||
|
||||
The diagnostics overlay shows dashboard version, uptime, heap, WiFi RSSI/IP, Cargo API status, Cargo firmware version, Cargo uptime, Cargo heap, Cargo AP client count, BMS status, relay count, and basic control-loop state.
|
||||
|
||||
|
||||
|
||||
@@ -75,3 +75,46 @@ The dashboard uses field-filtered `/api/v1/status` calls:
|
||||
- Slow poll: `/api/v1/status?fields=network,system,config`
|
||||
|
||||
This keeps the endpoint model simple while reducing payload size, JSON parsing, and UI churn.
|
||||
|
||||
## XIAO sensor-node RS485 diagnostics
|
||||
|
||||
The dashboard listens to the onboard RS485 receiver at 115200 baud using GPIO43 RX and GPIO44 TX. The Waveshare transceiver switches direction automatically, so no DE GPIO is required. The receiver accepts newline-delimited schema-v1 `sensor_status` JSON from the XIAO node and does not change Cargo HTTP/control behavior.
|
||||
|
||||
Open the hidden diagnostics overlay to view link state, frame sequence/gaps/errors, IMU accuracy/stability/pitch/roll/yaw, and GPS fix/satellites/HDOP/location/altitude/speed/UTC. This is a temporary bring-up view ahead of a dedicated off-road page.
|
||||
|
||||
Arduino **USB CDC On Boot** must remain enabled so dashboard debug `Serial` output uses native USB rather than competing with the GPIO43/GPIO44 RS485 UART.
|
||||
|
||||
## Passive CAN Logger
|
||||
|
||||
The dashboard runs its 500 kbps TWAI controller in listen-only mode and never
|
||||
transmits onto the vehicle bus. At 115200 baud it prints changed frames as:
|
||||
|
||||
CAN,ms,id,format,dlc,b0,b1,b2,b3,b4,b5,b6,b7
|
||||
|
||||
During a cold-start warm-up, enter the scan-tool coolant reading periodically:
|
||||
|
||||
TEMP,68
|
||||
TEMP,95
|
||||
TEMP,140
|
||||
|
||||
Generic timestamped markers can be added with `MARK,<label>`. For a stationary
|
||||
4WD capture, start in 2H and enter each marker only after the corresponding
|
||||
dashboard indicator has settled:
|
||||
|
||||
MARK,2H
|
||||
MARK,4H
|
||||
MARK,4LO
|
||||
MARK,4H
|
||||
MARK,2H
|
||||
|
||||
Wait 10-15 seconds between mode changes. Follow the vehicle owner's manual for
|
||||
the conditions required to select 4LO, keep the vehicle stationary where
|
||||
required, and save the complete serial output. The repeated return transitions
|
||||
help distinguish an actual mode signal from unrelated counters and timers.
|
||||
|
||||
Save the serial output, then rank CAN ID/byte candidates with:
|
||||
|
||||
python tools/analyze_can_coolant.py can-warmup.log
|
||||
|
||||
Verify strong candidates over multiple warm-up captures before using one for
|
||||
the dashboard gauge.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
#pragma once
|
||||
|
||||
static const char *DASHBOARD_VERSION = "0.1.127-first-boot-wifi";
|
||||
static const char *DASHBOARD_VERSION = "0.1.132-rs485-raw-diagnostics";
|
||||
|
||||
// First-boot defaults. Credentials are stored in Preferences/NVS after setup.
|
||||
static const char *DASHBOARD_DEFAULT_CARGO_WIFI_SSID = "OverlandController";
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
extern const lv_img_dsc_t gas_station;
|
||||
extern const lv_img_dsc_t thermometer_water;
|
||||
#include "dashboard_status_model.h"
|
||||
#include "sensor_node_receiver.h"
|
||||
|
||||
using namespace esp_panel::drivers;
|
||||
using namespace esp_panel::board;
|
||||
@@ -40,6 +41,20 @@ static constexpr uint32_t OBD_REQUEST_ID = 0x7DF;
|
||||
static constexpr uint32_t OBD_RESPONSE_ID_MIN = 0x7E8;
|
||||
static constexpr uint32_t OBD_RESPONSE_ID_MAX = 0x7EF;
|
||||
static constexpr uint8_t OBD_PID_COOLANT_TEMP = 0x05;
|
||||
static constexpr size_t CAN_TRACKED_ID_COUNT = 96;
|
||||
static constexpr unsigned long CAN_LOG_MIN_INTERVAL_MS = 100;
|
||||
|
||||
struct CanTrackedFrame {
|
||||
bool used = false;
|
||||
bool extended = false;
|
||||
uint32_t identifier = 0;
|
||||
uint8_t dlc = 0;
|
||||
uint8_t data[8] = {};
|
||||
unsigned long last_log_ms = 0;
|
||||
};
|
||||
|
||||
static CanTrackedFrame can_tracked_frames[CAN_TRACKED_ID_COUNT];
|
||||
static String can_serial_command;
|
||||
|
||||
static bool obd_can_ready = false;
|
||||
static bool obd_can_started = false;
|
||||
@@ -47,7 +62,13 @@ static unsigned long last_obd_poll_ms = 0;
|
||||
static unsigned long last_obd_rx_ms = 0;
|
||||
static unsigned long last_obd_tx_ms = 0;
|
||||
static uint32_t obd_tx_count = 0;
|
||||
static uint32_t obd_tx_fail_count = 0;
|
||||
static uint32_t obd_rx_count = 0;
|
||||
static uint32_t obd_raw_rx_count = 0;
|
||||
static uint32_t obd_last_rx_identifier = 0;
|
||||
static uint8_t obd_last_rx_dlc = 0;
|
||||
static bool obd_last_rx_extended = false;
|
||||
static unsigned long last_obd_raw_rx_ms = 0;
|
||||
static int obd_coolant_temp_f = -999;
|
||||
static int last_rendered_obd_coolant_temp_f = -1000;
|
||||
static String obd_last_error = "not initialized";
|
||||
@@ -438,9 +459,9 @@ static bool init_obd_can()
|
||||
return true;
|
||||
}
|
||||
|
||||
twai_general_config_t general_config = TWAI_GENERAL_CONFIG_DEFAULT(OBD_CAN_TX_PIN, OBD_CAN_RX_PIN, TWAI_MODE_NORMAL);
|
||||
general_config.tx_queue_len = 5;
|
||||
general_config.rx_queue_len = 20;
|
||||
twai_general_config_t general_config = TWAI_GENERAL_CONFIG_DEFAULT(OBD_CAN_TX_PIN, OBD_CAN_RX_PIN, TWAI_MODE_LISTEN_ONLY);
|
||||
general_config.tx_queue_len = 0;
|
||||
general_config.rx_queue_len = 64;
|
||||
|
||||
twai_timing_config_t timing_config = TWAI_TIMING_CONFIG_500KBITS();
|
||||
twai_filter_config_t filter_config = TWAI_FILTER_CONFIG_ACCEPT_ALL();
|
||||
@@ -465,36 +486,12 @@ static bool init_obd_can()
|
||||
|
||||
obd_can_started = true;
|
||||
obd_last_error = "started";
|
||||
Serial.println("OBD CAN: TWAI started on TX GPIO15 / RX GPIO16 at 500 kbps");
|
||||
Serial.println("OBD CAN: passive logger started on RX GPIO16 at 500 kbps");
|
||||
Serial.println("CAN CSV: CAN,ms,id,format,dlc,b0,b1,b2,b3,b4,b5,b6,b7");
|
||||
Serial.println("Enter TEMP,<degrees F> when recording a scan-tool coolant reading.");
|
||||
return true;
|
||||
}
|
||||
|
||||
static void send_obd_coolant_request()
|
||||
{
|
||||
twai_message_t message = {};
|
||||
message.identifier = OBD_REQUEST_ID;
|
||||
message.extd = 0;
|
||||
message.rtr = 0;
|
||||
message.data_length_code = 8;
|
||||
message.data[0] = 0x02;
|
||||
message.data[1] = 0x01;
|
||||
message.data[2] = OBD_PID_COOLANT_TEMP;
|
||||
message.data[3] = 0x55;
|
||||
message.data[4] = 0x55;
|
||||
message.data[5] = 0x55;
|
||||
message.data[6] = 0x55;
|
||||
message.data[7] = 0x55;
|
||||
|
||||
esp_err_t result = twai_transmit(&message, 0);
|
||||
if (result == ESP_OK) {
|
||||
obd_tx_count++;
|
||||
last_obd_tx_ms = millis();
|
||||
obd_last_error = "coolant request sent";
|
||||
} else {
|
||||
obd_last_error = "tx failed: " + String((int)result);
|
||||
}
|
||||
}
|
||||
|
||||
static void handle_obd_can_message(const twai_message_t &message)
|
||||
{
|
||||
if (message.extd || message.rtr) {
|
||||
@@ -526,19 +523,107 @@ static void handle_obd_can_message(const twai_message_t &message)
|
||||
}
|
||||
}
|
||||
|
||||
static CanTrackedFrame *find_can_tracked_frame(const twai_message_t &message)
|
||||
{
|
||||
CanTrackedFrame *free_slot = nullptr;
|
||||
for (size_t i = 0; i < CAN_TRACKED_ID_COUNT; i++) {
|
||||
CanTrackedFrame &tracked = can_tracked_frames[i];
|
||||
if (tracked.used && tracked.identifier == message.identifier && tracked.extended == message.extd) {
|
||||
return &tracked;
|
||||
}
|
||||
if (!tracked.used && free_slot == nullptr) free_slot = &tracked;
|
||||
}
|
||||
return free_slot;
|
||||
}
|
||||
|
||||
static void log_changed_can_frame(const twai_message_t &message)
|
||||
{
|
||||
if (message.rtr) return;
|
||||
|
||||
CanTrackedFrame *tracked = find_can_tracked_frame(message);
|
||||
if (tracked == nullptr) return;
|
||||
|
||||
bool changed = !tracked->used || tracked->dlc != message.data_length_code;
|
||||
for (uint8_t i = 0; i < message.data_length_code && i < 8; i++) {
|
||||
changed = changed || tracked->data[i] != message.data[i];
|
||||
}
|
||||
|
||||
unsigned long now = millis();
|
||||
bool should_log = changed && (!tracked->used || now - tracked->last_log_ms >= CAN_LOG_MIN_INTERVAL_MS);
|
||||
tracked->used = true;
|
||||
tracked->extended = message.extd;
|
||||
tracked->identifier = message.identifier;
|
||||
tracked->dlc = message.data_length_code;
|
||||
memcpy(tracked->data, message.data, sizeof(tracked->data));
|
||||
|
||||
if (!should_log) return;
|
||||
|
||||
tracked->last_log_ms = now;
|
||||
Serial.print("CAN,");
|
||||
Serial.print(now);
|
||||
Serial.print(",");
|
||||
Serial.print(message.identifier, HEX);
|
||||
Serial.print(message.extd ? ",EXT," : ",STD,");
|
||||
Serial.print(message.data_length_code);
|
||||
for (uint8_t i = 0; i < 8; i++) {
|
||||
Serial.print(",");
|
||||
if (i < message.data_length_code) {
|
||||
if (message.data[i] < 0x10) Serial.print("0");
|
||||
Serial.print(message.data[i], HEX);
|
||||
}
|
||||
}
|
||||
Serial.println();
|
||||
}
|
||||
|
||||
static void process_can_serial_commands()
|
||||
{
|
||||
while (Serial.available() > 0) {
|
||||
char value = (char)Serial.read();
|
||||
if (value == '\r') continue;
|
||||
if (value != '\n') {
|
||||
if (can_serial_command.length() < 48) can_serial_command += value;
|
||||
continue;
|
||||
}
|
||||
|
||||
can_serial_command.trim();
|
||||
if (can_serial_command.startsWith("TEMP,")) {
|
||||
String temperature = can_serial_command.substring(5);
|
||||
temperature.trim();
|
||||
Serial.print("TEMP,");
|
||||
Serial.print(millis());
|
||||
Serial.print(",");
|
||||
Serial.println(temperature);
|
||||
} else if (can_serial_command.startsWith("MARK,")) {
|
||||
String label = can_serial_command.substring(5);
|
||||
label.trim();
|
||||
Serial.print("MARK,");
|
||||
Serial.print(millis());
|
||||
Serial.print(",");
|
||||
Serial.println(label);
|
||||
} else if (can_serial_command.length() > 0) {
|
||||
Serial.println("ERROR,expected TEMP,<degrees F> or MARK,<label>");
|
||||
}
|
||||
can_serial_command = "";
|
||||
}
|
||||
}
|
||||
|
||||
static void process_obd_can()
|
||||
{
|
||||
if (!init_obd_can()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (millis() - last_obd_poll_ms >= 1000) {
|
||||
last_obd_poll_ms = millis();
|
||||
send_obd_coolant_request();
|
||||
}
|
||||
process_can_serial_commands();
|
||||
|
||||
twai_message_t message = {};
|
||||
while (twai_receive(&message, 0) == ESP_OK) {
|
||||
obd_raw_rx_count++;
|
||||
obd_last_rx_identifier = message.identifier;
|
||||
obd_last_rx_dlc = message.data_length_code;
|
||||
obd_last_rx_extended = message.extd;
|
||||
last_obd_raw_rx_ms = millis();
|
||||
|
||||
log_changed_can_frame(message);
|
||||
handle_obd_can_message(message);
|
||||
}
|
||||
|
||||
@@ -584,6 +669,69 @@ static void update_diagnostics_overlay()
|
||||
text += "\n Cargo API: ";
|
||||
text += status_model.api_ok ? "OK" : "FAIL";
|
||||
|
||||
const SensorNodeDashboardStatus &sensor_node = getSensorNodeStatus();
|
||||
text += "\n\nSensor Node RS485\n";
|
||||
text += " Link: ";
|
||||
text += sensorNodeRs485Connected() ? "connected" : "offline";
|
||||
text += " @ 115200";
|
||||
text += "\n Pins: RX GPIO43 / TX GPIO44";
|
||||
text += "\n Version: ";
|
||||
text += sensor_node.firmwareVersion.length() ? sensor_node.firmwareVersion : String("--");
|
||||
text += "\n Boot/sequence: ";
|
||||
text += sensor_node.frameReceived ? String(sensor_node.bootId, HEX) : String("--");
|
||||
text += "/";
|
||||
text += sensor_node.frameReceived ? String(sensor_node.frameSequence) : String("--");
|
||||
text += "\n Frames/gaps/errors: ";
|
||||
text += String(sensor_node.framesReceived);
|
||||
text += "/";
|
||||
text += String(sensor_node.framesDropped);
|
||||
text += "/";
|
||||
text += String(sensor_node.parseErrors + sensor_node.oversizedFrames);
|
||||
text += "\n Raw bytes/age: ";
|
||||
text += String(sensor_node.bytesReceived);
|
||||
text += "/";
|
||||
text += sensor_node.bytesReceived
|
||||
? String(millis() - sensor_node.lastByteMs) + " ms"
|
||||
: String("--");
|
||||
text += "\n Frame age: ";
|
||||
text += sensor_node.frameReceived
|
||||
? String(millis() - sensor_node.lastFrameMs) + " ms"
|
||||
: String("--");
|
||||
text += "\n IMU: ";
|
||||
text += sensor_node.imuOnline && sensor_node.imuValid ? "OK" : "offline";
|
||||
text += " acc ";
|
||||
text += String(sensor_node.imuAccuracy);
|
||||
text += " ";
|
||||
text += sensor_node.stability;
|
||||
text += "\n Pitch/roll: ";
|
||||
text += String(sensor_node.pitchDegrees, 1);
|
||||
text += " / ";
|
||||
text += String(sensor_node.rollDegrees, 1);
|
||||
text += sensor_node.levelCalibrationValid ? " deg calibrated" : " deg raw";
|
||||
text += "\n Yaw: ";
|
||||
text += String(sensor_node.yawDegrees, 1);
|
||||
text += " deg";
|
||||
text += "\n GPS: ";
|
||||
text += sensor_node.gpsOnline ? (sensor_node.gpsFix ? "fix" : "no fix") : "offline";
|
||||
text += " sats ";
|
||||
text += String(sensor_node.satellites);
|
||||
text += " HDOP ";
|
||||
text += sensor_node.hdop > 0 ? String(sensor_node.hdop, 1) : String("--");
|
||||
text += "\n Position: ";
|
||||
if (sensor_node.gpsFix) {
|
||||
text += String(sensor_node.latitude, 6);
|
||||
text += ", ";
|
||||
text += String(sensor_node.longitude, 6);
|
||||
} else {
|
||||
text += "--";
|
||||
}
|
||||
text += "\n Speed: ";
|
||||
text += sensor_node.gpsFix ? String(sensor_node.speedKph, 1) + " km/h" : String("--");
|
||||
text += "\n Altitude: ";
|
||||
text += sensor_node.gpsFix ? String(sensor_node.altitudeMeters, 1) + " m" : String("--");
|
||||
text += "\n UTC: ";
|
||||
text += sensor_node.utcTime.length() ? sensor_node.utcTime : String("--");
|
||||
|
||||
text += "\n\nCargo ESP\n";
|
||||
text += " Version: ";
|
||||
text += status_model.firmware_version;
|
||||
@@ -622,18 +770,89 @@ static void update_diagnostics_overlay()
|
||||
text += String(status_model.cell_delta_mv);
|
||||
text += " mV";
|
||||
|
||||
text += "\n\nOBD-II / CAN\n";
|
||||
text += "\n\nPassive CAN Logger\n";
|
||||
text += " TWAI: ";
|
||||
text += obd_can_started ? "started" : "offline";
|
||||
text += " @ 500 kbps";
|
||||
text += "\n Pins: TX GPIO15 / RX GPIO16";
|
||||
text += "\n Coolant: ";
|
||||
text += obd_coolant_temp_f > -100 ? String(obd_coolant_temp_f) + "°F" : String("--");
|
||||
text += "\n TX/RX: ";
|
||||
text += String(obd_tx_count);
|
||||
text += "/";
|
||||
text += String(obd_rx_count);
|
||||
text += "\n Last RX age: ";
|
||||
text += last_obd_rx_ms > 0 ? String((millis() - last_obd_rx_ms) / 1000) + "s" : String("--");
|
||||
text += "\n Mode: listen-only (no transmit)";
|
||||
text += "\n Raw RX: ";
|
||||
text += String(obd_raw_rx_count);
|
||||
|
||||
text += "\n Last frame: ";
|
||||
if (obd_raw_rx_count > 0) {
|
||||
text += obd_last_rx_extended ? "EXT 0x" : "STD 0x";
|
||||
text += String(obd_last_rx_identifier, HEX);
|
||||
text += " DLC ";
|
||||
text += String(obd_last_rx_dlc);
|
||||
} else {
|
||||
text += "--";
|
||||
}
|
||||
|
||||
text += "\n Raw RX age: ";
|
||||
text += last_obd_raw_rx_ms > 0
|
||||
? String((millis() - last_obd_raw_rx_ms) / 1000) + "s"
|
||||
: String("--");
|
||||
|
||||
text += "\n Coolant RX age: ";
|
||||
text += last_obd_rx_ms > 0
|
||||
? String((millis() - last_obd_rx_ms) / 1000) + "s"
|
||||
: String("--");
|
||||
|
||||
if (obd_can_started) {
|
||||
twai_status_info_t can_status = {};
|
||||
esp_err_t status_result = twai_get_status_info(&can_status);
|
||||
|
||||
if (status_result == ESP_OK) {
|
||||
const char *state_text = "unknown";
|
||||
|
||||
switch (can_status.state) {
|
||||
case TWAI_STATE_STOPPED:
|
||||
state_text = "stopped";
|
||||
break;
|
||||
case TWAI_STATE_RUNNING:
|
||||
state_text = "running";
|
||||
break;
|
||||
case TWAI_STATE_BUS_OFF:
|
||||
state_text = "bus-off";
|
||||
break;
|
||||
case TWAI_STATE_RECOVERING:
|
||||
state_text = "recovering";
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
text += "\n State: ";
|
||||
text += state_text;
|
||||
|
||||
text += " TXq/RXq ";
|
||||
text += String(can_status.msgs_to_tx);
|
||||
text += "/";
|
||||
text += String(can_status.msgs_to_rx);
|
||||
|
||||
text += "\n TEC/REC: ";
|
||||
text += String(can_status.tx_error_counter);
|
||||
text += "/";
|
||||
text += String(can_status.rx_error_counter);
|
||||
|
||||
text += "\n Fail/miss/overrun: ";
|
||||
text += String(can_status.tx_failed_count);
|
||||
text += "/";
|
||||
text += String(can_status.rx_missed_count);
|
||||
text += "/";
|
||||
text += String(can_status.rx_overrun_count);
|
||||
|
||||
text += "\n Arb lost/bus errors: ";
|
||||
text += String(can_status.arb_lost_count);
|
||||
text += "/";
|
||||
text += String(can_status.bus_error_count);
|
||||
} else {
|
||||
text += "\n Driver status error: ";
|
||||
text += String((int)status_result);
|
||||
}
|
||||
}
|
||||
|
||||
text += "\n Last status: ";
|
||||
text += obd_last_error;
|
||||
|
||||
@@ -2469,6 +2688,7 @@ void setup()
|
||||
Serial.println("Target: Waveshare ESP32-S3 Touch LCD 5B");
|
||||
Serial.println("Display: 1024x600");
|
||||
Serial.println("API Base: /api/v1");
|
||||
initSensorNodeRs485();
|
||||
|
||||
load_dashboard_connection_config();
|
||||
|
||||
@@ -2504,6 +2724,7 @@ void loop()
|
||||
process_pending_relay_command();
|
||||
poll_status_api();
|
||||
process_obd_can();
|
||||
processSensorNodeRs485();
|
||||
process_dashboard_page_switch();
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
#include <ArduinoJson.h>
|
||||
#include <HardwareSerial.h>
|
||||
|
||||
#include "sensor_node_receiver.h"
|
||||
|
||||
namespace {
|
||||
constexpr int SENSOR_NODE_RS485_RX_PIN = 43;
|
||||
constexpr int SENSOR_NODE_RS485_TX_PIN = 44;
|
||||
constexpr uint32_t SENSOR_NODE_RS485_BAUD = 115200;
|
||||
constexpr size_t SENSOR_NODE_RS485_MAX_LINE = 1900;
|
||||
constexpr size_t SENSOR_NODE_RS485_RX_BUFFER = 4096;
|
||||
constexpr unsigned long SENSOR_NODE_STALE_MS = 1500;
|
||||
|
||||
HardwareSerial sensorNodeSerial(1);
|
||||
SensorNodeDashboardStatus sensorNodeStatus;
|
||||
String receiveLine;
|
||||
bool discardUntilNewline = false;
|
||||
bool sequenceInitialized = false;
|
||||
|
||||
void recordSequence(uint32_t bootId, uint32_t sequence) {
|
||||
if (!sequenceInitialized || bootId != sensorNodeStatus.bootId) {
|
||||
if (sequenceInitialized && bootId != sensorNodeStatus.bootId) {
|
||||
sensorNodeStatus.nodeRestarts++;
|
||||
}
|
||||
sensorNodeStatus.bootId = bootId;
|
||||
sensorNodeStatus.frameSequence = sequence;
|
||||
sequenceInitialized = true;
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t delta = sequence - sensorNodeStatus.frameSequence;
|
||||
if (delta > 1 && delta < 0x80000000UL) {
|
||||
sensorNodeStatus.framesDropped += delta - 1;
|
||||
}
|
||||
sensorNodeStatus.frameSequence = sequence;
|
||||
}
|
||||
|
||||
void parseSensorNodeFrame(const String &line) {
|
||||
StaticJsonDocument<3072> doc;
|
||||
DeserializationError error = deserializeJson(doc, line);
|
||||
if (error) {
|
||||
sensorNodeStatus.parseErrors++;
|
||||
return;
|
||||
}
|
||||
|
||||
const char *type = doc["type"] | "";
|
||||
if (strcmp(type, "sensor_status") != 0 || (int)(doc["schema_version"] | 0) != 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
uint32_t bootId = doc["boot_id"] | 0;
|
||||
uint32_t sequence = doc["frame_sequence"] | 0;
|
||||
recordSequence(bootId, sequence);
|
||||
|
||||
JsonObject imu = doc["imu"];
|
||||
sensorNodeStatus.imuOnline = imu["online"] | false;
|
||||
sensorNodeStatus.imuValid = imu["valid"] | false;
|
||||
sensorNodeStatus.imuAccuracy = imu["accuracy"] | 0;
|
||||
sensorNodeStatus.stability = String((const char *)(imu["stability"] | "unknown"));
|
||||
sensorNodeStatus.yawDegrees = imu["yaw_degrees"] | 0.0f;
|
||||
|
||||
JsonObject level = imu["level_calibration"];
|
||||
sensorNodeStatus.levelCalibrationValid = level["valid"] | false;
|
||||
sensorNodeStatus.pitchDegrees = sensorNodeStatus.levelCalibrationValid
|
||||
? (float)(level["pitch_degrees"] | 0.0f)
|
||||
: (float)(imu["pitch_degrees"] | 0.0f);
|
||||
sensorNodeStatus.rollDegrees = sensorNodeStatus.levelCalibrationValid
|
||||
? (float)(level["roll_degrees"] | 0.0f)
|
||||
: (float)(imu["roll_degrees"] | 0.0f);
|
||||
|
||||
JsonObject gps = doc["gps"];
|
||||
sensorNodeStatus.gpsOnline = gps["online"] | false;
|
||||
sensorNodeStatus.gpsFix = gps["fix"] | false;
|
||||
sensorNodeStatus.satellites = gps["satellites"] | 0;
|
||||
sensorNodeStatus.hdop = gps["hdop"].isNull() ? 0 : (float)gps["hdop"];
|
||||
sensorNodeStatus.latitude = gps["latitude"] | 0.0;
|
||||
sensorNodeStatus.longitude = gps["longitude"] | 0.0;
|
||||
sensorNodeStatus.altitudeMeters = gps["altitude_meters"] | 0.0f;
|
||||
sensorNodeStatus.speedKph = gps["speed_kph"] | 0.0f;
|
||||
JsonObject utc = gps["utc"];
|
||||
if (!utc.isNull() && (int)(utc["year"] | 0) > 0) {
|
||||
char utcBuffer[24];
|
||||
snprintf(
|
||||
utcBuffer,
|
||||
sizeof(utcBuffer),
|
||||
"%04d-%02d-%02d %02d:%02d:%02dZ",
|
||||
(int)(utc["year"] | 0),
|
||||
(int)(utc["month"] | 0),
|
||||
(int)(utc["day"] | 0),
|
||||
(int)(utc["hour"] | 0),
|
||||
(int)(utc["minute"] | 0),
|
||||
(int)(utc["second"] | 0)
|
||||
);
|
||||
sensorNodeStatus.utcTime = utcBuffer;
|
||||
} else {
|
||||
sensorNodeStatus.utcTime = "";
|
||||
}
|
||||
sensorNodeStatus.firmwareVersion = String((const char *)(doc["firmware_version"] | ""));
|
||||
sensorNodeStatus.frameReceived = true;
|
||||
sensorNodeStatus.framesReceived++;
|
||||
sensorNodeStatus.lastFrameMs = millis();
|
||||
}
|
||||
}
|
||||
|
||||
void initSensorNodeRs485() {
|
||||
receiveLine.reserve(SENSOR_NODE_RS485_MAX_LINE);
|
||||
sensorNodeSerial.setRxBufferSize(SENSOR_NODE_RS485_RX_BUFFER);
|
||||
sensorNodeSerial.begin(
|
||||
SENSOR_NODE_RS485_BAUD,
|
||||
SERIAL_8N1,
|
||||
SENSOR_NODE_RS485_RX_PIN,
|
||||
SENSOR_NODE_RS485_TX_PIN
|
||||
);
|
||||
Serial.println("Sensor node RS485: RX GPIO43 / TX GPIO44 @ 115200");
|
||||
}
|
||||
|
||||
void processSensorNodeRs485() {
|
||||
while (sensorNodeSerial.available() > 0) {
|
||||
char value = (char)sensorNodeSerial.read();
|
||||
sensorNodeStatus.bytesReceived++;
|
||||
sensorNodeStatus.lastByteMs = millis();
|
||||
if (value == '\r') continue;
|
||||
|
||||
if (value != '\n') {
|
||||
if (discardUntilNewline) continue;
|
||||
if (receiveLine.length() < SENSOR_NODE_RS485_MAX_LINE) {
|
||||
receiveLine += value;
|
||||
} else {
|
||||
receiveLine = "";
|
||||
discardUntilNewline = true;
|
||||
sensorNodeStatus.oversizedFrames++;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (discardUntilNewline) {
|
||||
discardUntilNewline = false;
|
||||
receiveLine = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
if (receiveLine.length() > 0) {
|
||||
parseSensorNodeFrame(receiveLine);
|
||||
}
|
||||
receiveLine = "";
|
||||
}
|
||||
}
|
||||
|
||||
bool sensorNodeRs485Connected() {
|
||||
return sensorNodeStatus.frameReceived &&
|
||||
millis() - sensorNodeStatus.lastFrameMs <= SENSOR_NODE_STALE_MS;
|
||||
}
|
||||
|
||||
const SensorNodeDashboardStatus &getSensorNodeStatus() {
|
||||
return sensorNodeStatus;
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
#pragma once
|
||||
|
||||
#include <Arduino.h>
|
||||
|
||||
struct SensorNodeDashboardStatus {
|
||||
bool frameReceived = false;
|
||||
bool imuOnline = false;
|
||||
bool imuValid = false;
|
||||
bool levelCalibrationValid = false;
|
||||
bool gpsOnline = false;
|
||||
bool gpsFix = false;
|
||||
uint8_t imuAccuracy = 0;
|
||||
uint32_t bootId = 0;
|
||||
uint32_t frameSequence = 0;
|
||||
uint32_t framesReceived = 0;
|
||||
uint32_t framesDropped = 0;
|
||||
uint32_t parseErrors = 0;
|
||||
uint32_t oversizedFrames = 0;
|
||||
uint32_t bytesReceived = 0;
|
||||
uint32_t nodeRestarts = 0;
|
||||
uint32_t satellites = 0;
|
||||
float pitchDegrees = 0;
|
||||
float rollDegrees = 0;
|
||||
float yawDegrees = 0;
|
||||
float speedKph = 0;
|
||||
float hdop = 0;
|
||||
float altitudeMeters = 0;
|
||||
double latitude = 0;
|
||||
double longitude = 0;
|
||||
String stability = "unknown";
|
||||
String firmwareVersion;
|
||||
String utcTime;
|
||||
unsigned long lastByteMs = 0;
|
||||
unsigned long lastFrameMs = 0;
|
||||
};
|
||||
|
||||
void initSensorNodeRs485();
|
||||
void processSensorNodeRs485();
|
||||
bool sensorNodeRs485Connected();
|
||||
const SensorNodeDashboardStatus &getSensorNodeStatus();
|
||||
@@ -0,0 +1,19 @@
|
||||
# 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 at the verified 38400 baud; RS485 uses hardware UART1.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
@@ -0,0 +1,98 @@
|
||||
#include <Preferences.h>
|
||||
#include <math.h>
|
||||
#include "calibration.h"
|
||||
#include "sensor_node_config.h"
|
||||
|
||||
namespace {
|
||||
Preferences preferences;
|
||||
float angleDelta(float a, float b) {
|
||||
float delta = fmodf(a - b + 540.0f, 360.0f) - 180.0f;
|
||||
return delta;
|
||||
}
|
||||
float normalizeAngle(float angle) {
|
||||
return fmodf(angle + 540.0f, 360.0f) - 180.0f;
|
||||
}
|
||||
void saveCalibration() {
|
||||
preferences.begin("sensor_cal", false);
|
||||
preferences.putBool("level_valid", levelCalibration.valid);
|
||||
preferences.putFloat("pitch_zero", levelCalibration.pitchOffsetDegrees);
|
||||
preferences.putFloat("roll_zero", levelCalibration.rollOffsetDegrees);
|
||||
preferences.end();
|
||||
}
|
||||
}
|
||||
|
||||
LevelCalibrationState levelCalibration;
|
||||
|
||||
void initLevelCalibration() {
|
||||
preferences.begin("sensor_cal", true);
|
||||
levelCalibration.valid = preferences.getBool("level_valid", false);
|
||||
levelCalibration.pitchOffsetDegrees = preferences.getFloat("pitch_zero", 0);
|
||||
levelCalibration.rollOffsetDegrees = preferences.getFloat("roll_zero", 0);
|
||||
preferences.end();
|
||||
}
|
||||
|
||||
bool startLevelCalibration(const ImuState& imu) {
|
||||
if (!imu.online || !imu.valid || millis() - imu.lastUpdateMs > SENSOR_STALE_MS) return false;
|
||||
levelCalibration.active = true;
|
||||
levelCalibration.startedMs = millis();
|
||||
levelCalibration.lastSampleMs = 0;
|
||||
levelCalibration.sampleCount = 0;
|
||||
levelCalibration.pitchSum = 0;
|
||||
levelCalibration.rollSinSum = 0;
|
||||
levelCalibration.rollCosSum = 0;
|
||||
levelCalibration.firstPitch = imu.pitchDegrees;
|
||||
levelCalibration.firstRoll = imu.rollDegrees;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool updateLevelCalibration(const ImuState& imu, String& result) {
|
||||
if (!levelCalibration.active) return false;
|
||||
if (!imu.valid || millis() - imu.lastUpdateMs > SENSOR_STALE_MS) {
|
||||
levelCalibration.active = false;
|
||||
result = "imu_unavailable";
|
||||
return true;
|
||||
}
|
||||
if (imu.lastUpdateMs == levelCalibration.lastSampleMs) return false;
|
||||
levelCalibration.lastSampleMs = imu.lastUpdateMs;
|
||||
|
||||
if (fabsf(imu.pitchDegrees - levelCalibration.firstPitch) > LEVEL_CALIBRATION_MAX_MOVEMENT_DEGREES ||
|
||||
fabsf(angleDelta(imu.rollDegrees, levelCalibration.firstRoll)) > LEVEL_CALIBRATION_MAX_MOVEMENT_DEGREES) {
|
||||
levelCalibration.active = false;
|
||||
result = "vehicle_moved";
|
||||
return true;
|
||||
}
|
||||
|
||||
levelCalibration.pitchSum += imu.pitchDegrees;
|
||||
levelCalibration.rollSinSum += sinf(imu.rollDegrees * DEG_TO_RAD);
|
||||
levelCalibration.rollCosSum += cosf(imu.rollDegrees * DEG_TO_RAD);
|
||||
levelCalibration.sampleCount++;
|
||||
|
||||
if (millis() - levelCalibration.startedMs < LEVEL_CALIBRATION_DURATION_MS) return false;
|
||||
if (levelCalibration.sampleCount < LEVEL_CALIBRATION_MIN_SAMPLES) {
|
||||
levelCalibration.active = false;
|
||||
result = "insufficient_samples";
|
||||
return true;
|
||||
}
|
||||
|
||||
levelCalibration.pitchOffsetDegrees = levelCalibration.pitchSum / levelCalibration.sampleCount;
|
||||
levelCalibration.rollOffsetDegrees = atan2f(levelCalibration.rollSinSum, levelCalibration.rollCosSum) * RAD_TO_DEG;
|
||||
levelCalibration.valid = true;
|
||||
levelCalibration.active = false;
|
||||
saveCalibration();
|
||||
result = "complete";
|
||||
return true;
|
||||
}
|
||||
|
||||
void clearLevelCalibration() {
|
||||
levelCalibration = LevelCalibrationState();
|
||||
saveCalibration();
|
||||
}
|
||||
|
||||
float calibratedPitchDegrees(const ImuState& imu) {
|
||||
return levelCalibration.valid ? imu.pitchDegrees - levelCalibration.pitchOffsetDegrees : imu.pitchDegrees;
|
||||
}
|
||||
|
||||
float calibratedRollDegrees(const ImuState& imu) {
|
||||
return levelCalibration.valid ? normalizeAngle(imu.rollDegrees - levelCalibration.rollOffsetDegrees) : imu.rollDegrees;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
#include "sensor_state.h"
|
||||
|
||||
struct LevelCalibrationState {
|
||||
bool valid = false;
|
||||
bool active = false;
|
||||
float pitchOffsetDegrees = 0;
|
||||
float rollOffsetDegrees = 0;
|
||||
unsigned long startedMs = 0;
|
||||
unsigned long lastSampleMs = 0;
|
||||
uint32_t sampleCount = 0;
|
||||
float pitchSum = 0;
|
||||
float rollSinSum = 0;
|
||||
float rollCosSum = 0;
|
||||
float firstPitch = 0;
|
||||
float firstRoll = 0;
|
||||
};
|
||||
|
||||
extern LevelCalibrationState levelCalibration;
|
||||
void initLevelCalibration();
|
||||
bool startLevelCalibration(const ImuState& imu);
|
||||
bool updateLevelCalibration(const ImuState& imu, String& result);
|
||||
void clearLevelCalibration();
|
||||
float calibratedPitchDegrees(const ImuState& imu);
|
||||
float calibratedRollDegrees(const ImuState& imu);
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
#include <TinyGPSPlus.h>
|
||||
#include "gps.h"
|
||||
#include "sensor_node_config.h"
|
||||
namespace { TinyGPSPlus parser; }
|
||||
|
||||
namespace {
|
||||
uint32_t activeBaud = GPS_BAUD;
|
||||
}
|
||||
|
||||
void initGps(HardwareSerial& serial) {
|
||||
serial.begin(activeBaud, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN);
|
||||
Serial.print("Starting GPS UART at ");
|
||||
Serial.println(activeBaud);
|
||||
}
|
||||
|
||||
void updateGps(HardwareSerial& serial, GpsState& s) {
|
||||
while (serial.available()) { s.lastByteMs = millis(); parser.encode(serial.read()); }
|
||||
s.charactersProcessed = parser.charsProcessed();
|
||||
s.sentencesPassed = parser.passedChecksum();
|
||||
s.sentencesFailed = parser.failedChecksum();
|
||||
s.baud = activeBaud;
|
||||
s.hdopValid = parser.hdop.isValid();
|
||||
if (s.hdopValid) s.hdop = parser.hdop.hdop();
|
||||
s.locationAgeMs = parser.location.isValid() ? parser.location.age() : ULONG_MAX;
|
||||
s.timeAgeMs = parser.time.isValid() ? parser.time.age() : ULONG_MAX;
|
||||
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(); }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
#include <HardwareSerial.h>
|
||||
#include "sensor_state.h"
|
||||
void initGps(HardwareSerial& serial);
|
||||
void updateGps(HardwareSerial& serial, GpsState& state);
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
#include <Adafruit_BNO08x.h>
|
||||
#include <Wire.h>
|
||||
#include <math.h>
|
||||
#include "imu.h"
|
||||
#include "sensor_node_config.h"
|
||||
|
||||
namespace {
|
||||
Adafruit_BNO08x bno086(-1);
|
||||
sh2_SensorValue_t value;
|
||||
bool initialized = false;
|
||||
unsigned long lastInitAttemptMs = 0;
|
||||
float radiansToDegrees(float r) { return r * 180.0f / PI; }
|
||||
bool enableReports() {
|
||||
bool rotationEnabled = bno086.enableReport(SH2_ROTATION_VECTOR, IMU_REPORT_INTERVAL_US);
|
||||
bno086.enableReport(SH2_LINEAR_ACCELERATION, IMU_REPORT_INTERVAL_US);
|
||||
bno086.enableReport(SH2_GYROSCOPE_CALIBRATED, IMU_REPORT_INTERVAL_US);
|
||||
bno086.enableReport(SH2_GRAVITY, IMU_REPORT_INTERVAL_US);
|
||||
bno086.enableReport(SH2_STABILITY_CLASSIFIER, IMU_STABILITY_INTERVAL_US);
|
||||
return rotationEnabled;
|
||||
}
|
||||
bool tryInitImu() {
|
||||
lastInitAttemptMs = millis();
|
||||
initialized = bno086.begin_I2C(BNO086_I2C_ADDRESS, &Wire);
|
||||
if (initialized) initialized = enableReports();
|
||||
return initialized;
|
||||
}
|
||||
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 = radiansToDegrees(atan2f(sinr, cosr));
|
||||
float sinp = 2 * (s.real * s.j - s.k * s.i);
|
||||
s.pitchDegrees = radiansToDegrees(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 = radiansToDegrees(atan2f(siny, cosy));
|
||||
}
|
||||
String stabilityName(uint8_t value) {
|
||||
switch (value) {
|
||||
case STABILITY_CLASSIFIER_ON_TABLE: return "on_table";
|
||||
case STABILITY_CLASSIFIER_STATIONARY: return "stationary";
|
||||
case STABILITY_CLASSIFIER_STABLE: return "stable";
|
||||
case STABILITY_CLASSIFIER_MOTION: return "motion";
|
||||
default: return "unknown";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool initImu() {
|
||||
Wire.begin(BNO086_SDA_PIN, BNO086_SCL_PIN);
|
||||
Wire.setClock(BNO086_I2C_CLOCK_HZ);
|
||||
delay(100);
|
||||
Serial.print("Starting BNO086 at 0x");
|
||||
Serial.println(BNO086_I2C_ADDRESS, HEX);
|
||||
for (int attempt = 1; attempt <= BNO086_INIT_ATTEMPTS; attempt++) {
|
||||
if (tryInitImu()) return true;
|
||||
Serial.print("BNO086 init attempt failed: ");
|
||||
Serial.println(attempt);
|
||||
delay(BNO086_INIT_RETRY_DELAY_MS);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void maintainImu(ImuState& s) {
|
||||
if (initialized || millis() - lastInitAttemptMs < BNO086_RUNTIME_RETRY_MS) return;
|
||||
Serial.println("Retrying BNO086 initialization");
|
||||
s.online = tryInitImu();
|
||||
if (s.online) Serial.println("BNO086 recovered");
|
||||
}
|
||||
|
||||
void updateImu(ImuState& s) {
|
||||
s.online = initialized;
|
||||
if (initialized && bno086.wasReset()) {
|
||||
initialized = enableReports();
|
||||
s.online = initialized;
|
||||
if (!initialized) return;
|
||||
}
|
||||
if (!initialized || !bno086.getSensorEvent(&value)) return;
|
||||
unsigned long now = millis();
|
||||
Vector3State* vector = nullptr;
|
||||
switch (value.sensorId) {
|
||||
case SH2_ROTATION_VECTOR:
|
||||
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 = now; updateEuler(s); return;
|
||||
case SH2_LINEAR_ACCELERATION:
|
||||
vector = &s.linearAcceleration;
|
||||
vector->x = value.un.linearAcceleration.x; vector->y = value.un.linearAcceleration.y; vector->z = value.un.linearAcceleration.z; break;
|
||||
case SH2_GYROSCOPE_CALIBRATED:
|
||||
vector = &s.angularVelocity;
|
||||
vector->x = value.un.gyroscope.x; vector->y = value.un.gyroscope.y; vector->z = value.un.gyroscope.z; break;
|
||||
case SH2_GRAVITY:
|
||||
vector = &s.gravity;
|
||||
vector->x = value.un.gravity.x; vector->y = value.un.gravity.y; vector->z = value.un.gravity.z; break;
|
||||
case SH2_STABILITY_CLASSIFIER:
|
||||
s.stabilityCode = value.un.stabilityClassifier.classification;
|
||||
s.stability = stabilityName(s.stabilityCode); s.stabilityLastUpdateMs = now; return;
|
||||
default: return;
|
||||
}
|
||||
vector->accuracy = value.status; vector->lastUpdateMs = now;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
#pragma once
|
||||
#include "sensor_state.h"
|
||||
bool initImu();
|
||||
void maintainImu(ImuState& state);
|
||||
void updateImu(ImuState& state);
|
||||
@@ -0,0 +1,144 @@
|
||||
#include <ArduinoJson.h>
|
||||
#include <esp_system.h>
|
||||
#include "rs485_transport.h"
|
||||
#include "sensor_node_config.h"
|
||||
#include "calibration.h"
|
||||
|
||||
namespace {
|
||||
String commandBuffer;
|
||||
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) {
|
||||
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();
|
||||
// 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);
|
||||
#if USB_TELEMETRY_ENABLED
|
||||
serializeJson(doc, Serial); Serial.println();
|
||||
#endif
|
||||
}
|
||||
}
|
||||
|
||||
void initRs485(HardwareSerial& serial) {
|
||||
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);
|
||||
bootId = esp_random();
|
||||
}
|
||||
|
||||
void publishSensorStatus(HardwareSerial& serial, const ImuState& imu, const GpsState& gps) {
|
||||
StaticJsonDocument<2048> 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["boot_id"] = bootId;
|
||||
doc["frame_sequence"] = frameSequence++;
|
||||
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;
|
||||
i["age_ms"] = imu.lastUpdateMs ? millis() - imu.lastUpdateMs : -1;
|
||||
auto addVector = [&](const char* name, const Vector3State& v) {
|
||||
JsonObject o = i.createNestedObject(name);
|
||||
o["x"] = v.x; o["y"] = v.y; o["z"] = v.z; o["accuracy"] = v.accuracy;
|
||||
o["age_ms"] = v.lastUpdateMs ? millis() - v.lastUpdateMs : -1;
|
||||
};
|
||||
addVector("linear_acceleration_mps2", imu.linearAcceleration);
|
||||
addVector("angular_velocity_rads", imu.angularVelocity);
|
||||
addVector("gravity_mps2", imu.gravity);
|
||||
i["stability"] = imu.stability; i["stability_code"] = imu.stabilityCode;
|
||||
i["stability_age_ms"] = imu.stabilityLastUpdateMs ? millis() - imu.stabilityLastUpdateMs : -1;
|
||||
JsonObject level = i.createNestedObject("level_calibration");
|
||||
level["valid"] = levelCalibration.valid; level["active"] = levelCalibration.active;
|
||||
level["pitch_offset_degrees"] = levelCalibration.pitchOffsetDegrees;
|
||||
level["roll_offset_degrees"] = levelCalibration.rollOffsetDegrees;
|
||||
level["pitch_degrees"] = calibratedPitchDegrees(imu);
|
||||
level["roll_degrees"] = calibratedRollDegrees(imu);
|
||||
JsonObject g = doc.createNestedObject("gps");
|
||||
g["online"] = gps.online; g["fix"] = gps.fix; g["satellites"] = gps.satellites;
|
||||
g["baud"] = gps.baud;
|
||||
if (gps.hdopValid) g["hdop"] = gps.hdop; else g["hdop"] = nullptr;
|
||||
g["location_age_ms"] = gps.locationAgeMs == ULONG_MAX ? -1 : (long)gps.locationAgeMs;
|
||||
g["time_age_ms"] = gps.timeAgeMs == ULONG_MAX ? -1 : (long)gps.timeAgeMs;
|
||||
JsonObject nmea = g.createNestedObject("nmea");
|
||||
nmea["characters"] = gps.charactersProcessed;
|
||||
nmea["sentences_passed"] = gps.sentencesPassed;
|
||||
nmea["sentences_failed"] = gps.sentencesFailed;
|
||||
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;
|
||||
sendJson(serial, doc);
|
||||
}
|
||||
|
||||
void publishCalibrationResult(HardwareSerial& serial, const String& result) {
|
||||
StaticJsonDocument<384> doc;
|
||||
doc["type"] = "calibration_response";
|
||||
doc["calibration"] = "level";
|
||||
doc["result"] = result;
|
||||
doc["ok"] = result == "started" || result == "complete" || result == "cleared";
|
||||
doc["valid"] = levelCalibration.valid;
|
||||
doc["pitch_offset_degrees"] = levelCalibration.pitchOffsetDegrees;
|
||||
doc["roll_offset_degrees"] = levelCalibration.rollOffsetDegrees;
|
||||
sendJson(serial, doc);
|
||||
}
|
||||
|
||||
void pollRs485Commands(HardwareSerial& serial, const ImuState& imu) {
|
||||
// Bound RX work so an electrically echoed or noisy bus cannot starve sensors.
|
||||
int bytesRemaining = RS485_COMMAND_MAX_LENGTH * 2;
|
||||
while (serial.available() && bytesRemaining-- > 0) {
|
||||
char c = serial.read();
|
||||
if (c == '\r') continue;
|
||||
if (c != '\n') {
|
||||
if (discardCommandUntilNewline) continue;
|
||||
if (commandBuffer.length() < RS485_COMMAND_MAX_LENGTH) {
|
||||
commandBuffer += c;
|
||||
} else {
|
||||
commandBuffer = "";
|
||||
discardCommandUntilNewline = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (discardCommandUntilNewline) {
|
||||
discardCommandUntilNewline = false;
|
||||
commandBuffer = "";
|
||||
continue;
|
||||
}
|
||||
|
||||
StaticJsonDocument<192> request;
|
||||
DeserializationError error = deserializeJson(request, commandBuffer);
|
||||
commandBuffer = "";
|
||||
// Partial self-echoes and line noise are not commands. Dropping them
|
||||
// silently avoids turning one bad fragment into a response/echo loop.
|
||||
if (error) continue;
|
||||
String type = request["type"] | "";
|
||||
// Some RS485 boards echo transmitted frames into RX. Ignore our own output.
|
||||
if (type == "sensor_status" || type == "calibration_response") continue;
|
||||
if (type == "calibrate_level") {
|
||||
publishCalibrationResult(serial, startLevelCalibration(imu) ? "started" : "imu_unavailable");
|
||||
} else if (type == "clear_level_calibration") {
|
||||
clearLevelCalibration(); publishCalibrationResult(serial, "cleared");
|
||||
} else {
|
||||
publishCalibrationResult(serial, "unknown_command");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
#pragma once
|
||||
#include <HardwareSerial.h>
|
||||
#include "sensor_state.h"
|
||||
void initRs485(HardwareSerial& serial);
|
||||
void publishSensorStatus(HardwareSerial& serial, const ImuState& imu, const GpsState& gps);
|
||||
void pollRs485Commands(HardwareSerial& serial, const ImuState& imu);
|
||||
void publishCalibrationResult(HardwareSerial& serial, const String& result);
|
||||
@@ -0,0 +1,32 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
#define SENSOR_NODE_NAME "xiao_c6_sensor_node"
|
||||
#define SENSOR_NODE_FIRMWARE_VERSION "0.2.2"
|
||||
#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 0x4B
|
||||
#define BNO086_I2C_CLOCK_HZ 100000
|
||||
#define BNO086_INIT_ATTEMPTS 8
|
||||
#define BNO086_INIT_RETRY_DELAY_MS 250
|
||||
#define BNO086_RUNTIME_RETRY_MS 5000
|
||||
#define GPS_BAUD 38400
|
||||
#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_STABILITY_INTERVAL_US 100000
|
||||
#define TELEMETRY_INTERVAL_MS 250
|
||||
#define SENSOR_STALE_MS 2000
|
||||
#define USB_TELEMETRY_ENABLED 1
|
||||
#define LEVEL_CALIBRATION_DURATION_MS 5000
|
||||
#define LEVEL_CALIBRATION_MIN_SAMPLES 100
|
||||
#define LEVEL_CALIBRATION_MAX_MOVEMENT_DEGREES 2.0f
|
||||
#define RS485_COMMAND_MAX_LENGTH 192
|
||||
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
#include <Arduino.h>
|
||||
|
||||
struct Vector3State {
|
||||
float x = 0, y = 0, z = 0;
|
||||
uint8_t accuracy = 0;
|
||||
unsigned long lastUpdateMs = 0;
|
||||
};
|
||||
|
||||
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;
|
||||
Vector3State linearAcceleration, angularVelocity, gravity;
|
||||
uint8_t stabilityCode = 0;
|
||||
String stability = "unknown";
|
||||
unsigned long stabilityLastUpdateMs = 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;
|
||||
uint32_t charactersProcessed = 0;
|
||||
uint32_t sentencesPassed = 0;
|
||||
uint32_t sentencesFailed = 0;
|
||||
uint32_t baud = 0;
|
||||
bool hdopValid = false;
|
||||
double hdop = 0;
|
||||
unsigned long locationAgeMs = ULONG_MAX, timeAgeMs = ULONG_MAX;
|
||||
unsigned long lastByteMs = 0;
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
#include <Arduino.h>
|
||||
#include "gps.h"
|
||||
#include "imu.h"
|
||||
#include "rs485_transport.h"
|
||||
#include "sensor_node_config.h"
|
||||
#include "calibration.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);
|
||||
initLevelCalibration();
|
||||
imuState.online = initImu();
|
||||
Serial.println(imuState.online ? "BNO086 ready" : "BNO086 not detected");
|
||||
}
|
||||
|
||||
void loop() {
|
||||
updateGps(gpsSerial, gpsState); maintainImu(imuState); updateImu(imuState);
|
||||
pollRs485Commands(rs485Serial, imuState);
|
||||
String calibrationResult;
|
||||
if (updateLevelCalibration(imuState, calibrationResult)) {
|
||||
publishCalibrationResult(rs485Serial, calibrationResult);
|
||||
}
|
||||
unsigned long now = millis();
|
||||
if (now - lastTelemetryMs >= TELEMETRY_INTERVAL_MS) {
|
||||
publishSensorStatus(rs485Serial, imuState, gpsState); lastTelemetryMs = now;
|
||||
}
|
||||
delay(1);
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import sys
|
||||
|
||||
COMMANDS = [
|
||||
[sys.executable, "-m", "pytest", "tests/test_http_api_contract.py"],
|
||||
[sys.executable, "-m", "pytest", "tests/test_xiao_sensor_node_contract.py"],
|
||||
[sys.executable, "-m", "pytest", "tests/test_dashboard_source_contract.py"],
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
DASHBOARD = ROOT / "firmware" / "esp32-s3-dashboard" / "esp32-s3-dashboard.ino"
|
||||
SENSOR_NODE_RECEIVER = ROOT / "firmware" / "esp32-s3-dashboard" / "sensor_node_receiver.cpp"
|
||||
|
||||
|
||||
def test_setup_done_button_event_is_handled():
|
||||
@@ -15,3 +16,39 @@ def test_setup_done_button_event_is_handled():
|
||||
assert "LV_EVENT_CLICKED" in callback
|
||||
assert "lv_obj_add_flag(setup_keyboard, LV_OBJ_FLAG_HIDDEN);" in callback
|
||||
assert "lv_obj_add_event_cb(done_button, setup_keyboard_event_cb, LV_EVENT_CLICKED" in source
|
||||
|
||||
|
||||
def test_dashboard_can_is_passive_and_logs_changed_frames():
|
||||
source = DASHBOARD.read_text()
|
||||
|
||||
assert "TWAI_MODE_LISTEN_ONLY" in source
|
||||
assert "twai_transmit(" not in source
|
||||
assert "log_changed_can_frame(message);" in source
|
||||
assert 'Serial.print("CAN,");' in source
|
||||
assert 'can_serial_command.startsWith("TEMP,")' in source
|
||||
assert 'can_serial_command.startsWith("MARK,")' in source
|
||||
assert 'Serial.print("MARK,");' in source
|
||||
|
||||
|
||||
def test_dashboard_receives_sensor_node_rs485_for_diagnostics():
|
||||
dashboard = DASHBOARD.read_text()
|
||||
receiver = SENSOR_NODE_RECEIVER.read_text()
|
||||
|
||||
assert "initSensorNodeRs485();" in dashboard
|
||||
assert "processSensorNodeRs485();" in dashboard
|
||||
assert "Sensor Node RS485" in dashboard
|
||||
assert "sensorNodeRs485Connected()" in dashboard
|
||||
assert "SENSOR_NODE_RS485_RX_PIN = 43" in receiver
|
||||
assert "SENSOR_NODE_RS485_TX_PIN = 44" in receiver
|
||||
assert "SENSOR_NODE_RS485_BAUD = 115200" in receiver
|
||||
assert 'strcmp(type, "sensor_status")' in receiver
|
||||
assert 'doc["frame_sequence"]' in receiver
|
||||
assert "framesDropped" in receiver
|
||||
assert "bytesReceived++" in receiver
|
||||
assert r'text += "\n Raw bytes/age: "' in dashboard
|
||||
assert 'imu["yaw_degrees"]' in receiver
|
||||
assert 'gps["altitude_meters"]' in receiver
|
||||
assert 'JsonObject utc = gps["utc"]' in receiver
|
||||
assert r'text += "\n Yaw: "' in dashboard
|
||||
assert r'text += "\n Altitude: "' in dashboard
|
||||
assert r'text += "\n UTC: "' in dashboard
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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
|
||||
assert "USB_TELEMETRY_ENABLED" in text
|
||||
assert "serializeJson(doc, Serial)" in text
|
||||
assert 'createNestedObject("nmea")' 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"]:
|
||||
assert field 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_imu_does_not_redeclare_arduino_degrees_macro():
|
||||
imu = source("imu.cpp")
|
||||
assert "float degrees(" not in imu
|
||||
assert "radiansToDegrees" in imu
|
||||
|
||||
def test_imu_uses_verified_i2c_address():
|
||||
config = source("sensor_node_config.h")
|
||||
imu = source("imu.cpp")
|
||||
assert "BNO086_I2C_ADDRESS 0x4B" in config
|
||||
assert "findBnoAddress" not in imu
|
||||
assert "BNO086_INIT_ATTEMPTS" in imu
|
||||
assert "BNO086_RUNTIME_RETRY_MS" in imu
|
||||
assert "bno086.wasReset()" in imu
|
||||
for report in ["SH2_LINEAR_ACCELERATION", "SH2_GYROSCOPE_CALIBRATED", "SH2_GRAVITY", "SH2_STABILITY_CLASSIFIER"]:
|
||||
assert report in imu
|
||||
|
||||
def test_gps_uses_verified_uart_baud():
|
||||
config = source("sensor_node_config.h")
|
||||
gps = source("gps.cpp")
|
||||
assert "GPS_BAUD 38400" in config
|
||||
assert "baudHasValidNmea" not in gps
|
||||
|
||||
def test_level_calibration_is_persistent_and_non_blocking():
|
||||
calibration = source("calibration.cpp")
|
||||
transport = source("rs485_transport.cpp")
|
||||
loop = source("xiao-esp32c6-sensor-node.ino").split("void loop()", 1)[1]
|
||||
assert 'preferences.begin("sensor_cal"' in calibration
|
||||
assert 'putFloat("pitch_zero"' in calibration
|
||||
assert 'putFloat("roll_zero"' in calibration
|
||||
assert 'type == "calibrate_level"' in transport
|
||||
assert 'type == "clear_level_calibration"' in transport
|
||||
assert "updateLevelCalibration" in loop
|
||||
assert "delay(5000)" not in calibration
|
||||
|
||||
def test_rs485_echo_cannot_starve_sensor_loop():
|
||||
transport = source("rs485_transport.cpp")
|
||||
assert "bytesRemaining" in transport
|
||||
assert "discardCommandUntilNewline" in transport
|
||||
assert 'type == "sensor_status"' 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():
|
||||
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
|
||||
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Rank passive CAN bytes against timestamped scan-tool coolant readings."""
|
||||
|
||||
import argparse
|
||||
import math
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def correlation(xs, ys):
|
||||
if len(xs) < 3 or len(set(xs)) < 2 or len(set(ys)) < 2:
|
||||
return 0.0
|
||||
xm, ym = sum(xs) / len(xs), sum(ys) / len(ys)
|
||||
numerator = sum((x - xm) * (y - ym) for x, y in zip(xs, ys))
|
||||
scale = math.sqrt(sum((x - xm) ** 2 for x in xs) * sum((y - ym) ** 2 for y in ys))
|
||||
return numerator / scale if scale else 0.0
|
||||
|
||||
|
||||
def parse_capture(path):
|
||||
frames = defaultdict(list)
|
||||
temperatures = []
|
||||
for line in path.read_text(errors="replace").splitlines():
|
||||
parts = [part.strip() for part in line.split(",")]
|
||||
try:
|
||||
if len(parts) >= 3 and parts[0] == "TEMP":
|
||||
temperatures.append((int(parts[1]), float(parts[2])))
|
||||
elif len(parts) >= 6 and parts[0] == "CAN":
|
||||
timestamp, can_id, dlc = int(parts[1]), parts[2].upper(), int(parts[4])
|
||||
for index, value in enumerate(parts[5 : 5 + min(dlc, 8)]):
|
||||
if value:
|
||||
frames[(can_id, index)].append((timestamp, int(value, 16)))
|
||||
except ValueError:
|
||||
continue
|
||||
return frames, sorted(temperatures)
|
||||
|
||||
|
||||
def value_at(samples, timestamp):
|
||||
latest = None
|
||||
for sample_time, value in samples:
|
||||
if sample_time > timestamp:
|
||||
break
|
||||
latest = value
|
||||
return latest
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Rank CAN bytes against TEMP markers.")
|
||||
parser.add_argument("capture", type=Path)
|
||||
parser.add_argument("--limit", type=int, default=20)
|
||||
args = parser.parse_args()
|
||||
frames, temperatures = parse_capture(args.capture)
|
||||
if len(temperatures) < 3:
|
||||
parser.error("capture needs at least three TEMP markers")
|
||||
|
||||
ranked = []
|
||||
for (can_id, byte_index), samples in frames.items():
|
||||
pairs = [(value_at(samples, timestamp), temp) for timestamp, temp in temperatures]
|
||||
pairs = [(value, temp) for value, temp in pairs if value is not None]
|
||||
values, temps = [p[0] for p in pairs], [p[1] for p in pairs]
|
||||
score = correlation(values, temps)
|
||||
if len(values) >= 3 and len(set(values)) >= 2:
|
||||
ranked.append((abs(score), score, can_id, byte_index, len(values), min(values), max(values)))
|
||||
|
||||
ranked.sort(reverse=True)
|
||||
print("rank id byte corr samples raw-range")
|
||||
for rank, (_, score, can_id, byte_index, count, low, high) in enumerate(ranked[:args.limit], 1):
|
||||
print(f"{rank:>4} 0x{can_id:<4} {byte_index:>4} {score:>+7.3f} {count:>7} {low:>3}..{high:<3}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PATCH_COMMIT="a701059"
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
cd "$repo_root"
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Refusing to edit a dirty working tree." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${PATCH_COMMIT}^{commit}" 2>/dev/null; then
|
||||
git fetch origin
|
||||
fi
|
||||
|
||||
if ! git merge-base --is-ancestor "$PATCH_COMMIT" HEAD; then
|
||||
git cherry-pick "$PATCH_COMMIT"
|
||||
else
|
||||
echo "XIAO sensor-node patch is already applied."
|
||||
fi
|
||||
|
||||
if [[ ! -f .venv/bin/activate ]]; then
|
||||
echo "Missing .venv/bin/activate; create the project virtual environment first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source .venv/bin/activate
|
||||
python run_tests.py
|
||||
|
||||
git add -A
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "firmware: apply XIAO C6 sensor node patch"
|
||||
fi
|
||||
|
||||
branch="$(git branch --show-current)"
|
||||
if [[ -z "$branch" ]]; then
|
||||
echo "Cannot push from detached HEAD." >&2
|
||||
exit 1
|
||||
fi
|
||||
git push origin "$branch"
|
||||
Reference in New Issue
Block a user