From f145730b14522a7671831fe86f22b3cf6268f866 Mon Sep 17 00:00:00 2001 From: nick Date: Sun, 26 Jul 2026 18:10:50 -0600 Subject: [PATCH] dashboard: show sensor node RS485 diagnostics --- docs/CHANGELOG.md | 1 + docs/HARDWARE.md | 11 ++ docs/SENSOR_NODE_PROTOCOL.md | 2 + docs/dashboard-esp32s3.md | 2 + firmware/esp32-s3-dashboard/README.md | 8 ++ .../esp32-s3-dashboard/dashboard_config.h | 2 +- .../esp32-s3-dashboard/esp32-s3-dashboard.ino | 53 +++++++ .../sensor_node_receiver.cpp | 135 ++++++++++++++++++ .../esp32-s3-dashboard/sensor_node_receiver.h | 36 +++++ tests/test_dashboard_source_contract.py | 17 +++ 10 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 firmware/esp32-s3-dashboard/sensor_node_receiver.cpp create mode 100644 firmware/esp32-s3-dashboard/sensor_node_receiver.h diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 2adbf25..555ddaf 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -10,6 +10,7 @@ - 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. ## Unreleased diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md index 0a09877..cbd0997 100644 --- a/docs/HARDWARE.md +++ b/docs/HARDWARE.md @@ -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 diff --git a/docs/SENSOR_NODE_PROTOCOL.md b/docs/SENSOR_NODE_PROTOCOL.md index 1a6cbbe..e56267c 100644 --- a/docs/SENSOR_NODE_PROTOCOL.md +++ b/docs/SENSOR_NODE_PROTOCOL.md @@ -26,6 +26,8 @@ The receiver should parse only newline-complete JSON objects. A changed `boot_id 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 diff --git a/docs/dashboard-esp32s3.md b/docs/dashboard-esp32s3.md index 51b4e92..59a146a 100644 --- a/docs/dashboard-esp32s3.md +++ b/docs/dashboard-esp32s3.md @@ -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/accuracy/stability, and GPS fix/satellites/HDOP/location/speed. 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. diff --git a/firmware/esp32-s3-dashboard/README.md b/firmware/esp32-s3-dashboard/README.md index f50ffb1..ff2d685 100644 --- a/firmware/esp32-s3-dashboard/README.md +++ b/firmware/esp32-s3-dashboard/README.md @@ -76,6 +76,14 @@ The dashboard uses field-filtered `/api/v1/status` calls: 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, and GPS fix/satellites/HDOP/location/speed. 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 diff --git a/firmware/esp32-s3-dashboard/dashboard_config.h b/firmware/esp32-s3-dashboard/dashboard_config.h index 58ef5b7..313cc37 100644 --- a/firmware/esp32-s3-dashboard/dashboard_config.h +++ b/firmware/esp32-s3-dashboard/dashboard_config.h @@ -1,6 +1,6 @@ #pragma once -static const char *DASHBOARD_VERSION = "0.1.129-can-markers"; +static const char *DASHBOARD_VERSION = "0.1.130-rs485-diagnostics"; // First-boot defaults. Credentials are stored in Preferences/NVS after setup. static const char *DASHBOARD_DEFAULT_CARGO_WIFI_SSID = "OverlandController"; diff --git a/firmware/esp32-s3-dashboard/esp32-s3-dashboard.ino b/firmware/esp32-s3-dashboard/esp32-s3-dashboard.ino index aa75ccf..976384d 100644 --- a/firmware/esp32-s3-dashboard/esp32-s3-dashboard.ino +++ b/firmware/esp32-s3-dashboard/esp32-s3-dashboard.ino @@ -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; @@ -668,6 +669,56 @@ 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 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 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\nCargo ESP\n"; text += " Version: "; text += status_model.firmware_version; @@ -2624,6 +2675,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(); @@ -2659,6 +2711,7 @@ void loop() process_pending_relay_command(); poll_status_api(); process_obd_can(); + processSensorNodeRs485(); process_dashboard_page_switch(); diff --git a/firmware/esp32-s3-dashboard/sensor_node_receiver.cpp b/firmware/esp32-s3-dashboard/sensor_node_receiver.cpp new file mode 100644 index 0000000..3ab6a26 --- /dev/null +++ b/firmware/esp32-s3-dashboard/sensor_node_receiver.cpp @@ -0,0 +1,135 @@ +#include +#include + +#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")); + + 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.speedKph = gps["speed_kph"] | 0.0f; + 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(); + 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; +} + diff --git a/firmware/esp32-s3-dashboard/sensor_node_receiver.h b/firmware/esp32-s3-dashboard/sensor_node_receiver.h new file mode 100644 index 0000000..feed40c --- /dev/null +++ b/firmware/esp32-s3-dashboard/sensor_node_receiver.h @@ -0,0 +1,36 @@ +#pragma once + +#include + +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 nodeRestarts = 0; + uint32_t satellites = 0; + float pitchDegrees = 0; + float rollDegrees = 0; + float speedKph = 0; + float hdop = 0; + double latitude = 0; + double longitude = 0; + String stability = "unknown"; + String firmwareVersion; + unsigned long lastFrameMs = 0; +}; + +void initSensorNodeRs485(); +void processSensorNodeRs485(); +bool sensorNodeRs485Connected(); +const SensorNodeDashboardStatus &getSensorNodeStatus(); + diff --git a/tests/test_dashboard_source_contract.py b/tests/test_dashboard_source_contract.py index f7dc8a7..304682c 100644 --- a/tests/test_dashboard_source_contract.py +++ b/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(): @@ -27,3 +28,19 @@ def test_dashboard_can_is_passive_and_logs_changed_frames(): 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