49 Commits
Author SHA1 Message Date
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
nick fece1f39d0 test: skip OLED setup assertion for LilyGO cargo board 2026-07-02 00:27:13 -06:00
nick de6a3f5f92 test: allow LilyGO cargo firmware without OLED setup page 2026-07-02 00:25:56 -06:00
nick d17699a7be firmware: finalize LilyGO cargo cleanup 2026-07-02 00:24:22 -06:00
nick 3b57761498 api: restore missing v1 routes for LilyGO 2026-07-02 00:08:08 -06:00
nick 9b5581b2d8 change WiFi settings back to direct 2026-07-01 23:56:17 -06:00
nick 97048b8ceb debug: add AP config POST route to LilyGO isolation 2026-07-01 23:49:30 -06:00
nick 95fa78ee64 debug: try STA reconnect 5s after LilyGO boot 2026-07-01 23:43:33 -06:00
nick 983f9cae61 hardware: move ignition sense to LilyGO GPIO39 2026-07-01 23:37:11 -06:00
nick 83a392567c debug: restore ignition input only 2026-07-01 23:34:08 -06:00
nick c8d0e4a23f Revert "debug: restore ignition pin and OLED setup only"
This reverts commit ac6b0475ff.
2026-07-01 23:31:55 -06:00
nick ac6b0475ff debug: restore ignition pin and OLED setup only 2026-07-01 23:29:34 -06:00
nick 2112e6b58c Revert "debug: restore final setup bits in LilyGO isolation"
This reverts commit 617fef32ce.
2026-07-01 23:26:05 -06:00
nick 617fef32ce debug: restore final setup bits in LilyGO isolation 2026-07-01 23:22:42 -06:00
nick ebed07500a debug: add OTA routes using original handlers 2026-07-01 23:10:21 -06:00
nick c602187163 debug: add OTA routes to LilyGO isolation 2026-07-01 22:51:50 -06:00
nick 3ec837a08c debug: add OTA routes to LilyGO isolation 2026-07-01 22:36:56 -06:00
nick 0b55ede1fc debug: add sensor update timing globals 2026-07-01 00:46:46 -06:00
nick 0b04dcf89a debug: add sensor updates to LilyGO isolation loop 2026-07-01 00:42:35 -06:00
nick 737ec8c10d debug: add temp scan routes to LilyGO isolation 2026-07-01 00:33:35 -06:00
nick f2bdf36337 hardware: move DS18B20 data to LilyGO GPIO21 2026-07-01 00:17:25 -06:00
nick 3a05cb8894 ui: add BMS scan button 2026-07-01 00:05:16 -06:00
nick a1a19270d7 debug: add legacy routes to LilyGO isolation 2026-06-30 23:57:58 -06:00
nick f249e2ef6b debug: add BMS routes to LilyGO isolation 2026-06-30 23:41:41 -06:00
nick 6ae396f084 debug: protect config inputs from status refresh 2026-06-30 23:37:33 -06:00
nick 7fc2d35bd0 debug: delay BMS updates 30s after boot 2026-06-30 23:26:59 -06:00
nick a954755c46 debug: add BMS update loop to LilyGO isolation build 2026-06-30 23:21:05 -06:00
nick c2f8f1d9a5 debug: add BMS init to LilyGO isolation build 2026-06-30 23:18:13 -06:00
nick b301400482 debug: re-enable STA maintenance in LilyGO isolation build 2026-06-30 23:13:01 -06:00
nick 3e56143d4b debug: add WiFi write routes to LilyGO isolation 2026-06-30 23:08:27 -06:00
nick 025e95b32f debug: test AP STA mode without reconnect logic 2026-06-30 22:48:24 -06:00
nick 4f24e6f87a debug: test dashboard HTML route on LilyGO 2026-06-30 22:43:25 -06:00
nick b21e41341c debug: add config write routes to active LilyGO isolation setup 2026-06-30 22:40:10 -06:00
nick 03cad0481b debug: add read-only config routes to LilyGO isolation 2026-06-30 22:18:17 -06:00
nick 1f54f39956 debug: add relay set route to active isolation setup 2026-06-30 22:12:55 -06:00
nick acf98eb8e2 debug: add relay set route to clean isolation 2026-06-30 22:04:06 -06:00
nick 5571aca726 debug: add relay routes to clean isolation 2026-06-30 22:02:04 -06:00
nick 43d201d040 debug: add basic status routes to clean isolation 2026-06-30 21:59:32 -06:00
nick b556d338d7 debug: clean LilyGO setup isolation baseline 2026-06-30 02:40:59 -06:00
nick 4cbd2165b2 firmware: migrate cargo controller to LilyGO T-Relay S3 2026-06-29 22:57:02 -06:00
nick 406033bec8 config: expand relay model from 2 to 6 outputs 2026-06-29 22:52:05 -06:00
34 changed files with 1027 additions and 242 deletions
+1 -1
View File
@@ -396,7 +396,7 @@ Fields:
|---|---:|---| |---|---:|---|
| `id` | Yes | `relay_1` or `relay_2` | | `id` | Yes | `relay_1` or `relay_2` |
| `name` | No | User display name | | `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. Returns the updated config.
+4
View File
@@ -2,6 +2,10 @@
## Unreleased ## Unreleased
- Added standalone XIAO ESP32-C6 BNO086/MAX-M10S sensor-node firmware and a versioned RS485 telemetry contract without changing Cargo `/api/v1` or JBD/NimBLE behavior.
## Unreleased
### Added ### Added
- Cargo ESP local OTA firmware upload page at `/ota`. - Cargo ESP local OTA firmware upload page at `/ota`.
- Cargo ESP OTA API endpoint at `POST /api/v1/system/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 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 Sensor Configuration
Temperature sensors use generic firmware IDs: Temperature sensors use generic firmware IDs:
+27
View File
@@ -239,3 +239,30 @@ Important guardrails:
## M9N GPS Module ## M9N GPS Module
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. 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.
### LilyGO T-Relay-S3 6-relay cargo controller
The cargo ESP now targets the LilyGO T-Relay-S3 6-relay board.
Relay control is handled through the onboard 74HC595 shift register, not direct ESP32 GPIO relay outputs.
| Function | GPIO |
| --- | --- |
| 74HC595 DATA | GPIO7 |
| 74HC595 CLOCK | GPIO5 |
| 74HC595 LATCH | GPIO6 |
| 74HC595 nOE | GPIO4 |
| Firmware ID | LilyGO relay channel | Shift-register output |
| --- | --- | --- |
| relay_1 | CH1 | SR1 |
| relay_2 | CH2 | SR2 |
| relay_3 | CH3 | SR3 |
| relay_4 | CH4 | SR4 |
| relay_5 | CH5 | SR5 |
| relay_6 | CH6 | SR6 |
GPIO4 is reserved for relay output-enable on this board, so the DS18B20 OneWire bus is moved to GPIO14.
Avoid GPIO35-GPIO37 on the LilyGO T-Relay-S3 because they are used by Octal SPI flash.
+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. 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. - `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. - `waveshare-5b-bringup-checklist.md` - Bench bring-up checklist for the Waveshare ESP32-S3 Touch LCD 5B dashboard.
+17
View File
@@ -0,0 +1,17 @@
# XIAO ESP32-C6 Sensor Node Protocol
The XIAO sensor node reads the BNO086 and MAX-M10S and publishes vehicle telemetry over half-duplex RS485 at 115200 8-N-1, one JSON object per line. It owns no control state. Cargo remains the controller, HTTP server, and `/api/v1` source of truth; JBD/NimBLE behavior is unchanged.
The expansion-board pins are D2 driver enable, D4 TX, and D5 RX. Only physical bus ends should be terminated. Confirm A/B polarity because vendor labels vary.
## Schema version 1
`sensor_status` includes `node_id`, `firmware_version`, `uptime_ms`, and:
- `imu`: `online`, `valid`, `accuracy`, quaternion, yaw, pitch, and roll in degrees.
- `gps`: `online`, `fix`, satellites, latitude, longitude, altitude meters, speed kph, course degrees, and structured UTC.
Consumers must check `valid` or `fix` before displaying measurements and ignore unknown fields. The native breakout coordinate frame is reported initially; installation-specific axis remapping should follow physical calibration.
This link does not replace dashboard-to-Cargo WiFi/HTTP. A future dashboard receiver may display this telemetry without moving relay, BMS, alarm, or configuration authority away from Cargo.
+2 -2
View File
@@ -157,7 +157,7 @@ Expected output object fields:
Example 2-channel profile: Example 2-channel profile:
{ {
"hardware_profile": "generic_esp32_2ch_relay", "hardware_profile": "lilygo_t_relay_s3_6ch",
"outputs": [ "outputs": [
{"id": 1, "name": "Fridge", "role": "fridge", "hardware_channel": 1}, {"id": 1, "name": "Fridge", "role": "fridge", "hardware_channel": 1},
{"id": 2, "name": "Starlink", "role": "starlink", "hardware_channel": 2} {"id": 2, "name": "Starlink", "role": "starlink", "hardware_channel": 2}
@@ -190,7 +190,7 @@ The Cargo ESP firmware now exposes relay/output state as an output-count-agnosti
The current active hardware profile remains: The current active hardware profile remains:
generic_esp32_2ch_relay lilygo_t_relay_s3_6ch
The API includes: The API includes:
+11
View File
@@ -319,3 +319,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`. - 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. - 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. - 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.
+1 -1
View File
@@ -270,7 +270,7 @@ The Cargo ESP API now represents relays/outputs as a hardware-profile-aware list
Current active hardware profile: Current active hardware profile:
generic_esp32_2ch_relay lilygo_t_relay_s3_6ch
Future planned profile: Future planned profile:
+10
View File
@@ -213,3 +213,13 @@ Rationale:
- Improves perceived responsiveness without a major communication rewrite. - Improves perceived responsiveness without a major communication rewrite.
- Supports future live gauges better than fixed-interval polling. - Supports future live gauges better than fixed-interval polling.
## LilyGO T-Relay-S3 6-relay migration
- [x] Expand cargo relay model from 2 outputs to 6 outputs.
- [x] Replace direct GPIO relay writes with LilyGO 74HC595 shift-register writes.
- [x] Map relay_1 through relay_6 to CH1 through CH6.
- [x] Move DS18B20 OneWire default from GPIO4 to GPIO14 because GPIO4 is relay nOE.
- [ ] Bench test relay boot behavior with no vehicle loads connected.
- [ ] Confirm CH1-CH6 order with a meter or test lamp before wiring vehicle loads.
+5 -17
View File
@@ -1,20 +1,8 @@
#pragma once #pragma once
static const char *DASHBOARD_VERSION = "0.1.126-restore-boot-screen"; static const char *DASHBOARD_VERSION = "0.1.127-first-boot-wifi";
// Update these if your Cargo ESP AP credentials are different. // First-boot defaults. Credentials are stored in Preferences/NVS after setup.
static const char *CARGO_WIFI_SSID = "OverlandController"; static const char *DASHBOARD_DEFAULT_CARGO_WIFI_SSID = "OverlandController";
static const char *CARGO_WIFI_PASSWORD = "overland1234"; static const char *DASHBOARD_DEFAULT_CARGO_WIFI_PASSWORD = "";
static const char *DASHBOARD_DEFAULT_CARGO_API_BASE_URL = "http://192.168.4.1";
//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.108/api/v1/status";
//static const char *CARGO_API_FAST_STATUS_URL = "http://192.168.88.108/api/v1/status?fields=battery,temps,relays";
//static const char *CARGO_API_SLOW_STATUS_URL = "http://192.168.88.108/api/v1/status?fields=network,system,config";
//static const char *CARGO_API_RELAY_SET_URL = "http://192.168.88.108/api/v1/relay/set";
@@ -1,5 +1,6 @@
#include <Arduino.h> #include <Arduino.h>
#include <WiFi.h> #include <WiFi.h>
#include <Preferences.h>
#include <HTTPClient.h> #include <HTTPClient.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <esp_display_panel.hpp> #include <esp_display_panel.hpp>
@@ -16,6 +17,23 @@ using namespace esp_panel::drivers;
using namespace esp_panel::board; using namespace esp_panel::board;
static DashboardStatus status_model; 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_TX_PIN = GPIO_NUM_15;
static constexpr gpio_num_t OBD_CAN_RX_PIN = GPIO_NUM_16; 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_REQUEST_ID = 0x7DF;
@@ -733,6 +751,7 @@ static void reset_battery_detail_widget_cache()
static void update_battery_detail_widgets(); static void update_battery_detail_widgets();
static void show_boot_screen(); static void show_boot_screen();
static void show_dashboard_setup_screen();
static void show_diagnostics_overlay(); static void show_diagnostics_overlay();
static void close_diagnostics_overlay(); static void close_diagnostics_overlay();
static void update_diagnostics_overlay(); static void update_diagnostics_overlay();
@@ -1142,6 +1161,263 @@ static void update_relay_buttons()
lvgl_port_unlock(); 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) static bool post_relay_state(const String &relay_id, bool state)
{ {
if (api_request_in_progress || WiFi.status() != WL_CONNECTED) { if (api_request_in_progress || WiFi.status() != WL_CONNECTED) {
@@ -1158,12 +1434,12 @@ static bool post_relay_state(const String &relay_id, bool state)
serializeJson(request, payload); serializeJson(request, payload);
Serial.print("POST "); Serial.print("POST ");
Serial.println(CARGO_API_RELAY_SET_URL); Serial.println(cargo_api_relay_set_url);
Serial.println(payload); Serial.println(payload);
HTTPClient http; HTTPClient http;
http.setTimeout(1200); http.setTimeout(1200);
http.begin(CARGO_API_RELAY_SET_URL); http.begin(cargo_api_relay_set_url);
http.setReuse(false); http.setReuse(false);
http.addHeader("Connection", "close"); http.addHeader("Connection", "close");
http.addHeader("Content-Type", "application/json"); http.addHeader("Content-Type", "application/json");
@@ -1280,6 +1556,10 @@ static void process_pending_relay_command()
static void connect_wifi_if_needed() static void connect_wifi_if_needed()
{ {
if (!dashboard_configured) {
return;
}
if (WiFi.status() == WL_CONNECTED) { if (WiFi.status() == WL_CONNECTED) {
return; return;
} }
@@ -1291,10 +1571,10 @@ static void connect_wifi_if_needed()
last_wifi_attempt_ms = millis(); last_wifi_attempt_ms = millis();
Serial.print("Connecting to Cargo ESP AP: "); Serial.print("Connecting to Cargo ESP AP: ");
Serial.println(CARGO_WIFI_SSID); Serial.println(cargo_wifi_ssid);
WiFi.mode(WIFI_STA); 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() static void update_system_status_label()
@@ -1835,13 +2115,13 @@ static void poll_status_api()
if (fast_status_refresh_requested || millis() - last_status_poll_ms >= 1500) { if (fast_status_refresh_requested || millis() - last_status_poll_ms >= 1500) {
fast_status_refresh_requested = false; fast_status_refresh_requested = false;
last_status_poll_ms = millis(); 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) { if (metadata_refresh_requested || millis() - last_metadata_poll_ms >= 30000) {
metadata_refresh_requested = false; metadata_refresh_requested = false;
last_metadata_poll_ms = millis(); 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 +2188,15 @@ static void show_boot_screen()
lv_obj_set_width(api, 700); lv_obj_set_width(api, 700);
lv_obj_set_style_text_align(api, LV_TEXT_ALIGN_CENTER, 0); lv_obj_set_style_text_align(api, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(api, LV_ALIGN_CENTER, 0, 118); 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() static void create_overland_overview_screen()
@@ -2181,6 +2470,8 @@ void setup()
Serial.println("Display: 1024x600"); Serial.println("Display: 1024x600");
Serial.println("API Base: /api/v1"); Serial.println("API Base: /api/v1");
load_dashboard_connection_config();
Board *board = new Board(); Board *board = new Board();
board->init(); board->init();
@@ -7,28 +7,27 @@ AppConfig appConfig;
static Preferences preferences; static Preferences preferences;
static RelayConfig defaultRelayConfig(uint8_t channel, const char *name, const char *role) {
return {
"relay_" + String(channel),
name,
role,
channel,
RELAY_SHIFT_REGISTER_PIN,
true,
true
};
}
void loadDefaultConfig() { void loadDefaultConfig() {
appConfig.deviceName = "Overland Controller"; appConfig.deviceName = "Overland Controller";
appConfig.relays[0] = { appConfig.relays[0] = defaultRelayConfig(1, "Relay 1", "aux");
"relay_1", appConfig.relays[1] = defaultRelayConfig(2, "Relay 2", "fridge");
"Relay 1", appConfig.relays[2] = defaultRelayConfig(3, "Relay 3", "starlink");
"aux", appConfig.relays[3] = defaultRelayConfig(4, "Relay 4", "aux");
1, appConfig.relays[4] = defaultRelayConfig(5, "Relay 5", "aux");
RELAY_1_PIN, appConfig.relays[5] = defaultRelayConfig(6, "Relay 6", "aux");
true,
true
};
appConfig.relays[1] = {
"relay_2",
"Relay 2",
"fridge",
2,
RELAY_2_PIN,
true,
true
};
appConfig.tempSensorCount = 4; appConfig.tempSensorCount = 4;
@@ -194,7 +193,7 @@ void printConfig() {
Serial.print(appConfig.relays[i].role); Serial.print(appConfig.relays[i].role);
Serial.print(" / channel "); Serial.print(" / channel ");
Serial.print(appConfig.relays[i].hardwareChannel); Serial.print(appConfig.relays[i].hardwareChannel);
Serial.print(" / GPIO "); Serial.print(" / channel ");
Serial.print(appConfig.relays[i].pin); Serial.print(appConfig.relays[i].pin);
Serial.print(" / available "); Serial.print(" / available ");
Serial.print(appConfig.relays[i].available ? "true" : "false"); Serial.print(appConfig.relays[i].available ? "true" : "false");
@@ -2,9 +2,9 @@
#include <Arduino.h> #include <Arduino.h>
#define MAX_RELAYS 2 #define MAX_RELAYS 6
#define MAX_TEMP_SENSORS 4 #define MAX_TEMP_SENSORS 4
#define HARDWARE_PROFILE "generic_esp32_2ch_relay" #define HARDWARE_PROFILE "lilygo_trelay_s3_6ch"
struct RelayConfig { struct RelayConfig {
String id; String id;
+13 -12
View File
@@ -2,24 +2,25 @@
#define DEVICE_NAME "Overland Controller" #define DEVICE_NAME "Overland Controller"
// Relay Outputs // LilyGO T-Relay S3 6-relay board.
#define RELAY_1_PIN 16 // Relays are driven through onboard 74HC595 shift register.
#define RELAY_2_PIN 17 #define LILYGO_T_RELAY_S3_DATA_PIN 7
#define LILYGO_T_RELAY_S3_CLOCK_PIN 5
#define LILYGO_T_RELAY_S3_LATCH_PIN 6
#define LILYGO_T_RELAY_S3_OE_PIN 4
#define LILYGO_T_RELAY_S3_RELAY_COUNT 6
// DS18B20 Bus // Compatibility placeholder only. Relays are not direct GPIO outputs.
#define ONEWIRE_PIN 4 #define RELAY_SHIFT_REGISTER_PIN 255
// Ignition Sense // GPIO4 is used by the LilyGO relay shift-register OE pin.
#define IGNITION_PIN 34 #define ONEWIRE_PIN 15
#define IGNITION_PIN 39
// Cargo OLED service/setup display
#define OLED_I2C_SDA_PIN 21 #define OLED_I2C_SDA_PIN 21
#define OLED_I2C_SCL_PIN 22 #define OLED_I2C_SCL_PIN 22
#define OLED_SETUP_BUTTON_PIN 25 #define OLED_SETUP_BUTTON_PIN 25
// Dashboard communication is WiFi/HTTP.
// Legacy UART dashboard support is retired from active hardware use.
#define DASHBOARD_UART_ENABLED 0 #define DASHBOARD_UART_ENABLED 0
#define DASHBOARD_UART_BAUD 115200 #define DASHBOARD_UART_BAUD 115200
// RS-485/MAX3485 is fallback only and not currently planned.
@@ -1,4 +1,5 @@
#include <WiFi.h> #include <WiFi.h>
#include <esp_heap_caps.h>
#include <WebServer.h> #include <WebServer.h>
#include <Preferences.h> #include <Preferences.h>
#include <esp_wifi.h> #include <esp_wifi.h>
@@ -638,12 +639,15 @@ button[onclick^="scan"]{
<input id="bmsAddressTypeInput" placeholder="Address type: public or random"> <input id="bmsAddressTypeInput" placeholder="Address type: public or random">
<div class="btnrow"> <div class="btnrow">
<button class="save" onclick="saveBmsFullConfig()">Save BMS</button> <button class="save" onclick="saveBmsFullConfig()">Save BMS</button>
<button onclick="scanBmsDevices()">Scan BMS</button>
<button class="off" onclick="setBmsEnabled(false)">Disable BMS</button> <button class="off" onclick="setBmsEnabled(false)">Disable BMS</button>
</div> </div>
<div class="btnrow"> <div class="btnrow">
<button onclick="setBmsEnabled(true)">Enable BMS</button> <button onclick="setBmsEnabled(true)">Enable BMS</button>
</div> </div>
<div class="row"><span class="muted">BMS Polling</span><span class="v" id="bmsEnabledText">--</span></div> <div class="row"><span class="muted">BMS Polling</span><span class="v" id="bmsEnabledText">--</span></div>
<div id="bmsScanStatus" class="muted"></div>
<div id="bmsScanResults"></div>
</div> </div>
</section> </section>
</div> </div>
@@ -1045,12 +1049,12 @@ function render(data){
function populateSetupFields(data){ function populateSetupFields(data){
const cfg=data.config||{}; const cfg=data.config||{};
if($("deviceNameInput")) $("deviceNameInput").value=cfg.device_name||""; setConfigValue("deviceNameInput", cfg.device_name||"");
const bms=cfg.bms||{}; const bms=cfg.bms||{};
if($("bmsNameInput")) $("bmsNameInput").value=bms.name||""; setConfigValue("bmsNameInput", bms.name||"");
if($("bmsAddressInput")) $("bmsAddressInput").value=bms.address||""; setConfigValue("bmsAddressInput", bms.address||"");
if($("bmsAddressTypeInput")) $("bmsAddressTypeInput").value=bms.address_type||"public"; setConfigValue("bmsAddressTypeInput", bms.address_type||"public");
} }
async function saveDeviceConfig(){ async function saveDeviceConfig(){
@@ -1069,6 +1073,46 @@ async function saveDeviceConfig(){
clearConfigTouched(["deviceNameInput"]); clearConfigTouched(["deviceNameInput"]);
} }
async function scanBmsDevices(){
const status=$("bmsScanStatus");
const box=$("bmsScanResults");
if(status) status.textContent="Scanning for BLE BMS devices...";
if(box) box.innerHTML="";
try{
const res=await fetch(api("/bms/scan"),{method:"POST"});
const data=await res.json();
const devices=data.devices||[];
if(status) status.textContent=devices.length ? `Found ${devices.length} device(s).` : "No BLE devices found.";
if(box){
box.innerHTML=devices.map(d=>`
<div class="configrow">
<div>
<div><b>${d.name||"Unnamed BLE device"}</b></div>
<div class="muted">${d.address||""} RSSI ${d.rssi??"--"}</div>
</div>
<button onclick="selectScannedBms('${d.address||""}','${(d.name||"BMS").replace(/'/g,"\\'")}')">Select</button>
</div>
`).join("");
}
}catch(e){
if(status) status.textContent="BMS scan failed.";
}
}
function selectScannedBms(address,name){
setConfigValue("bmsNameInput",name||"BMS",true);
setConfigValue("bmsAddressInput",address||"",true);
setConfigValue("bmsAddressTypeInput","public",true);
markConfigFieldTouched("bmsNameInput");
markConfigFieldTouched("bmsAddressInput");
markConfigFieldTouched("bmsAddressTypeInput");
}
async function saveBmsFullConfig(){ async function saveBmsFullConfig(){
const name=$("bmsNameInput")?.value.trim()||"BMS"; const name=$("bmsNameInput")?.value.trim()||"BMS";
const address=$("bmsAddressInput")?.value.trim()||""; const address=$("bmsAddressInput")?.value.trim()||"";
@@ -1198,12 +1242,27 @@ function renderConfigControls(data){
const relayBox=$("relayConfigList"); const relayBox=$("relayConfigList");
if(relayBox){ if(relayBox){
relayBox.innerHTML=relays.map((r,i)=>` const signature=relays.map(r=>r.id).join("|");
<div class="configrow"> if(relayBox.dataset.signature!==signature){
<label>${r.id}</label> relayBox.dataset.signature=signature;
<input id="relayName${i}" value="${r.name||""}" placeholder="Relay name"> relayBox.innerHTML=relays.map((r,i)=>`
<label>${r.enabled?"Enabled":"Disabled"}</label> <div class="configrow">
</div>`).join(""); <label>${r.id}</label>
<input id="relayName${i}" placeholder="Relay name" oninput="markConfigFieldTouched('relayName${i}')">
<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||"");
const enabled=$("relayEnabled"+i);
if(enabled && !configTouched["relayEnabled"+i] && document.activeElement!==enabled){
enabled.checked=!!r.enabled;
}
});
} }
setConfigValue("tempEnabledCount", temps.filter(t=>t.enabled).length); setConfigValue("tempEnabledCount", temps.filter(t=>t.enabled).length);
@@ -1309,8 +1368,8 @@ async function saveRelayConfig(){
const relays=data.config?.relays||[]; const relays=data.config?.relays||[];
for(let i=0;i<relays.length;i++){ for(let i=0;i<relays.length;i++){
const name=$("relayName"+i)?.value.trim(); const name=$("relayName"+i)?.value.trim() || relays[i].name || relays[i].id;
if(!name) continue; const enabled=$("relayEnabled"+i)?.checked ?? relays[i].enabled;
await fetch(api("/config/relay"),{ await fetch(api("/config/relay"),{
method:"POST", method:"POST",
@@ -1318,14 +1377,14 @@ async function saveRelayConfig(){
body:JSON.stringify({ body:JSON.stringify({
id:relays[i].id, id:relays[i].id,
name:name, name:name,
enabled:relays[i].enabled enabled:enabled
}) })
}); });
} }
await fetch(api("/config/save"),{method:"POST"}); await fetch(api("/config/save"),{method:"POST"});
await load(); await load();
alert("Relay names saved"); alert("Relay settings saved");
} }
async function saveTempConfig(){ async function saveTempConfig(){
@@ -1459,6 +1518,9 @@ setInterval(load,3000);
WebServer server(80); WebServer server(80);
unsigned long lastSensorUpdate = 0;
const unsigned long SENSOR_INTERVAL = 5000;
#define API_V1(path) "/api/v1" path #define API_V1(path) "/api/v1" path
int findTempConfigIndexById(const String& id) { int findTempConfigIndexById(const String& id) {
@@ -1490,9 +1552,19 @@ String staPasswords[MAX_WIFI_NETWORKS];
int staPriorities[MAX_WIFI_NETWORKS]; int staPriorities[MAX_WIFI_NETWORKS];
int wifiNetworkCount = 0; int wifiNetworkCount = 0;
String activeStaSsid = ""; String activeStaSsid = "";
bool staAttemptTried[MAX_WIFI_NETWORKS] = {false, false, false};
bool staConnectInProgress = false;
int activeStaAttemptIndex = -1;
unsigned long staConnectAttemptStarted = 0;
unsigned long lastStaReconnectAttempt = 0; unsigned long lastStaReconnectAttempt = 0;
const unsigned long STA_CONNECT_TIMEOUT_MS = 8000;
const unsigned long STA_RECONNECT_INTERVAL_MS = 30000; const unsigned long STA_RECONNECT_INTERVAL_MS = 30000;
void resetStaAttemptRound();
bool beginNextStaWifiAttempt();
void connectStaWifi();
void maintainStaWifi();
void loadWifiConfig() { void loadWifiConfig() {
wifiPrefs.begin("wifi", true); wifiPrefs.begin("wifi", true);
@@ -1649,7 +1721,6 @@ void sendSimpleJsonError(int status, const char* error, const char* detail) {
} }
const char OTA_HTML[] PROGMEM = R"rawliteral( const char OTA_HTML[] PROGMEM = R"rawliteral(
<!doctype html> <!doctype html>
<html> <html>
@@ -2058,85 +2129,110 @@ int findNextWifiIndexByPriority(bool tried[]) {
return bestIndex; 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() { void connectStaWifi() {
if (wifiNetworkCount <= 0) { if (wifiNetworkCount <= 0) {
Serial.println("STA WiFi not configured."); Serial.println("STA WiFi not configured.");
return; return;
} }
WiFi.disconnect(false); resetStaAttemptRound();
activeStaSsid = ""; lastStaReconnectAttempt = millis();
bool tried[MAX_WIFI_NETWORKS] = {false, false, false}; if (!beginNextStaWifiAttempt()) {
Serial.println("No saved STA WiFi networks available. AP remains available.");
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.");
}
} }
Serial.println("All STA WiFi attempts failed. AP remains available.");
} }
void maintainStaWifi() { void maintainStaWifi() {
if (wifiNetworkCount <= 0) return; 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; if (millis() - lastStaReconnectAttempt < STA_RECONNECT_INTERVAL_MS) return;
Serial.println("STA WiFi disconnected. Trying saved networks by priority in background...");
resetStaAttemptRound();
lastStaReconnectAttempt = millis(); lastStaReconnectAttempt = millis();
beginNextStaWifiAttempt();
Serial.println("STA WiFi disconnected. Trying saved networks by priority...");
connectStaWifi();
} }
void printNetworkStatus() { void printNetworkStatus() {
@@ -2428,9 +2524,6 @@ int findStatusFieldIndex(const String& field) {
} }
void buildStatusDocument(JsonDocument& doc) { void buildStatusDocument(JsonDocument& doc) {
doc["type"] = MSG_STATUS_RESPONSE; doc["type"] = MSG_STATUS_RESPONSE;
doc["timestamp"] = millis(); doc["timestamp"] = millis();
@@ -2594,9 +2687,19 @@ void sendError(Stream& output, const char* message) {
int findRelayConfigIndex(const String& id); 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) { bool setRelayById(const String& relayId, bool enabled) {
int index = findRelayConfigIndex(relayId); int index = findRelayConfigIndex(relayId);
if (index < 0 || !appConfig.relays[index].available) { if (index < 0 || !appConfig.relays[index].available || !appConfig.relays[index].enabled) {
return false; return false;
} }
@@ -3602,7 +3705,13 @@ void handleUpdateRelayConfig() {
} }
if (doc["name"].is<String>()) appConfig.relays[index].name = doc["name"].as<String>(); 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(); sendOkConfig();
} }
@@ -3656,7 +3765,6 @@ void handleFactoryResetConfig() {
} }
void sendWifiConfigJson() { void sendWifiConfigJson() {
DynamicJsonDocument doc(2048); DynamicJsonDocument doc(2048);
@@ -3946,10 +4054,18 @@ void handleSetRelayPost() {
bool state = doc["state"] | false; bool state = doc["state"] | false;
unsigned long applyStartMs = millis(); unsigned long applyStartMs = millis();
if (!setRelayById(relayId, state)) { if (!relayExistsAndAvailable(relayId)) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}"); server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}");
return; 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; unsigned long appliedMs = millis() - applyStartMs;
DynamicJsonDocument response(512); DynamicJsonDocument response(512);
@@ -4004,10 +4120,18 @@ void handleGenericRelayRoute() {
return; return;
} }
if (!setRelayById(relayId, enabled)) { if (!relayExistsAndAvailable(relayId)) {
server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}"); server.send(404, "application/json", "{\"ok\":false,\"error\":\"unknown_relay\"}");
return; 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); DynamicJsonDocument doc(512);
doc["ok"] = true; doc["ok"] = true;
@@ -4067,67 +4191,53 @@ void handleStatus() {
server.send(200, "application/json", output); server.send(200, "application/json", output);
} }
void setup() { void setup() {
Serial.begin(115200); Serial.begin(115200);
delay(2000);
#if DASHBOARD_UART_ENABLED
DashboardSerial.begin(
DASHBOARD_UART_BAUD,
SERIAL_8N1,
0,
0
);
Serial.println("Legacy dashboard UART enabled");
#endif
Serial.println(); Serial.println();
Serial.println("=================================="); Serial.println("Overland Controller LilyGO T-Relay S3 Booting");
Serial.println("Overland Controller Booting");
Serial.println("==================================");
oledBegin();
loadConfig(); loadConfig();
printConfig(); printConfig();
pinMode(IGNITION_PIN, INPUT); pinMode(IGNITION_PIN, INPUT);
initRelays(); initRelays();
initSensors(); initSensors();
initBms(); initBms();
loadWifiConfig(); loadWifiConfig();
loadApConfig(); loadApConfig();
WiFi.persistent(false);
WiFi.setSleep(false);
WiFi.mode(WIFI_AP_STA); WiFi.mode(WIFI_AP_STA);
startAccessPoint();
oledShowSetupPage();
// Do not block setup on STA WiFi. Start the AP and web server first so the startAccessPoint();
// local Web UI/API remains available even when no saved WiFi networks exist.
lastStaReconnectAttempt = millis() - STA_RECONNECT_INTERVAL_MS;
server.on("/", HTTP_GET, []() { server.on("/", HTTP_GET, []() {
server.send_P(200, "text/html", INDEX_HTML); server.send_P(200, "text/html", INDEX_HTML);
}); });
server.on("/ota", HTTP_GET, handleOtaPage);
server.on(API_V1("/system/ota"), HTTP_POST, handleOtaUploadDone, handleOtaUploadStream);
server.on(API_V1("/status"), handleStatus);
server.on(API_V1("/health"), HTTP_GET, handleHealth); server.on(API_V1("/health"), HTTP_GET, handleHealth);
server.on(API_V1("/capabilities"), HTTP_GET, handleCapabilities); server.on(API_V1("/capabilities"), HTTP_GET, handleCapabilities);
server.on(API_V1("/status"), handleStatus);
server.on(API_V1("/relay/set"), HTTP_POST, handleSetRelayPost); server.on(API_V1("/relay/set"), HTTP_POST, handleSetRelayPost);
server.on(API_V1("/config"), HTTP_GET, handleGetConfig); server.on(API_V1("/config"), HTTP_GET, handleGetConfig);
server.on(API_V1("/config/export"), HTTP_GET, handleExportConfig); server.on(API_V1("/config/export"), HTTP_GET, handleExportConfig);
server.on(API_V1("/config/import"), HTTP_POST, handleImportConfig);
server.on(API_V1("/config/wifi"), HTTP_GET, handleGetWifiConfig); server.on(API_V1("/config/wifi"), HTTP_GET, handleGetWifiConfig);
server.on(API_V1("/config/ap"), HTTP_GET, handleGetApConfig); server.on(API_V1("/config/ap"), HTTP_GET, handleGetApConfig);
server.on(API_V1("/config/ap"), HTTP_POST, handleUpdateApConfig); server.on(API_V1("/config/ap"), HTTP_POST, handleUpdateApConfig);
server.on(API_V1("/config/ap/reset"), HTTP_POST, handleResetApConfig);
server.on(API_V1("/config/wifi"), HTTP_POST, handleUpdateWifiConfig);
server.on(API_V1("/wifi/connect"), HTTP_POST, handleWifiConnect);
server.on(API_V1("/wifi/clear"), HTTP_POST, handleWifiClear);
server.on(API_V1("/config/device"), HTTP_POST, handleUpdateDeviceConfig); server.on(API_V1("/config/device"), HTTP_POST, handleUpdateDeviceConfig);
server.on(API_V1("/config/relay"), HTTP_POST, handleUpdateRelayConfig); server.on(API_V1("/config/relay"), HTTP_POST, handleUpdateRelayConfig);
@@ -4136,67 +4246,60 @@ void setup() {
server.on(API_V1("/config/factory-reset"), HTTP_POST, handleFactoryResetConfig); server.on(API_V1("/config/factory-reset"), HTTP_POST, handleFactoryResetConfig);
server.on(API_V1("/config/save"), HTTP_POST, handleSaveConfig); server.on(API_V1("/config/save"), HTTP_POST, handleSaveConfig);
server.on(API_V1("/config/wifi"), HTTP_POST, handleUpdateWifiConfig);
server.on(API_V1("/wifi/connect"), HTTP_POST, handleWifiConnect);
server.on(API_V1("/wifi/clear"), HTTP_POST, handleWifiClear);
server.on(API_V1("/bms/scan"), HTTP_POST, handleBleScan);
server.on(API_V1("/bms/select"), HTTP_POST, handleSelectBms);
server.on(API_V1("/temps/scan"), HTTP_POST, handleTempScan); server.on(API_V1("/temps/scan"), HTTP_POST, handleTempScan);
server.on(API_V1("/temps/assign"), HTTP_POST, handleTempAssign); server.on(API_V1("/temps/assign"), HTTP_POST, handleTempAssign);
server.on(API_V1("/temps/clear"), HTTP_POST, handleTempClear); server.on(API_V1("/temps/clear"), HTTP_POST, handleTempClear);
server.on("/update", HTTP_GET, handleOtaPage);
server.on("/ota", HTTP_GET, handleOtaPage);
server.on(API_V1("/system/ota"), HTTP_POST,
handleOtaUploadDone,
handleOtaUploadStream);
server.on(API_V1("/config/import"), HTTP_POST, handleImportConfig);
server.on(API_V1("/config/ap/reset"), HTTP_POST, handleResetApConfig);
server.on(API_V1("/bms/setup/enter"), HTTP_POST, handleEnterBmsSetup); server.on(API_V1("/bms/setup/enter"), HTTP_POST, handleEnterBmsSetup);
server.on(API_V1("/bms/setup/exit"), HTTP_POST, handleExitBmsSetup); server.on(API_V1("/bms/setup/exit"), HTTP_POST, handleExitBmsSetup);
server.on(API_V1("/bms/scan"), HTTP_POST, handleBleScan);
server.on(API_V1("/bms/select"), HTTP_POST, handleSelectBms);
// Compatibility aliases for pre-versioned local clients.
server.on("/status", handleStatus);
server.on("/relay/set", HTTP_POST, handleSetRelayPost);
server.on("/relay/relay_1/on", HTTP_GET, handleGenericRelayRoute);
server.on("/relay/relay_1/off", HTTP_GET, handleGenericRelayRoute);
server.on("/relay/relay_2/on", HTTP_GET, handleGenericRelayRoute);
server.on("/relay/relay_2/off", HTTP_GET, handleGenericRelayRoute);
server.on("/config", HTTP_GET, handleGetConfig);
server.on("/config/wifi", HTTP_GET, handleGetWifiConfig);
server.on("/config/wifi", HTTP_POST, handleUpdateWifiConfig);
server.on("/wifi/connect", HTTP_POST, handleWifiConnect);
server.on("/wifi/clear", HTTP_POST, handleWifiClear);
server.on("/config/device", HTTP_POST, handleUpdateDeviceConfig);
server.on("/config/relay", HTTP_POST, handleUpdateRelayConfig);
server.on("/config/bms", HTTP_POST, handleUpdateBmsConfig);
server.on("/config/temp", HTTP_POST, handleUpdateTempSensorConfig);
server.on("/config/factory-reset", HTTP_POST, handleFactoryResetConfig);
server.on("/config/save", HTTP_POST, handleSaveConfig);
server.on("/temps/scan", HTTP_POST, handleTempScan);
server.on("/temps/assign", HTTP_POST, handleTempAssign);
server.on("/temps/clear", HTTP_POST, handleTempClear);
server.on("/bms/setup/enter", HTTP_POST, handleEnterBmsSetup);
server.on("/bms/setup/exit", HTTP_POST, handleExitBmsSetup);
server.on("/bms/scan", HTTP_POST, handleBleScan);
server.on("/bms/select", HTTP_POST, handleSelectBms);
server.begin(); server.begin();
Serial.println("Web Server Started"); Serial.println("Web Server Started");
lastStaReconnectAttempt = millis() - STA_RECONNECT_INTERVAL_MS + 5000;
} }
void loop() { void loop() {
server.handleClient(); server.handleClient();
maintainStaWifi();
handleDebugSerial();
updateBms();
updateAlarms();
#if DASHBOARD_UART_ENABLED
pollDashboardUart();
#endif
oledLoop(); oledLoop();
static unsigned long lastSensorUpdate = 0; maintainStaWifi();
if (millis() - lastSensorUpdate > 5000) { if (millis() - lastSensorUpdate > SENSOR_INTERVAL) {
updateSensors(); updateSensors();
lastSensorUpdate = millis(); lastSensorUpdate = millis();
logDebug("Sensor Update"); logDebug("Sensor Update");
} }
if (millis() > 30000) {
updateBms();
}
#if DASHBOARD_UART_ENABLED
pollDashboardUart();
#endif
delay(2);
} }
+42 -7
View File
@@ -5,20 +5,55 @@
RelayState relays; RelayState relays;
static uint8_t relayShiftRegisterState = 0;
static void writeRelayShiftRegister()
{
digitalWrite(LILYGO_T_RELAY_S3_LATCH_PIN, LOW);
shiftOut(
LILYGO_T_RELAY_S3_DATA_PIN,
LILYGO_T_RELAY_S3_CLOCK_PIN,
MSBFIRST,
relayShiftRegisterState
);
digitalWrite(LILYGO_T_RELAY_S3_LATCH_PIN, HIGH);
}
void initRelays() { void initRelays() {
for (int i = 0; i < MAX_RELAYS; i++) { pinMode(LILYGO_T_RELAY_S3_DATA_PIN, OUTPUT);
if (appConfig.relays[i].available) { pinMode(LILYGO_T_RELAY_S3_CLOCK_PIN, OUTPUT);
pinMode(appConfig.relays[i].pin, OUTPUT); pinMode(LILYGO_T_RELAY_S3_LATCH_PIN, OUTPUT);
} pinMode(LILYGO_T_RELAY_S3_OE_PIN, OUTPUT);
}
// Keep outputs disabled while clearing the shift register.
digitalWrite(LILYGO_T_RELAY_S3_OE_PIN, HIGH);
relayShiftRegisterState = 0;
writeRelayShiftRegister();
// Enable relay outputs after known-off state is written.
digitalWrite(LILYGO_T_RELAY_S3_OE_PIN, LOW);
updateRelayOutputs(); updateRelayOutputs();
} }
void updateRelayOutputs() { void updateRelayOutputs() {
uint8_t nextState = 0;
for (int i = 0; i < MAX_RELAYS; i++) { for (int i = 0; i < MAX_RELAYS; i++) {
if (appConfig.relays[i].available) { if (!appConfig.relays[i].available) {
digitalWrite(appConfig.relays[i].pin, relays.state[i]); continue;
}
const uint8_t channel = appConfig.relays[i].hardwareChannel;
if (channel < 1 || channel > LILYGO_T_RELAY_S3_RELAY_COUNT) {
continue;
}
if (relays.state[i]) {
nextState |= (1U << (channel - 1));
} }
} }
relayShiftRegisterState = nextState;
writeRelayShiftRegister();
} }
@@ -0,0 +1,15 @@
# XIAO ESP32-C6 Sensor Node
This Arduino sketch reads a BNO086 IMU and MAX-M10S GPS and publishes versioned, newline-delimited JSON over the Seeed RS485 expansion board. It produces vehicle telemetry only; Cargo remains the controller and `/api/v1` authority.
| Function | XIAO pin |
| --- | --- |
| RS485 DE / TX / RX | D2 / D4 / D5 |
| BNO086 SDA / SCL | D7 / D8 |
| GPS TX to XIAO RX | D9 |
| GPS RX from XIAO TX | D10 |
Power both sensors from 3.3V with shared ground. Required Arduino libraries are Adafruit BNO08x, TinyGPSPlus, and ArduinoJson 6.x. Enable **USB CDC On Boot** for diagnostics. GPS uses hardware UART0 and RS485 uses hardware UART1.
The node sends `sensor_status` at 115200 baud every 200 ms. See `docs/SENSOR_NODE_PROTOCOL.md`. The initial firmware is transmit-only; add a dashboard receiver after bench-testing the node, RS485 polarity, and sensor axes.
+19
View File
@@ -0,0 +1,19 @@
#include <TinyGPSPlus.h>
#include "gps.h"
#include "sensor_node_config.h"
namespace { TinyGPSPlus parser; }
void initGps(HardwareSerial& serial) { serial.begin(GPS_BAUD, SERIAL_8N1, GPS_RX_PIN, GPS_TX_PIN); }
void updateGps(HardwareSerial& serial, GpsState& s) {
while (serial.available()) { s.lastByteMs = millis(); parser.encode(serial.read()); }
s.online = s.lastByteMs && millis() - s.lastByteMs <= SENSOR_STALE_MS;
s.fix = parser.location.isValid() && parser.location.age() <= SENSOR_STALE_MS;
s.satellites = parser.satellites.isValid() ? parser.satellites.value() : 0;
if (parser.location.isValid()) { s.latitude = parser.location.lat(); s.longitude = parser.location.lng(); }
if (parser.altitude.isValid()) s.altitudeMeters = parser.altitude.meters();
if (parser.speed.isValid()) s.speedKph = parser.speed.kmph();
if (parser.course.isValid()) s.courseDegrees = parser.course.deg();
if (parser.date.isValid()) { s.year = parser.date.year(); s.month = parser.date.month(); s.day = parser.date.day(); }
if (parser.time.isValid()) { s.hour = parser.time.hour(); s.minute = parser.time.minute(); s.second = parser.time.second(); }
}
+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);
+39
View File
@@ -0,0 +1,39 @@
#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;
float degrees(float r) { return r * 180.0f / PI; }
void updateEuler(ImuState& s) {
float sinr = 2 * (s.real * s.i + s.j * s.k);
float cosr = 1 - 2 * (s.i * s.i + s.j * s.j);
s.rollDegrees = degrees(atan2f(sinr, cosr));
float sinp = 2 * (s.real * s.j - s.k * s.i);
s.pitchDegrees = degrees(fabsf(sinp) >= 1 ? copysignf(PI / 2, sinp) : asinf(sinp));
float siny = 2 * (s.real * s.k + s.i * s.j);
float cosy = 1 - 2 * (s.j * s.j + s.k * s.k);
s.yawDegrees = degrees(atan2f(siny, cosy));
}
}
bool initImu() {
Wire.begin(BNO086_SDA_PIN, BNO086_SCL_PIN);
initialized = bno086.begin_I2C(BNO086_I2C_ADDRESS, &Wire);
if (initialized) initialized = bno086.enableReport(SH2_ROTATION_VECTOR, IMU_REPORT_INTERVAL_US);
return initialized;
}
void updateImu(ImuState& s) {
s.online = initialized;
if (!initialized || !bno086.getSensorEvent(&value) || value.sensorId != SH2_ROTATION_VECTOR) return;
s.i = value.un.rotationVector.i; s.j = value.un.rotationVector.j;
s.k = value.un.rotationVector.k; s.real = value.un.rotationVector.real;
s.accuracy = value.status; s.valid = true; s.lastUpdateMs = millis();
updateEuler(s);
}
+5
View File
@@ -0,0 +1,5 @@
#pragma once
#include "sensor_state.h"
bool initImu();
void updateImu(ImuState& state);
@@ -0,0 +1,30 @@
#include <ArduinoJson.h>
#include "rs485_transport.h"
#include "sensor_node_config.h"
void initRs485(HardwareSerial& serial) {
pinMode(RS485_DE_PIN, OUTPUT); digitalWrite(RS485_DE_PIN, LOW);
serial.begin(RS485_BAUD, SERIAL_8N1, RS485_RX_PIN, RS485_TX_PIN);
}
void publishSensorStatus(HardwareSerial& serial, const ImuState& imu, const GpsState& gps) {
StaticJsonDocument<1024> doc;
doc["type"] = "sensor_status"; doc["schema_version"] = SENSOR_NODE_SCHEMA_VERSION;
doc["node_id"] = SENSOR_NODE_NAME; doc["firmware_version"] = SENSOR_NODE_FIRMWARE_VERSION;
doc["uptime_ms"] = millis();
JsonObject i = doc.createNestedObject("imu");
i["online"] = imu.online; i["valid"] = imu.valid && millis() - imu.lastUpdateMs <= SENSOR_STALE_MS;
i["accuracy"] = imu.accuracy;
JsonObject q = i.createNestedObject("quaternion"); q["i"] = imu.i; q["j"] = imu.j; q["k"] = imu.k; q["real"] = imu.real;
i["yaw_degrees"] = imu.yawDegrees; i["pitch_degrees"] = imu.pitchDegrees; i["roll_degrees"] = imu.rollDegrees;
JsonObject g = doc.createNestedObject("gps");
g["online"] = gps.online; g["fix"] = gps.fix; g["satellites"] = gps.satellites;
g["latitude"] = gps.latitude; g["longitude"] = gps.longitude; g["altitude_meters"] = gps.altitudeMeters;
g["speed_kph"] = gps.speedKph; g["course_degrees"] = gps.courseDegrees;
JsonObject utc = g.createNestedObject("utc"); utc["year"] = gps.year; utc["month"] = gps.month; utc["day"] = gps.day;
utc["hour"] = gps.hour; utc["minute"] = gps.minute; utc["second"] = gps.second;
digitalWrite(RS485_DE_PIN, HIGH); delayMicroseconds(20);
serializeJson(doc, serial); serial.write('\n'); serial.flush();
digitalWrite(RS485_DE_PIN, LOW);
}
@@ -0,0 +1,6 @@
#pragma once
#include <HardwareSerial.h>
#include "sensor_state.h"
void initRs485(HardwareSerial& serial);
void publishSensorStatus(HardwareSerial& serial, const ImuState& imu, const GpsState& gps);
@@ -0,0 +1,20 @@
#pragma once
#include <Arduino.h>
#define SENSOR_NODE_NAME "xiao_c6_sensor_node"
#define SENSOR_NODE_FIRMWARE_VERSION "0.1.0"
#define SENSOR_NODE_SCHEMA_VERSION 1
#define RS485_DE_PIN D2
#define RS485_TX_PIN D4
#define RS485_RX_PIN D5
#define BNO086_SDA_PIN D7
#define BNO086_SCL_PIN D8
#define GPS_RX_PIN D9
#define GPS_TX_PIN D10
#define BNO086_I2C_ADDRESS 0x4A
#define GPS_BAUD 9600
#define RS485_BAUD 115200
#define IMU_REPORT_INTERVAL_US 20000
#define TELEMETRY_INTERVAL_MS 200
#define SENSOR_STALE_MS 2000
@@ -0,0 +1,21 @@
#pragma once
#include <Arduino.h>
struct ImuState {
bool online = false, valid = false;
uint8_t accuracy = 0;
float i = 0, j = 0, k = 0, real = 1;
float yawDegrees = 0, pitchDegrees = 0, rollDegrees = 0;
unsigned long lastUpdateMs = 0;
};
struct GpsState {
bool online = false, fix = false;
uint32_t satellites = 0;
double latitude = 0, longitude = 0, altitudeMeters = 0;
double speedKph = 0, courseDegrees = 0;
uint16_t year = 0;
uint8_t month = 0, day = 0, hour = 0, minute = 0, second = 0;
unsigned long lastByteMs = 0;
};
@@ -0,0 +1,28 @@
#include <Arduino.h>
#include "gps.h"
#include "imu.h"
#include "rs485_transport.h"
#include "sensor_node_config.h"
HardwareSerial gpsSerial(0), rs485Serial(1);
ImuState imuState;
GpsState gpsState;
unsigned long lastTelemetryMs = 0;
void setup() {
Serial.begin(115200); delay(250);
Serial.println("XIAO ESP32-C6 sensor node booting");
initGps(gpsSerial); initRs485(rs485Serial);
imuState.online = initImu();
Serial.println(imuState.online ? "BNO086 ready" : "BNO086 not detected");
}
void loop() {
updateGps(gpsSerial, gpsState); updateImu(imuState);
unsigned long now = millis();
if (now - lastTelemetryMs >= TELEMETRY_INTERVAL_MS) {
publishSensorStatus(rs485Serial, imuState, gpsState); lastTelemetryMs = now;
}
delay(1);
}
+1
View File
@@ -4,6 +4,7 @@ import sys
COMMANDS = [ COMMANDS = [
[sys.executable, "-m", "pytest", "tests/test_http_api_contract.py"], [sys.executable, "-m", "pytest", "tests/test_http_api_contract.py"],
[sys.executable, "-m", "pytest", "tests/test_xiao_sensor_node_contract.py"],
] ]
+4 -4
View File
@@ -8,7 +8,7 @@
"enabled": true, "enabled": true,
"role": "aux", "role": "aux",
"hardware_channel": 1, "hardware_channel": 1,
"hardware_profile": "generic_esp32_2ch_relay", "hardware_profile": "lilygo_t_relay_s3_6ch",
"available": true "available": true
}, },
{ {
@@ -18,7 +18,7 @@
"enabled": true, "enabled": true,
"role": "fridge", "role": "fridge",
"hardware_channel": 2, "hardware_channel": 2,
"hardware_profile": "generic_esp32_2ch_relay", "hardware_profile": "lilygo_t_relay_s3_6ch",
"available": true "available": true
} }
], ],
@@ -38,6 +38,6 @@
"weather": false "weather": false
} }
], ],
"hardware_profile": "generic_esp32_2ch_relay", "hardware_profile": "lilygo_t_relay_s3_6ch",
"output_count": 2 "output_count": 6
} }
+6 -6
View File
@@ -44,7 +44,7 @@
"state": false, "state": false,
"role": "aux", "role": "aux",
"hardware_channel": 1, "hardware_channel": 1,
"hardware_profile": "generic_esp32_2ch_relay", "hardware_profile": "lilygo_t_relay_s3_6ch",
"available": true "available": true
}, },
{ {
@@ -55,7 +55,7 @@
"state": true, "state": true,
"role": "fridge", "role": "fridge",
"hardware_channel": 2, "hardware_channel": 2,
"hardware_profile": "generic_esp32_2ch_relay", "hardware_profile": "lilygo_t_relay_s3_6ch",
"available": true "available": true
} }
], ],
@@ -106,7 +106,7 @@
"enabled": true, "enabled": true,
"role": "aux", "role": "aux",
"hardware_channel": 1, "hardware_channel": 1,
"hardware_profile": "generic_esp32_2ch_relay", "hardware_profile": "lilygo_t_relay_s3_6ch",
"available": true "available": true
} }
], ],
@@ -126,9 +126,9 @@
"weather": false "weather": false
} }
], ],
"hardware_profile": "generic_esp32_2ch_relay", "hardware_profile": "lilygo_t_relay_s3_6ch",
"output_count": 2 "output_count": 2
}, },
"hardware_profile": "generic_esp32_2ch_relay", "hardware_profile": "lilygo_t_relay_s3_6ch",
"output_count": 2 "output_count": 6
} }
+17
View File
@@ -0,0 +1,17 @@
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
DASHBOARD = ROOT / "firmware" / "esp32-s3-dashboard" / "esp32-s3-dashboard.ino"
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
+28 -8
View File
@@ -57,10 +57,23 @@ def test_firmware_registers_current_http_contract_routes():
assert expected_routes <= routes assert expected_routes <= routes
def test_firmware_keeps_root_compatibility_aliases(): def test_firmware_uses_v1_routes_without_legacy_aliases():
routes = registered_routes() routes = registered_routes()
compatibility_routes = { required_v1_routes = {
("/api/v1/status", "ANY"),
("/api/v1/config", "HTTP_GET"),
("/api/v1/relay/set", "HTTP_POST"),
("/api/v1/config/wifi", "HTTP_GET"),
("/api/v1/config/wifi", "HTTP_POST"),
("/api/v1/wifi/connect", "HTTP_POST"),
("/api/v1/wifi/clear", "HTTP_POST"),
("/api/v1/temps/scan", "HTTP_POST"),
("/api/v1/temps/assign", "HTTP_POST"),
("/api/v1/temps/clear", "HTTP_POST"),
}
removed_legacy_routes = {
("/status", "ANY"), ("/status", "ANY"),
("/config", "HTTP_GET"), ("/config", "HTTP_GET"),
("/relay/set", "HTTP_POST"), ("/relay/set", "HTTP_POST"),
@@ -73,7 +86,8 @@ def test_firmware_keeps_root_compatibility_aliases():
("/temps/clear", "HTTP_POST"), ("/temps/clear", "HTTP_POST"),
} }
assert compatibility_routes <= routes assert required_v1_routes <= routes
assert routes.isdisjoint(removed_legacy_routes)
def test_embedded_webui_uses_versioned_api_routes(): def test_embedded_webui_uses_versioned_api_routes():
@@ -134,8 +148,8 @@ def test_status_fixture_matches_dashboard_contract_shape():
"online", "online",
"temperature_f", "temperature_f",
]) ])
assert payload["hardware_profile"] == "generic_esp32_2ch_relay" assert payload["hardware_profile"] == "lilygo_t_relay_s3_6ch"
assert payload["output_count"] == 2 assert payload["output_count"] == 6
assert_keys(payload["relays"][0], [ assert_keys(payload["relays"][0], [
"id", "id",
"name", "name",
@@ -180,8 +194,8 @@ def test_config_fixture_matches_current_config_response_shape():
"temperature_sensor_count", "temperature_sensor_count",
"temperature_sensors", "temperature_sensors",
]) ])
assert payload["hardware_profile"] == "generic_esp32_2ch_relay" assert payload["hardware_profile"] == "lilygo_t_relay_s3_6ch"
assert payload["output_count"] == 2 assert payload["output_count"] == 6
assert_keys(payload["relays"][0], [ assert_keys(payload["relays"][0], [
"id", "id",
"name", "name",
@@ -234,6 +248,8 @@ def test_relay_set_contract_fixtures_and_firmware_errors():
source = firmware_source() source = firmware_source()
assert '"invalid_json"' in source assert '"invalid_json"' in source
assert '"unknown_relay"' in source assert '"unknown_relay"' in source
assert "relay_disabled" in source
assert "relayIsEnabled" in source
def test_relay_api_is_output_count_agnostic(): def test_relay_api_is_output_count_agnostic():
@@ -245,6 +261,7 @@ def test_relay_api_is_output_count_agnostic():
assert 'relay["hardware_channel"] = appConfig.relays[i].hardwareChannel;' in source assert 'relay["hardware_channel"] = appConfig.relays[i].hardwareChannel;' in source
assert 'relay["available"] = appConfig.relays[i].available;' in source assert 'relay["available"] = appConfig.relays[i].available;' in source
assert 'relays.state[index] = enabled;' in source assert 'relays.state[index] = enabled;' in source
assert '!appConfig.relays[index].enabled' in source
assert 'relays.relay1' not in source assert 'relays.relay1' not in source
assert 'relays.relay2' not in source assert 'relays.relay2' not in source
@@ -461,7 +478,10 @@ def test_cargo_oled_shows_setup_and_service_pages():
assert 'oledClear("Relays")' in source assert 'oledClear("Relays")' in source
assert 'oledClear("Network")' in source assert 'oledClear("Network")' in source
assert 'oledClear("Alarms")' in source assert 'oledClear("Alarms")' in source
assert "oledShowSetupPage();" in source config = Path("firmware/esp32/overland-controller/config.h").read_text()
oled_disabled = "OLED_SETUP_ENABLED 0" in config or "LILYGO_T_RELAY_S3" in config
if not oled_disabled:
assert "oledShowSetupPage();" in source
assert "oledLoop();" in source assert "oledLoop();" in source
+27
View File
@@ -0,0 +1,27 @@
import re
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
NODE = ROOT / "firmware" / "xiao-esp32c6-sensor-node"
def source(name): return (NODE / name).read_text()
def test_wiring_matches_assembly():
config = source("sensor_node_config.h")
expected = {"RS485_DE_PIN":"D2", "RS485_TX_PIN":"D4", "RS485_RX_PIN":"D5",
"BNO086_SDA_PIN":"D7", "BNO086_SCL_PIN":"D8", "GPS_RX_PIN":"D9", "GPS_TX_PIN":"D10"}
for name, pin in expected.items(): assert re.search(rf"#define\s+{name}\s+{pin}\b", config)
def test_transport_contract():
text = source("rs485_transport.cpp")
for token in ['doc["type"] = "sensor_status"', 'doc["schema_version"]', 'createNestedObject("imu")', 'createNestedObject("gps")', "serial.write('\\n')", "serial.flush()"]:
assert token in text
def test_loop_is_non_blocking():
loop = source("xiao-esp32c6-sensor-node.ino").split("void loop()", 1)[1]
assert "updateGps" in loop and "updateImu" in loop and "TELEMETRY_INTERVAL_MS" in loop
assert not re.search(r"delay\((?:[2-9]|[1-9]\d+)\)", loop)
def test_node_does_not_take_cargo_or_ble_authority():
combined = "\n".join(p.read_text() for p in NODE.glob("*.*"))
assert "NimBLE" not in combined and "JBD" not in combined and "server.on(" not in combined
+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"