Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4f5146eb82 | ||
|
|
a701059378 | ||
|
|
22a6d77be8 | ||
|
|
b51990f4ff | ||
|
|
563434a0a1 | ||
|
|
2e4fb4ad6c | ||
|
|
9371171ebb | ||
|
|
c18bc71359 | ||
|
|
f2f452d04a |
+1
-1
@@ -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.
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
## 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
|
||||
- Cargo ESP local OTA firmware upload page at `/ota`.
|
||||
- Cargo ESP OTA API endpoint at `POST /api/v1/system/ota`.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -53,5 +53,6 @@ The LILYGO T-Relay-S3 6-way board has been ordered as a future Cargo ESP candida
|
||||
Current development remains on the 2-channel relay board while the API and docs are prepared for output-count-agnostic operation.
|
||||
|
||||
- `api-v1-audit.md` - Audit of legacy API paths vs `/api/v1` namespace.
|
||||
- `SENSOR_NODE_PROTOCOL.md` - XIAO ESP32-C6 BNO086/MAX-M10S RS485 telemetry contract.
|
||||
|
||||
- `waveshare-5b-bringup-checklist.md` - Bench bring-up checklist for the Waveshare ESP32-S3 Touch LCD 5B dashboard.
|
||||
|
||||
@@ -0,0 +1,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.
|
||||
|
||||
@@ -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`.
|
||||
- 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.
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
#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.
|
||||
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>
|
||||
@@ -16,6 +17,23 @@ 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;
|
||||
@@ -733,6 +751,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 +1161,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 +1434,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 +1556,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 +1571,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 +2115,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 +2188,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()
|
||||
@@ -2181,6 +2470,8 @@ void setup()
|
||||
Serial.println("Display: 1024x600");
|
||||
Serial.println("API Base: /api/v1");
|
||||
|
||||
load_dashboard_connection_config();
|
||||
|
||||
Board *board = new Board();
|
||||
board->init();
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
@@ -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(); }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
#pragma once
|
||||
#include <HardwareSerial.h>
|
||||
#include "sensor_state.h"
|
||||
void initGps(HardwareSerial& serial);
|
||||
void updateGps(HardwareSerial& serial, GpsState& state);
|
||||
|
||||
@@ -0,0 +1,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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import sys
|
||||
|
||||
COMMANDS = [
|
||||
[sys.executable, "-m", "pytest", "tests/test_http_api_contract.py"],
|
||||
[sys.executable, "-m", "pytest", "tests/test_xiao_sensor_node_contract.py"],
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+42
@@ -0,0 +1,42 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PATCH_COMMIT="a701059"
|
||||
|
||||
repo_root="$(git rev-parse --show-toplevel)"
|
||||
cd "$repo_root"
|
||||
|
||||
if [[ -n "$(git status --porcelain)" ]]; then
|
||||
echo "Refusing to edit a dirty working tree." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! git cat-file -e "${PATCH_COMMIT}^{commit}" 2>/dev/null; then
|
||||
git fetch origin
|
||||
fi
|
||||
|
||||
if ! git merge-base --is-ancestor "$PATCH_COMMIT" HEAD; then
|
||||
git cherry-pick "$PATCH_COMMIT"
|
||||
else
|
||||
echo "XIAO sensor-node patch is already applied."
|
||||
fi
|
||||
|
||||
if [[ ! -f .venv/bin/activate ]]; then
|
||||
echo "Missing .venv/bin/activate; create the project virtual environment first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
source .venv/bin/activate
|
||||
python run_tests.py
|
||||
|
||||
git add -A
|
||||
if ! git diff --cached --quiet; then
|
||||
git commit -m "firmware: apply XIAO C6 sensor node patch"
|
||||
fi
|
||||
|
||||
branch="$(git branch --show-current)"
|
||||
if [[ -z "$branch" ]]; then
|
||||
echo "Cannot push from detached HEAD." >&2
|
||||
exit 1
|
||||
fi
|
||||
git push origin "$branch"
|
||||
Reference in New Issue
Block a user