Author SHA1 Message Date
nick ab5424d82e fix: restore verified XIAO RS485 pin direction 2026-07-26 18:38:14 -06:00
nick 753d13157e fix: correct XIAO RS485 UART pin mapping 2026-07-26 18:26:36 -06:00
nick fea549d1e2 dashboard: expand RS485 sensor diagnostics 2026-07-26 18:15:48 -06:00
nick f145730b14 dashboard: show sensor node RS485 diagnostics 2026-07-26 18:10:50 -06:00
nick 35c0721b47 firmware: harden XIAO RS485 telemetry 2026-07-26 18:01:58 -06:00
nick 8c2375d402 dashboard: add passive CAN capture markers 2026-07-26 15:26:42 -06:00
nick ed9611310e dashboard: expand CAN diagnostics 2026-07-26 14:49:19 -06:00
nick 6d61078484 firmware: expand sensor telemetry 2026-07-17 03:04:45 -06:00
nick b1a71e7bd1 firmware: recover slow BNO086 startup 2026-07-17 02:56:45 -06:00
nick dc7a7223fb firmware: use verified sensor settings 2026-07-17 02:51:40 -06:00
nick d5578ce796 firmware: discard partial RS485 echoes 2026-07-17 02:47:13 -06:00
nick 75845a18e3 firmware: prevent RS485 echo starvation 2026-07-17 02:44:47 -06:00
nick 4f96902068 firmware: add persistent level calibration 2026-07-17 02:37:49 -06:00
nick fb2ca464b9 firmware: auto-detect GPS UART baud 2026-07-17 02:24:40 -06:00
nick 1bf4530517 firmware: expose GPS NMEA diagnostics 2026-07-17 02:21:59 -06:00
nick 07559da6c0 firmware: mirror sensor telemetry to USB 2026-07-17 02:01:53 -06:00
nick a1a2f399d1 firmware: diagnose BNO086 I2C address 2026-07-17 01:59:02 -06:00
nick 1ada42b9fe firmware: avoid Arduino degrees macro collision 2026-07-17 01:56:46 -06:00
nick 4f5146eb82 tools: add sensor node patch script 2026-07-17 01:46:57 -06:00
nick a701059378 firmware: add XIAO C6 sensor node 2026-07-17 01:46:37 -06:00
nick 22a6d77be8 dashboard: fix setup keyboard done button 2026-07-17 00:30:20 -06:00
nick b51990f4ff Make cargo STA WiFi reconnect non-blocking 2026-07-06 09:52:29 -06:00
nick 563434a0a1 Add relay enable toggles to WebUI 2026-07-02 23:50:19 -06:00
nick 2e4fb4ad6c dashboard: add setup keyboard done handling 2026-07-02 00:54:46 -06:00
nick 9371171ebb dashboard: prefill setup defaults but require first save 2026-07-02 00:48:48 -06:00
nick c18bc71359 dashboard: add first-boot cargo WiFi setup 2026-07-02 00:38:41 -06:00
nick f2f452d04a merge: LilyGO T-Relay S3 cargo controller support 2026-07-02 00:29:51 -06:00
31 changed files with 1826 additions and 139 deletions
+1 -1
View File
@@ -396,7 +396,7 @@ Fields:
|---|---:|---|
| `id` | Yes | `relay_1` or `relay_2` |
| `name` | No | User display name |
| `enabled` | No | Configured enabled state |
| `enabled` | No | Configured enabled state. When set to `false`, the relay output is forced OFF and future relay control requests are rejected with `relay_disabled`. |
Returns the updated config.
+13
View File
@@ -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`.
+2
View File
@@ -69,6 +69,8 @@ HTTP endpoint:
POST /api/v1/config/relay
Disabled relays are forced OFF immediately, hidden from normal dashboard controls, and protected from `/api/v1/relay/set` commands until re-enabled.
## Temperature Sensor Configuration
Temperature sensors use generic firmware IDs:
+11
View File
@@ -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
+1
View File
@@ -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.
+37
View File
@@ -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.
+13
View File
@@ -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.
@@ -319,3 +321,14 @@ The diagnostics overlay shows dashboard version, uptime, heap, WiFi RSSI/IP, Car
- Sends standard 11-bit functional request ID `0x7DF` and accepts ECU responses from `0x7E8` through `0x7EF`.
- Diagnostics page shows TWAI state, coolant value, TX/RX counts, and last OBD status.
- Overview coolant gauge shows `--°` until a valid OBD response is received.
## Dashboard first-boot WiFi setup
The ESP32-S3 dashboard no longer hardcodes Cargo ESP WiFi credentials or the Cargo API base URL. On first boot, it shows a touchscreen setup page where the user enters:
- Cargo ESP WiFi SSID
- Cargo ESP WiFi password
- Cargo API base URL, for example `http://192.168.4.1`
The dashboard saves these values in ESP32 Preferences/NVS and builds all `/api/v1` endpoint URLs at runtime. The boot screen also provides a Setup button for changing saved settings later.
+43
View File
@@ -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.
+5 -17
View File
@@ -1,20 +1,8 @@
#pragma once
static const char *DASHBOARD_VERSION = "0.1.126-restore-boot-screen";
static const char *DASHBOARD_VERSION = "0.1.132-rs485-raw-diagnostics";
// Update these if your Cargo ESP AP credentials are different.
static const char *CARGO_WIFI_SSID = "OverlandController";
static const char *CARGO_WIFI_PASSWORD = "Ward1707";
//static const char *CARGO_WIFI_SSID = "WardAP";
//static const char *CARGO_WIFI_PASSWORD = "Ward5213";
static const char *CARGO_API_STATUS_URL = "http://192.168.4.1/api/v1/status";
static const char *CARGO_API_FAST_STATUS_URL = "http://192.168.4.1/api/v1/status?fields=battery,temps,relays";
static const char *CARGO_API_SLOW_STATUS_URL = "http://192.168.4.1/api/v1/status?fields=network,system,config";
static const char *CARGO_API_RELAY_SET_URL = "http://192.168.4.1/api/v1/relay/set";
//static const char *CARGO_API_STATUS_URL = "http://192.168.88.181/api/v1/status";
//static const char *CARGO_API_FAST_STATUS_URL = "http://192.168.88.181/api/v1/status?fields=battery,temps,relays";
//static const char *CARGO_API_SLOW_STATUS_URL = "http://192.168.88.181/api/v1/status?fields=network,system,config";
//static const char *CARGO_API_RELAY_SET_URL = "http://192.168.88.181/api/v1/relay/set";
// First-boot defaults. Credentials are stored in Preferences/NVS after setup.
static const char *DASHBOARD_DEFAULT_CARGO_WIFI_SSID = "OverlandController";
static const char *DASHBOARD_DEFAULT_CARGO_WIFI_PASSWORD = "";
static const char *DASHBOARD_DEFAULT_CARGO_API_BASE_URL = "http://192.168.4.1";
@@ -1,5 +1,6 @@
#include <Arduino.h>
#include <WiFi.h>
#include <Preferences.h>
#include <HTTPClient.h>
#include <ArduinoJson.h>
#include <esp_display_panel.hpp>
@@ -11,17 +12,49 @@
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;
static DashboardStatus status_model;
static Preferences dashboard_preferences;
static String cargo_wifi_ssid;
static String cargo_wifi_password;
static String cargo_api_base_url;
static String cargo_api_status_url;
static String cargo_api_fast_status_url;
static String cargo_api_slow_status_url;
static String cargo_api_relay_set_url;
static bool dashboard_configured = false;
static lv_obj_t *setup_ssid_textarea = nullptr;
static lv_obj_t *setup_password_textarea = nullptr;
static lv_obj_t *setup_api_textarea = nullptr;
static lv_obj_t *setup_status_label = nullptr;
static lv_obj_t *setup_keyboard = nullptr;
static constexpr gpio_num_t OBD_CAN_TX_PIN = GPIO_NUM_15;
static constexpr gpio_num_t OBD_CAN_RX_PIN = GPIO_NUM_16;
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;
@@ -29,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";
@@ -420,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();
@@ -447,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) {
@@ -508,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);
}
@@ -566,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;
@@ -604,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;
@@ -733,6 +970,7 @@ static void reset_battery_detail_widget_cache()
static void update_battery_detail_widgets();
static void show_boot_screen();
static void show_dashboard_setup_screen();
static void show_diagnostics_overlay();
static void close_diagnostics_overlay();
static void update_diagnostics_overlay();
@@ -1142,6 +1380,263 @@ static void update_relay_buttons()
lvgl_port_unlock();
}
static String normalize_cargo_api_base_url(String value)
{
value.trim();
if (value.length() == 0) {
value = DASHBOARD_DEFAULT_CARGO_API_BASE_URL;
}
if (!value.startsWith("http://") && !value.startsWith("https://")) {
value = "http://" + value;
}
while (value.endsWith("/")) {
value.remove(value.length() - 1);
}
return value;
}
static void rebuild_cargo_api_urls()
{
cargo_api_base_url = normalize_cargo_api_base_url(cargo_api_base_url);
cargo_api_status_url = cargo_api_base_url + "/api/v1/status";
cargo_api_fast_status_url = cargo_api_base_url + "/api/v1/status?fields=battery,temps,relays";
cargo_api_slow_status_url = cargo_api_base_url + "/api/v1/status?fields=network,system,config";
cargo_api_relay_set_url = cargo_api_base_url + "/api/v1/relay/set";
}
static void load_dashboard_connection_config()
{
dashboard_preferences.begin("dashcfg", false);
bool has_saved_ssid = dashboard_preferences.isKey("wifi_ssid");
cargo_wifi_ssid = dashboard_preferences.getString("wifi_ssid", DASHBOARD_DEFAULT_CARGO_WIFI_SSID);
cargo_wifi_password = dashboard_preferences.getString("wifi_pass", DASHBOARD_DEFAULT_CARGO_WIFI_PASSWORD);
cargo_api_base_url = dashboard_preferences.getString("api_base", DASHBOARD_DEFAULT_CARGO_API_BASE_URL);
rebuild_cargo_api_urls();
// Only skip setup after the user has explicitly saved settings.
dashboard_configured = has_saved_ssid;
Serial.print("Dashboard cargo SSID configured: ");
Serial.println(dashboard_configured ? "yes" : "no");
Serial.print("Dashboard cargo API base: ");
Serial.println(cargo_api_base_url);
}
static void save_dashboard_connection_config(const String &ssid, const String &password, const String &api_base)
{
cargo_wifi_ssid = ssid;
cargo_wifi_password = password;
cargo_api_base_url = api_base;
rebuild_cargo_api_urls();
dashboard_preferences.putString("wifi_ssid", cargo_wifi_ssid);
dashboard_preferences.putString("wifi_pass", cargo_wifi_password);
dashboard_preferences.putString("api_base", cargo_api_base_url);
dashboard_configured = cargo_wifi_ssid.length() > 0;
}
static void setup_keyboard_event_cb(lv_event_t *event)
{
lv_event_code_t code = lv_event_get_code(event);
if (code == LV_EVENT_READY || code == LV_EVENT_CANCEL || code == LV_EVENT_CLICKED) {
if (setup_keyboard != nullptr) {
lv_keyboard_set_textarea(setup_keyboard, nullptr);
lv_obj_add_flag(setup_keyboard, LV_OBJ_FLAG_HIDDEN);
}
}
}
static void setup_textarea_event_cb(lv_event_t *event)
{
lv_event_code_t code = lv_event_get_code(event);
lv_obj_t *target = lv_event_get_target(event);
if (code == LV_EVENT_FOCUSED || code == LV_EVENT_CLICKED) {
if (setup_keyboard != nullptr) {
lv_keyboard_set_textarea(setup_keyboard, target);
lv_obj_clear_flag(setup_keyboard, LV_OBJ_FLAG_HIDDEN);
}
}
}
static lv_obj_t *create_setup_textarea(lv_obj_t *parent, const char *label_text, const String &value, int y, bool password)
{
lv_obj_t *label = lv_label_create(parent);
lv_label_set_text(label, label_text);
lv_obj_set_style_text_color(label, lv_color_hex(0xDDE3EA), 0);
lv_obj_set_style_text_font(label, &lv_font_montserrat_26, 0);
lv_obj_align(label, LV_ALIGN_TOP_LEFT, 80, y);
lv_obj_t *textarea = lv_textarea_create(parent);
lv_obj_set_size(textarea, 760, 52);
lv_obj_align(textarea, LV_ALIGN_TOP_LEFT, 80, y + 32);
lv_textarea_set_one_line(textarea, true);
lv_textarea_set_text(textarea, value.c_str());
lv_textarea_set_password_mode(textarea, password);
lv_obj_set_style_text_font(textarea, &lv_font_montserrat_26, 0);
lv_obj_add_event_cb(textarea, setup_textarea_event_cb, LV_EVENT_ALL, nullptr);
return textarea;
}
static void setup_save_event_cb(lv_event_t *event)
{
if (lv_event_get_code(event) != LV_EVENT_CLICKED) {
return;
}
if (setup_keyboard != nullptr) {
lv_keyboard_set_textarea(setup_keyboard, nullptr);
lv_obj_add_flag(setup_keyboard, LV_OBJ_FLAG_HIDDEN);
}
String ssid = lv_textarea_get_text(setup_ssid_textarea);
String password = lv_textarea_get_text(setup_password_textarea);
String api_base = lv_textarea_get_text(setup_api_textarea);
ssid.trim();
api_base.trim();
if (ssid.length() == 0) {
if (setup_status_label != nullptr) {
lv_label_set_text(setup_status_label, "SSID is required.");
}
return;
}
save_dashboard_connection_config(ssid, password, api_base);
WiFi.disconnect(true);
was_wifi_connected = false;
overview_screen_created = false;
last_wifi_attempt_ms = 0;
last_status_poll_ms = 0;
last_metadata_poll_ms = 0;
metadata_refresh_requested = true;
lvgl_port_lock(-1);
if (dashboard_configured) {
show_boot_screen();
} else {
show_dashboard_setup_screen();
}
lvgl_port_unlock();
Serial.println("Dashboard connection settings saved.");
}
static void setup_reset_event_cb(lv_event_t *event)
{
if (lv_event_get_code(event) != LV_EVENT_CLICKED) {
return;
}
if (setup_keyboard != nullptr) {
lv_keyboard_set_textarea(setup_keyboard, nullptr);
lv_obj_add_flag(setup_keyboard, LV_OBJ_FLAG_HIDDEN);
}
dashboard_preferences.clear();
cargo_wifi_ssid = "";
cargo_wifi_password = "";
cargo_api_base_url = DASHBOARD_DEFAULT_CARGO_API_BASE_URL;
rebuild_cargo_api_urls();
dashboard_configured = false;
WiFi.disconnect(true);
lv_textarea_set_text(setup_ssid_textarea, "");
lv_textarea_set_text(setup_password_textarea, "");
lv_textarea_set_text(setup_api_textarea, cargo_api_base_url.c_str());
if (setup_status_label != nullptr) {
lv_label_set_text(setup_status_label, "Saved settings cleared.");
}
}
static void show_dashboard_setup_screen()
{
lv_obj_t *screen = lv_scr_act();
lv_obj_clean(screen);
lv_obj_set_style_bg_color(screen, lv_color_hex(0x101418), 0);
lv_obj_t *title = lv_label_create(screen);
lv_label_set_text(title, "Dashboard Setup");
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_48, 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 28);
lv_obj_t *subtitle = lv_label_create(screen);
lv_label_set_text(subtitle, "Enter Cargo ESP WiFi and API settings.");
lv_obj_set_style_text_color(subtitle, lv_color_hex(0xB8C0C8), 0);
lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_26, 0);
lv_obj_align(subtitle, LV_ALIGN_TOP_MID, 0, 86);
setup_ssid_textarea = create_setup_textarea(screen, "Cargo WiFi SSID", cargo_wifi_ssid, 130, false);
setup_password_textarea = create_setup_textarea(screen, "Cargo WiFi Password", cargo_wifi_password, 220, true);
setup_api_textarea = create_setup_textarea(screen, "Cargo API Base URL", cargo_api_base_url, 310, false);
lv_obj_t *save_button = lv_btn_create(screen);
lv_obj_set_size(save_button, 210, 58);
lv_obj_align(save_button, LV_ALIGN_TOP_LEFT, 80, 408);
lv_obj_add_event_cb(save_button, setup_save_event_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t *save_label = lv_label_create(save_button);
lv_label_set_text(save_label, "Save");
lv_obj_center(save_label);
lv_obj_t *clear_button = lv_btn_create(screen);
lv_obj_set_size(clear_button, 210, 58);
lv_obj_align(clear_button, LV_ALIGN_TOP_LEFT, 310, 408);
lv_obj_add_event_cb(clear_button, setup_reset_event_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t *clear_label = lv_label_create(clear_button);
lv_label_set_text(clear_label, "Clear");
lv_obj_center(clear_label);
setup_status_label = lv_label_create(screen);
lv_label_set_text(setup_status_label, dashboard_configured ? "Settings loaded." : "First boot: setup required.");
lv_obj_set_style_text_color(setup_status_label, lv_color_hex(0xDDE3EA), 0);
lv_obj_set_style_text_font(setup_status_label, &lv_font_montserrat_26, 0);
lv_obj_set_width(setup_status_label, 420);
lv_label_set_long_mode(setup_status_label, LV_LABEL_LONG_WRAP);
lv_obj_align(setup_status_label, LV_ALIGN_TOP_LEFT, 548, 410);
lv_obj_t *done_button = lv_btn_create(screen);
lv_obj_set_size(done_button, 180, 48);
lv_obj_align(done_button, LV_ALIGN_BOTTOM_RIGHT, -32, -188);
lv_obj_add_event_cb(done_button, setup_keyboard_event_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t *done_label = lv_label_create(done_button);
lv_label_set_text(done_label, "Done");
lv_obj_center(done_label);
setup_keyboard = lv_keyboard_create(screen);
lv_obj_set_size(setup_keyboard, 1024, 180);
lv_obj_align(setup_keyboard, LV_ALIGN_BOTTOM_MID, 0, 0);
lv_obj_add_event_cb(setup_keyboard, setup_keyboard_event_cb, LV_EVENT_ALL, nullptr);
lv_obj_add_flag(setup_keyboard, LV_OBJ_FLAG_HIDDEN);
}
static void setup_button_event_cb(lv_event_t *event)
{
if (lv_event_get_code(event) != LV_EVENT_CLICKED) {
return;
}
lvgl_port_lock(-1);
show_dashboard_setup_screen();
lvgl_port_unlock();
}
static bool post_relay_state(const String &relay_id, bool state)
{
if (api_request_in_progress || WiFi.status() != WL_CONNECTED) {
@@ -1158,12 +1653,12 @@ static bool post_relay_state(const String &relay_id, bool state)
serializeJson(request, payload);
Serial.print("POST ");
Serial.println(CARGO_API_RELAY_SET_URL);
Serial.println(cargo_api_relay_set_url);
Serial.println(payload);
HTTPClient http;
http.setTimeout(1200);
http.begin(CARGO_API_RELAY_SET_URL);
http.begin(cargo_api_relay_set_url);
http.setReuse(false);
http.addHeader("Connection", "close");
http.addHeader("Content-Type", "application/json");
@@ -1280,6 +1775,10 @@ static void process_pending_relay_command()
static void connect_wifi_if_needed()
{
if (!dashboard_configured) {
return;
}
if (WiFi.status() == WL_CONNECTED) {
return;
}
@@ -1291,10 +1790,10 @@ static void connect_wifi_if_needed()
last_wifi_attempt_ms = millis();
Serial.print("Connecting to Cargo ESP AP: ");
Serial.println(CARGO_WIFI_SSID);
Serial.println(cargo_wifi_ssid);
WiFi.mode(WIFI_STA);
WiFi.begin(CARGO_WIFI_SSID, CARGO_WIFI_PASSWORD);
WiFi.begin(cargo_wifi_ssid.c_str(), cargo_wifi_password.c_str());
}
static void update_system_status_label()
@@ -1835,13 +2334,13 @@ static void poll_status_api()
if (fast_status_refresh_requested || millis() - last_status_poll_ms >= 1500) {
fast_status_refresh_requested = false;
last_status_poll_ms = millis();
fetch_status_fields(CARGO_API_FAST_STATUS_URL, "fast status");
fetch_status_fields(cargo_api_fast_status_url.c_str(), "fast status");
}
if (metadata_refresh_requested || millis() - last_metadata_poll_ms >= 30000) {
metadata_refresh_requested = false;
last_metadata_poll_ms = millis();
fetch_status_fields(CARGO_API_SLOW_STATUS_URL, "slow status");
fetch_status_fields(cargo_api_slow_status_url.c_str(), "slow status");
}
}
@@ -1908,6 +2407,15 @@ static void show_boot_screen()
lv_obj_set_width(api, 700);
lv_obj_set_style_text_align(api, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(api, LV_ALIGN_CENTER, 0, 118);
lv_obj_t *setup_button = lv_btn_create(screen);
lv_obj_set_size(setup_button, 180, 54);
lv_obj_align(setup_button, LV_ALIGN_BOTTOM_MID, 0, -34);
lv_obj_add_event_cb(setup_button, setup_button_event_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t *setup_label = lv_label_create(setup_button);
lv_label_set_text(setup_label, "Setup");
lv_obj_center(setup_label);
}
static void create_overland_overview_screen()
@@ -2180,6 +2688,9 @@ 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();
Board *board = new Board();
board->init();
@@ -2213,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();
@@ -1249,11 +1249,20 @@ function renderConfigControls(data){
<div class="configrow">
<label>${r.id}</label>
<input id="relayName${i}" placeholder="Relay name" oninput="markConfigFieldTouched('relayName${i}')">
<label>${r.enabled?"Enabled":"Disabled"}</label>
<label class="tempCheck">
<input id="relayEnabled${i}" type="checkbox" onchange="markConfigFieldTouched('relayEnabled${i}')">
Enabled
</label>
</div>`).join("");
}
relays.forEach((r,i)=>setConfigValue("relayName"+i,r.name||""));
relays.forEach((r,i)=>{
setConfigValue("relayName"+i,r.name||"");
const enabled=$("relayEnabled"+i);
if(enabled && !configTouched["relayEnabled"+i] && document.activeElement!==enabled){
enabled.checked=!!r.enabled;
}
});
}
setConfigValue("tempEnabledCount", temps.filter(t=>t.enabled).length);
@@ -1359,8 +1368,8 @@ async function saveRelayConfig(){
const relays=data.config?.relays||[];
for(let i=0;i<relays.length;i++){
const name=$("relayName"+i)?.value.trim();
if(!name) continue;
const name=$("relayName"+i)?.value.trim() || relays[i].name || relays[i].id;
const enabled=$("relayEnabled"+i)?.checked ?? relays[i].enabled;
await fetch(api("/config/relay"),{
method:"POST",
@@ -1368,14 +1377,14 @@ async function saveRelayConfig(){
body:JSON.stringify({
id:relays[i].id,
name:name,
enabled:relays[i].enabled
enabled:enabled
})
});
}
await fetch(api("/config/save"),{method:"POST"});
await load();
alert("Relay names saved");
alert("Relay settings saved");
}
async function saveTempConfig(){
@@ -1543,9 +1552,19 @@ String staPasswords[MAX_WIFI_NETWORKS];
int staPriorities[MAX_WIFI_NETWORKS];
int wifiNetworkCount = 0;
String activeStaSsid = "";
bool staAttemptTried[MAX_WIFI_NETWORKS] = {false, false, false};
bool staConnectInProgress = false;
int activeStaAttemptIndex = -1;
unsigned long staConnectAttemptStarted = 0;
unsigned long lastStaReconnectAttempt = 0;
const unsigned long STA_CONNECT_TIMEOUT_MS = 8000;
const unsigned long STA_RECONNECT_INTERVAL_MS = 30000;
void resetStaAttemptRound();
bool beginNextStaWifiAttempt();
void connectStaWifi();
void maintainStaWifi();
void loadWifiConfig() {
wifiPrefs.begin("wifi", true);
@@ -2110,85 +2129,110 @@ int findNextWifiIndexByPriority(bool tried[]) {
return bestIndex;
}
void resetStaAttemptRound() {
for (int i = 0; i < MAX_WIFI_NETWORKS; i++) {
staAttemptTried[i] = false;
}
staConnectInProgress = false;
activeStaAttemptIndex = -1;
}
bool beginNextStaWifiAttempt() {
if (wifiNetworkCount <= 0) {
Serial.println("STA WiFi not configured.");
return false;
}
int i = findNextWifiIndexByPriority(staAttemptTried);
if (i < 0) {
return false;
}
staAttemptTried[i] = true;
activeStaAttemptIndex = i;
activeStaSsid = "";
Serial.print("Starting non-blocking STA WiFi attempt priority ");
Serial.print(staPriorities[i]);
Serial.print(" to: ");
Serial.println(staSsids[i]);
WiFi.disconnect(false, false);
WiFi.mode(WIFI_AP_STA);
WiFi.begin(staSsids[i].c_str(), staPasswords[i].c_str());
staConnectAttemptStarted = millis();
staConnectInProgress = true;
return true;
}
void connectStaWifi() {
if (wifiNetworkCount <= 0) {
Serial.println("STA WiFi not configured.");
return;
}
WiFi.disconnect(false);
activeStaSsid = "";
resetStaAttemptRound();
lastStaReconnectAttempt = millis();
bool tried[MAX_WIFI_NETWORKS] = {false, false, false};
for (int attempt = 0; attempt < wifiNetworkCount; attempt++) {
int i = findNextWifiIndexByPriority(tried);
if (i < 0) {
break;
}
tried[i] = true;
Serial.print("Connecting STA WiFi priority ");
Serial.print(staPriorities[i]);
Serial.print(" to: ");
Serial.println(staSsids[i]);
WiFi.disconnect(false, false);
delay(500);
WiFi.mode(WIFI_AP_STA);
delay(250);
WiFi.begin(staSsids[i].c_str(), staPasswords[i].c_str());
unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 8000) {
server.handleClient();
oledLoop();
delay(100);
Serial.print(".");
}
Serial.println();
if (WiFi.status() == WL_CONNECTED) {
activeStaSsid = staSsids[i];
Serial.println("STA WiFi connected");
Serial.print("STA SSID: ");
Serial.println(activeStaSsid);
Serial.print("STA IP: ");
Serial.println(WiFi.localIP());
return;
}
wl_status_t status = WiFi.status();
Serial.print("STA WiFi attempt failed. Status: ");
Serial.println((int)status);
wifi_ap_record_t apInfo;
if (esp_wifi_sta_get_ap_info(&apInfo) == ESP_OK) {
Serial.print("Connected AP RSSI: ");
Serial.println(apInfo.rssi);
} else {
Serial.println("No AP association info available.");
}
if (!beginNextStaWifiAttempt()) {
Serial.println("No saved STA WiFi networks available. AP remains available.");
}
Serial.println("All STA WiFi attempts failed. AP remains available.");
}
void maintainStaWifi() {
if (wifiNetworkCount <= 0) return;
if (WiFi.status() == WL_CONNECTED) return;
if (WiFi.status() == WL_CONNECTED) {
if (activeStaSsid.length() == 0) {
if (activeStaAttemptIndex >= 0 && activeStaAttemptIndex < wifiNetworkCount) {
activeStaSsid = staSsids[activeStaAttemptIndex];
} else {
activeStaSsid = WiFi.SSID();
}
Serial.println("STA WiFi connected");
Serial.print("STA SSID: ");
Serial.println(activeStaSsid);
Serial.print("STA IP: ");
Serial.println(WiFi.localIP());
}
staConnectInProgress = false;
activeStaAttemptIndex = -1;
return;
}
if (staConnectInProgress) {
if (millis() - staConnectAttemptStarted < STA_CONNECT_TIMEOUT_MS) {
return;
}
Serial.print("STA WiFi attempt timed out. Status: ");
Serial.println((int)WiFi.status());
WiFi.disconnect(false, false);
staConnectInProgress = false;
activeStaAttemptIndex = -1;
if (beginNextStaWifiAttempt()) {
return;
}
Serial.println("All STA WiFi attempts failed. AP remains available.");
resetStaAttemptRound();
lastStaReconnectAttempt = millis();
return;
}
activeStaSsid = "";
if (millis() - lastStaReconnectAttempt < STA_RECONNECT_INTERVAL_MS) return;
Serial.println("STA WiFi disconnected. Trying saved networks by priority in background...");
resetStaAttemptRound();
lastStaReconnectAttempt = millis();
Serial.println("STA WiFi disconnected. Trying saved networks by priority...");
connectStaWifi();
beginNextStaWifiAttempt();
}
void printNetworkStatus() {
@@ -2643,9 +2687,19 @@ void sendError(Stream& output, const char* message) {
int findRelayConfigIndex(const String& id);
bool relayExistsAndAvailable(const String& relayId) {
int index = findRelayConfigIndex(relayId);
return index >= 0 && appConfig.relays[index].available;
}
bool relayIsEnabled(const String& relayId) {
int index = findRelayConfigIndex(relayId);
return index >= 0 && appConfig.relays[index].enabled;
}
bool setRelayById(const String& relayId, bool enabled) {
int index = findRelayConfigIndex(relayId);
if (index < 0 || !appConfig.relays[index].available) {
if (index < 0 || !appConfig.relays[index].available || !appConfig.relays[index].enabled) {
return false;
}
@@ -3651,7 +3705,13 @@ void handleUpdateRelayConfig() {
}
if (doc["name"].is<String>()) appConfig.relays[index].name = doc["name"].as<String>();
if (doc["enabled"].is<bool>()) appConfig.relays[index].enabled = doc["enabled"].as<bool>();
if (doc["enabled"].is<bool>()) {
appConfig.relays[index].enabled = doc["enabled"].as<bool>();
if (!appConfig.relays[index].enabled) {
relays.state[index] = false;
updateRelayOutputs();
}
}
sendOkConfig();
}
@@ -3994,10 +4054,18 @@ void handleSetRelayPost() {
bool state = doc["state"] | false;
unsigned long applyStartMs = millis();
if (!setRelayById(relayId, state)) {
if (!relayExistsAndAvailable(relayId)) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}");
return;
}
if (!relayIsEnabled(relayId)) {
server.send(409, "application/json", "{\"ok\":false,\"error\":\"relay_disabled\"}");
return;
}
if (!setRelayById(relayId, state)) {
server.send(409, "application/json", "{\"ok\":false,\"error\":\"relay_disabled\"}");
return;
}
unsigned long appliedMs = millis() - applyStartMs;
DynamicJsonDocument response(512);
@@ -4052,10 +4120,18 @@ void handleGenericRelayRoute() {
return;
}
if (!setRelayById(relayId, enabled)) {
if (!relayExistsAndAvailable(relayId)) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}");
return;
}
if (!relayIsEnabled(relayId)) {
server.send(409, "application/json", "{\"ok\":false,\"error\":\"relay_disabled\"}");
return;
}
if (!setRelayById(relayId, enabled)) {
server.send(409, "application/json", "{\"ok\":false,\"error\":\"relay_disabled\"}");
return;
}
DynamicJsonDocument doc(512);
doc["ok"] = true;
@@ -4206,6 +4282,7 @@ void setup() {
void loop() {
server.handleClient();
oledLoop();
maintainStaWifi();
@@ -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);
+35
View File
@@ -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(); }
}
+6
View File
@@ -0,0 +1,6 @@
#pragma once
#include <HardwareSerial.h>
#include "sensor_state.h"
void initGps(HardwareSerial& serial);
void updateGps(HardwareSerial& serial, GpsState& state);
+100
View File
@@ -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;
}
+5
View File
@@ -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);
}
+2
View File
@@ -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"],
]
+54
View File
@@ -0,0 +1,54 @@
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():
source = DASHBOARD.read_text()
callback_start = source.index("static void setup_keyboard_event_cb")
callback_end = source.index("static void setup_textarea_event_cb", callback_start)
callback = source[callback_start:callback_end]
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
+3
View File
@@ -248,6 +248,8 @@ def test_relay_set_contract_fixtures_and_firmware_errors():
source = firmware_source()
assert '"invalid_json"' in source
assert '"unknown_relay"' in source
assert "relay_disabled" in source
assert "relayIsEnabled" in source
def test_relay_api_is_output_count_agnostic():
@@ -259,6 +261,7 @@ def test_relay_api_is_output_count_agnostic():
assert 'relay["hardware_channel"] = appConfig.relays[i].hardwareChannel;' in source
assert 'relay["available"] = appConfig.relays[i].available;' in source
assert 'relays.state[index] = enabled;' in source
assert '!appConfig.relays[index].enabled' in source
assert 'relays.relay1' not in source
assert 'relays.relay2' not in source
+78
View File
@@ -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
+72
View File
@@ -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())
+42
View File
@@ -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"