19 Commits
Author SHA1 Message Date
nick 24e86e2eb1 cargo: increase STA WiFi connect window 2026-06-23 10:18:18 -06:00
nick c96ef5e7cc cargo: clean up BMS backoff declarations 2026-06-22 22:46:50 -06:00
nick db666190cf cargo: fix BMS reconnect backoff helper order 2026-06-22 22:42:17 -06:00
nick b11151c832 cargo: back off missing BMS reconnect attempts 2026-06-22 22:39:54 -06:00
nick a18ed0aeca cargo: add local OTA firmware upload 2026-06-22 22:30:55 -06:00
nick e0b56f42e9 cargo: keep AP web UI available without STA WiFi 2026-06-22 22:21:51 -06:00
nick fec60b057f dashboard: restore boot screen 2026-06-20 14:34:06 -06:00
nick 4cba41c831 dashboard: tile spacing adjustment 2026-06-19 02:59:26 -06:00
nick 59fb7b5c29 dashboard: make main side cards equal width 2026-06-19 02:52:44 -06:00
nick 54c25f28bf dashboard: dynamically balance main top row layout 2026-06-19 02:46:40 -06:00
nick 49382358dc dashboard: balance main top row tile widths 2026-06-19 02:36:22 -06:00
nick 3750659a3d dashboard: auto refresh diagnostics overlay every 5 seconds 2026-06-19 02:27:41 -06:00
nick 9d75d2c39c dashboard: add OBD-II coolant polling over CAN 2026-06-19 02:19:08 -06:00
nick 97e49a5d98 docs: update roadmap 2026-06-19 02:09:19 -06:00
nick ef6b32e133 dashboard: fix temp alert style repaint 2026-06-19 02:07:50 -06:00
nick 73a28beacc dashboard: add temp alert debug logging 2026-06-19 02:03:33 -06:00
nick 2aac72616c Revert "dashboard: support temp alert field variants"
This reverts commit 9c1f5338c7.
2026-06-19 01:58:34 -06:00
nick f4c6623334 Revert "dashboard: use cargo temp alert contract fields"
This reverts commit 3e88a7a28a.
2026-06-19 01:58:34 -06:00
nick 5fbc33001a Revert "dashboard: compute temp alert from contract threshold"
This reverts commit 3c438bd4ac.
2026-06-19 01:58:34 -06:00
8 changed files with 385 additions and 44 deletions
+12
View File
@@ -2,6 +2,18 @@
## Unreleased ## Unreleased
### Added
- Cargo ESP local OTA firmware upload page at `/ota`.
- Cargo ESP OTA API endpoint at `POST /api/v1/system/ota`.
## Unreleased
### Fixed
- Cargo ESP starts AP and Web UI/API before attempting STA WiFi reconnects.
- STA reconnect attempts now service the web server during short connection windows so AP mode remains usable when no saved WiFi is available.
## Unreleased
### Changed ### Changed
- Reworked ESP32-S3 dashboard page navigation to use screen-wide LVGL swipe gestures. - Reworked ESP32-S3 dashboard page navigation to use screen-wide LVGL swipe gestures.
- Removed failed temporary double-tap and transparent hitbox navigation experiments. - Removed failed temporary double-tap and transparent hitbox navigation experiments.
+8
View File
@@ -311,3 +311,11 @@ The diagnostics overlay shows dashboard version, uptime, heap, WiFi RSSI/IP, Car
- Swipe right from the main dashboard to open Battery Detail. - Swipe right from the main dashboard to open Battery Detail.
- Swipe left from Battery Detail to return to the main dashboard. - Swipe left from Battery Detail to return to the main dashboard.
- Diagnostics remains hidden behind the multi-tap system-card gesture. - Diagnostics remains hidden behind the multi-tap system-card gesture.
## OBD-II Coolant Polling
- Dashboard ESP32-S3 Touch LCD 5B uses onboard CAN/TWAI via GPIO15 CANTX and GPIO16 CANRX.
- First implemented PID is Mode 01 PID 05 coolant temperature.
- 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.
+21
View File
@@ -192,3 +192,24 @@ Next Dashboard Milestones:
## 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.
## Planned: WebSocket live dashboard updates
Add a WebSocket push channel alongside the existing `/api/v1` REST API. The REST API remains the stable snapshot/debug/control contract, while WebSockets provide lower-latency live status updates for the dashboard.
Planned behavior:
- Cargo ESP continues to expose `/api/v1/status` and existing `/api/v1/...` endpoints.
- Cargo ESP adds a WebSocket endpoint, likely `/ws`.
- Dashboard ESP connects once to `/ws` and receives pushed status updates.
- Relay commands may continue using REST initially, with pushed status updates confirming state changes.
- Future high-rate data such as CAN/OBD, GPS, inclinometer, and temperature updates can use the WebSocket path.
- If WebSocket disconnects, dashboard should fall back to the existing REST polling behavior.
Rationale:
- Keeps the current API architecture intact and easy to debug.
- Avoids ESP-NOW channel-management complexity for now.
- Improves perceived responsiveness without a major communication rewrite.
- Supports future live gauges better than fixed-interval polling.
+13 -13
View File
@@ -1,20 +1,20 @@
#pragma once #pragma once
static const char *DASHBOARD_VERSION = "0.1.118-temp-alert-local-threshold"; static const char *DASHBOARD_VERSION = "0.1.126-restore-boot-screen";
// Update these if your Cargo ESP AP credentials are different. // Update these if your Cargo ESP AP credentials are different.
//static const char *CARGO_WIFI_SSID = "OverlandController"; static const char *CARGO_WIFI_SSID = "OverlandController";
//static const char *CARGO_WIFI_PASSWORD = "overland1234"; static const char *CARGO_WIFI_PASSWORD = "overland1234";
static const char *CARGO_WIFI_SSID = "WardAP"; //static const char *CARGO_WIFI_SSID = "WardAP";
static const char *CARGO_WIFI_PASSWORD = "Ward5213"; //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_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_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_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_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_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_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_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"; //static const char *CARGO_API_RELAY_SET_URL = "http://192.168.88.108/api/v1/relay/set";
@@ -3,6 +3,7 @@
#include <HTTPClient.h> #include <HTTPClient.h>
#include <ArduinoJson.h> #include <ArduinoJson.h>
#include <esp_display_panel.hpp> #include <esp_display_panel.hpp>
#include <driver/twai.h>
#include <lvgl.h> #include <lvgl.h>
#include "lvgl_v8_port.h" #include "lvgl_v8_port.h"
@@ -15,6 +16,24 @@ using namespace esp_panel::drivers;
using namespace esp_panel::board; using namespace esp_panel::board;
static DashboardStatus status_model; static DashboardStatus status_model;
static constexpr gpio_num_t OBD_CAN_TX_PIN = GPIO_NUM_15;
static constexpr gpio_num_t OBD_CAN_RX_PIN = GPIO_NUM_16;
static constexpr uint32_t OBD_REQUEST_ID = 0x7DF;
static constexpr uint32_t OBD_RESPONSE_ID_MIN = 0x7E8;
static constexpr uint32_t OBD_RESPONSE_ID_MAX = 0x7EF;
static constexpr uint8_t OBD_PID_COOLANT_TEMP = 0x05;
static bool obd_can_ready = false;
static bool obd_can_started = false;
static unsigned long last_obd_poll_ms = 0;
static unsigned long last_obd_rx_ms = 0;
static unsigned long last_obd_tx_ms = 0;
static uint32_t obd_tx_count = 0;
static uint32_t obd_rx_count = 0;
static int obd_coolant_temp_f = -999;
static int last_rendered_obd_coolant_temp_f = -1000;
static String obd_last_error = "not initialized";
static lv_obj_t *battery_card_obj = nullptr; static lv_obj_t *battery_card_obj = nullptr;
static lv_obj_t *battery_soc_arc = nullptr; static lv_obj_t *battery_soc_arc = nullptr;
@@ -369,6 +388,149 @@ static String format_diag_uptime(unsigned long seconds)
return out; return out;
} }
static void update_vehicle_coolant_widget()
{
if (vehicle_coolant_value_label == nullptr || vehicle_coolant_meter == nullptr || vehicle_coolant_needle == nullptr) {
return;
}
if (obd_coolant_temp_f == last_rendered_obd_coolant_temp_f) {
return;
}
last_rendered_obd_coolant_temp_f = obd_coolant_temp_f;
lvgl_port_lock(-1);
if (obd_coolant_temp_f > -100) {
int bounded_temp = clamp_int(obd_coolant_temp_f, 100, 260);
lv_meter_set_indicator_value(vehicle_coolant_meter, vehicle_coolant_needle, bounded_temp);
lv_label_set_text(vehicle_coolant_value_label, (String(obd_coolant_temp_f) + "°").c_str());
} else {
lv_meter_set_indicator_value(vehicle_coolant_meter, vehicle_coolant_needle, 100);
lv_label_set_text(vehicle_coolant_value_label, "--°");
}
lvgl_port_unlock();
}
static bool init_obd_can()
{
if (obd_can_ready && obd_can_started) {
return true;
}
twai_general_config_t general_config = TWAI_GENERAL_CONFIG_DEFAULT(OBD_CAN_TX_PIN, OBD_CAN_RX_PIN, TWAI_MODE_NORMAL);
general_config.tx_queue_len = 5;
general_config.rx_queue_len = 20;
twai_timing_config_t timing_config = TWAI_TIMING_CONFIG_500KBITS();
twai_filter_config_t filter_config = TWAI_FILTER_CONFIG_ACCEPT_ALL();
if (!obd_can_ready) {
esp_err_t install_result = twai_driver_install(&general_config, &timing_config, &filter_config);
if (install_result != ESP_OK && install_result != ESP_ERR_INVALID_STATE) {
obd_last_error = "driver install failed: " + String((int)install_result);
Serial.println("OBD CAN: " + obd_last_error);
return false;
}
obd_can_ready = true;
}
esp_err_t start_result = twai_start();
if (start_result != ESP_OK && start_result != ESP_ERR_INVALID_STATE) {
obd_last_error = "start failed: " + String((int)start_result);
Serial.println("OBD CAN: " + obd_last_error);
return false;
}
obd_can_started = true;
obd_last_error = "started";
Serial.println("OBD CAN: TWAI started on TX GPIO15 / RX GPIO16 at 500 kbps");
return true;
}
static void send_obd_coolant_request()
{
twai_message_t message = {};
message.identifier = OBD_REQUEST_ID;
message.extd = 0;
message.rtr = 0;
message.data_length_code = 8;
message.data[0] = 0x02;
message.data[1] = 0x01;
message.data[2] = OBD_PID_COOLANT_TEMP;
message.data[3] = 0x55;
message.data[4] = 0x55;
message.data[5] = 0x55;
message.data[6] = 0x55;
message.data[7] = 0x55;
esp_err_t result = twai_transmit(&message, 0);
if (result == ESP_OK) {
obd_tx_count++;
last_obd_tx_ms = millis();
obd_last_error = "coolant request sent";
} else {
obd_last_error = "tx failed: " + String((int)result);
}
}
static void handle_obd_can_message(const twai_message_t &message)
{
if (message.extd || message.rtr) {
return;
}
if (message.identifier < OBD_RESPONSE_ID_MIN || message.identifier > OBD_RESPONSE_ID_MAX) {
return;
}
if (message.data_length_code < 4) {
return;
}
// Single-frame OBD-II response:
// byte0 length, byte1 0x41 response mode, byte2 PID, byte3 value A.
if (message.data[1] == 0x41 && message.data[2] == OBD_PID_COOLANT_TEMP) {
int coolant_c = (int)message.data[3] - 40;
obd_coolant_temp_f = (int)roundf((coolant_c * 9.0f / 5.0f) + 32.0f);
obd_rx_count++;
last_obd_rx_ms = millis();
obd_last_error = "coolant response OK";
Serial.print("OBD CAN: coolant=");
Serial.print(obd_coolant_temp_f);
Serial.println(" F");
update_vehicle_coolant_widget();
}
}
static void process_obd_can()
{
if (!init_obd_can()) {
return;
}
if (millis() - last_obd_poll_ms >= 1000) {
last_obd_poll_ms = millis();
send_obd_coolant_request();
}
twai_message_t message = {};
while (twai_receive(&message, 0) == ESP_OK) {
handle_obd_can_message(message);
}
if (millis() - last_obd_rx_ms > 5000 && obd_coolant_temp_f > -100) {
obd_coolant_temp_f = -999;
update_vehicle_coolant_widget();
}
}
static void update_diagnostics_overlay() static void update_diagnostics_overlay()
{ {
if (diagnostics_overlay == nullptr || diagnostics_label == nullptr) { if (diagnostics_overlay == nullptr || diagnostics_label == nullptr) {
@@ -442,6 +604,21 @@ static void update_diagnostics_overlay()
text += String(status_model.cell_delta_mv); text += String(status_model.cell_delta_mv);
text += " mV"; text += " mV";
text += "\n\nOBD-II / CAN\n";
text += " TWAI: ";
text += obd_can_started ? "started" : "offline";
text += "\n Pins: TX GPIO15 / RX GPIO16";
text += "\n Coolant: ";
text += obd_coolant_temp_f > -100 ? String(obd_coolant_temp_f) + "°F" : String("--");
text += "\n TX/RX: ";
text += String(obd_tx_count);
text += "/";
text += String(obd_rx_count);
text += "\n Last RX age: ";
text += last_obd_rx_ms > 0 ? String((millis() - last_obd_rx_ms) / 1000) + "s" : String("--");
text += "\n Last status: ";
text += obd_last_error;
text += "\n\nControls\n"; text += "\n\nControls\n";
text += " Relays visible: "; text += " Relays visible: ";
text += String(relay_count); text += String(relay_count);
@@ -1288,14 +1465,10 @@ static void update_temp_tile_alert_style(int index, bool alert)
return; return;
} }
if (last_temp_tile_alert[index] == alert) { // Always apply the visual style. The cache can get out of sync when pages
return; // are hidden/shown, so it must not prevent repainting the tile.
}
last_temp_tile_alert[index] = alert; last_temp_tile_alert[index] = alert;
lvgl_port_lock(-1);
if (alert) { if (alert) {
lv_obj_set_style_bg_color(temp_tile_objs[index], lv_color_hex(0x7A271A), 0); lv_obj_set_style_bg_color(temp_tile_objs[index], lv_color_hex(0x7A271A), 0);
lv_obj_set_style_border_color(temp_tile_objs[index], lv_color_hex(0xF97066), 0); lv_obj_set_style_border_color(temp_tile_objs[index], lv_color_hex(0xF97066), 0);
@@ -1305,11 +1478,8 @@ static void update_temp_tile_alert_style(int index, bool alert)
lv_obj_set_style_border_color(temp_tile_objs[index], lv_color_hex(0x2F3A46), 0); lv_obj_set_style_border_color(temp_tile_objs[index], lv_color_hex(0x2F3A46), 0);
lv_obj_set_style_border_width(temp_tile_objs[index], 2, 0); lv_obj_set_style_border_width(temp_tile_objs[index], 2, 0);
} }
lvgl_port_unlock();
} }
static void update_battery_card_alert_style(bool alert) static void update_battery_card_alert_style(bool alert)
{ {
if (battery_card_obj == nullptr) { if (battery_card_obj == nullptr) {
@@ -1465,14 +1635,6 @@ static void parse_status_json(const String &body)
float temp_f = (float)(temp["temperature_f"] | 0.0); float temp_f = (float)(temp["temperature_f"] | 0.0);
bool high_alert = temp["high_alert"] | false; bool high_alert = temp["high_alert"] | false;
bool high_alert_enabled = temp["high_alert_enabled"] | false;
float high_alert_f = (float)(temp["high_alert_f"] | 9999.0);
if (high_alert_enabled && temp_f >= high_alert_f) {
high_alert = true;
}
if (weather) { if (weather) {
outside_status = "OUT "; outside_status = "OUT ";
outside_status += String((int)round(temp_f)); outside_status += String((int)round(temp_f));
@@ -1765,11 +1927,11 @@ current_dashboard_screen = SCREEN_OVERVIEW;
lv_obj_t *system_card = create_card(screen, "", 960, 48, LV_ALIGN_TOP_MID, 0, 12); lv_obj_t *system_card = create_card(screen, "", 960, 48, LV_ALIGN_TOP_MID, 0, 12);
lv_obj_add_flag(system_card, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_flag(system_card, LV_OBJ_FLAG_CLICKABLE);
lv_obj_add_event_cb(system_card, system_card_event_cb, LV_EVENT_CLICKED, nullptr); lv_obj_add_event_cb(system_card, system_card_event_cb, LV_EVENT_CLICKED, nullptr);
lv_obj_t *battery_card = create_card(screen, "", 380, 310, LV_ALIGN_TOP_LEFT, 32, 68); lv_obj_t *battery_card = create_card(screen, "", 330, 310, LV_ALIGN_TOP_LEFT, 32, 68);
battery_card_obj = battery_card; battery_card_obj = battery_card;
add_battery_card_tap_target(battery_card); add_battery_card_tap_target(battery_card);
lv_obj_t *temp_card = create_card(screen, "", 260, 310, LV_ALIGN_TOP_LEFT, 426, 68); lv_obj_t *temp_card = create_card(screen, "", 260, 310, LV_ALIGN_TOP_LEFT, 382, 68);
lv_obj_t *vehicle_card = create_card(screen, "", 292, 310, LV_ALIGN_TOP_RIGHT, -32, 68); lv_obj_t *vehicle_card = create_card(screen, "", 330, 310, LV_ALIGN_TOP_RIGHT, -32, 68);
lv_obj_t *relay_card = create_card(screen, "", 960, 205, LV_ALIGN_BOTTOM_MID, 0, -8); lv_obj_t *relay_card = create_card(screen, "", 960, 205, LV_ALIGN_BOTTOM_MID, 0, -8);
battery_charge_pulse_arc = lv_arc_create(battery_card); battery_charge_pulse_arc = lv_arc_create(battery_card);
@@ -1900,7 +2062,7 @@ lv_obj_t *system_card = create_card(screen, "", 960, 48, LV_ALIGN_TOP_MID, 0, 12
lv_meter_set_indicator_end_value(vehicle_coolant_meter, hot_arc, 260); lv_meter_set_indicator_end_value(vehicle_coolant_meter, hot_arc, 260);
vehicle_coolant_needle = lv_meter_add_needle_line(vehicle_coolant_meter, coolant_scale, 4, lv_color_hex(0xFFFFFF), -14); vehicle_coolant_needle = lv_meter_add_needle_line(vehicle_coolant_meter, coolant_scale, 4, lv_color_hex(0xFFFFFF), -14);
lv_meter_set_indicator_value(vehicle_coolant_meter, vehicle_coolant_needle, 188); lv_meter_set_indicator_value(vehicle_coolant_meter, vehicle_coolant_needle, 100);
lv_obj_t *vehicle_coolant_center_cap = lv_obj_create(vehicle_coolant_meter); lv_obj_t *vehicle_coolant_center_cap = lv_obj_create(vehicle_coolant_meter);
lv_obj_set_size(vehicle_coolant_center_cap, 54, 54); lv_obj_set_size(vehicle_coolant_center_cap, 54, 54);
@@ -1912,7 +2074,7 @@ lv_obj_t *system_card = create_card(screen, "", 960, 48, LV_ALIGN_TOP_MID, 0, 12
#endif #endif
vehicle_coolant_value_label = lv_label_create(vehicle_card); vehicle_coolant_value_label = lv_label_create(vehicle_card);
lv_label_set_text(vehicle_coolant_value_label, "188°"); lv_label_set_text(vehicle_coolant_value_label, "--°");
lv_obj_set_style_text_color(vehicle_coolant_value_label, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_color(vehicle_coolant_value_label, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(vehicle_coolant_value_label, &lv_font_montserrat_40, 0); lv_obj_set_style_text_font(vehicle_coolant_value_label, &lv_font_montserrat_40, 0);
lv_obj_set_width(vehicle_coolant_value_label, 120); lv_obj_set_width(vehicle_coolant_value_label, 120);
@@ -2038,9 +2200,7 @@ void setup()
lvgl_port_init(board->getLCD(), board->getTouch()); lvgl_port_init(board->getLCD(), board->getTouch());
lvgl_port_lock(-1); lvgl_port_lock(-1);
// TEMP DEBUG: bypass blocking Cargo ESP WiFi screen while troubleshooting pages. show_boot_screen();
create_overland_overview_screen();
overview_screen_created = true;
lvgl_port_unlock(); lvgl_port_unlock();
Serial.println("Dashboard boot screen ready"); Serial.println("Dashboard boot screen ready");
@@ -2052,7 +2212,13 @@ void loop()
update_system_status_label(); update_system_status_label();
process_pending_relay_command(); process_pending_relay_command();
poll_status_api(); poll_status_api();
process_obd_can();
process_dashboard_page_switch(); process_dashboard_page_switch();
delay(50);
if (diagnostics_overlay != nullptr) {
update_diagnostics_overlay();
}
delay(50);
} }
+35 -1
View File
@@ -26,6 +26,8 @@ static int bmsConnectFailCount = 0;
static unsigned long bmsReconnectPausedUntil = 0; static unsigned long bmsReconnectPausedUntil = 0;
static bool bmsSetupMode = false; static bool bmsSetupMode = false;
static const unsigned long READ_INTERVAL_MS = 5000; static const unsigned long READ_INTERVAL_MS = 5000;
static const unsigned long BMS_CONNECT_BACKOFF_INITIAL_MS = 30000;
static const unsigned long BMS_CONNECT_BACKOFF_MAX_MS = 300000;
BmsData bmsData; BmsData bmsData;
@@ -288,7 +290,34 @@ int scanBleDevices(uint32_t scanSeconds) {
return bleScanResultCount; return bleScanResultCount;
} }
static bool connectBms() { static void scheduleBmsReconnectBackoff();
static void clearBmsReconnectBackoff();
static void scheduleBmsReconnectBackoff() {
bmsConnectFailCount++;
if (bmsConnectBackoffMs == 0) {
bmsConnectBackoffMs = BMS_CONNECT_BACKOFF_INITIAL_MS;
} else {
bmsConnectBackoffMs = min(bmsConnectBackoffMs * 2, BMS_CONNECT_BACKOFF_MAX_MS);
}
bmsReconnectPausedUntil = millis() + bmsConnectBackoffMs;
Serial.print("BMS: reconnect backoff ");
Serial.print(bmsConnectBackoffMs / 1000);
Serial.print("s after ");
Serial.print(bmsConnectFailCount);
Serial.println(" failed attempt(s)");
}
static void clearBmsReconnectBackoff() {
bmsConnectFailCount = 0;
bmsConnectBackoffMs = 0;
bmsReconnectPausedUntil = 0;
}
bool connectBms() {
if (client && client->isConnected()) { if (client && client->isConnected()) {
bmsData.connected = true; bmsData.connected = true;
return true; return true;
@@ -307,6 +336,7 @@ static bool connectBms() {
if (!appConfig.bms.enabled || appConfig.bms.address.length() == 0) { if (!appConfig.bms.enabled || appConfig.bms.address.length() == 0) {
bmsData.connected = false; bmsData.connected = false;
clearBmsReconnectBackoff();
return false; return false;
} }
@@ -322,6 +352,9 @@ static bool connectBms() {
bmsData.connected = false; bmsData.connected = false;
NimBLEDevice::deleteClient(client); NimBLEDevice::deleteClient(client);
client = nullptr; client = nullptr;
notifyChar = nullptr;
writeChar = nullptr;
scheduleBmsReconnectBackoff();
return false; return false;
} }
@@ -352,6 +385,7 @@ static bool connectBms() {
Serial.println("BMS: Connected"); Serial.println("BMS: Connected");
bmsData.connected = true; bmsData.connected = true;
clearBmsReconnectBackoff();
return true; return true;
} }
@@ -3,6 +3,7 @@
#include <Preferences.h> #include <Preferences.h>
#include <esp_wifi.h> #include <esp_wifi.h>
#include <esp_system.h> #include <esp_system.h>
#include <Update.h>
#define OLED_ENABLED 0 #define OLED_ENABLED 0
@@ -1648,6 +1649,98 @@ void sendSimpleJsonError(int status, const char* error, const char* detail) {
} }
const char OTA_HTML[] PROGMEM = R"rawliteral(
<!doctype html>
<html>
<head>
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Cargo ESP OTA</title>
<style>
body{background:#0b0f14;color:#eef4ff;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Arial,sans-serif;margin:0}
.wrap{max-width:620px;margin:0 auto;padding:22px}
.card{background:#141b24;border:1px solid #223044;border-radius:18px;padding:18px}
h1{margin-top:0}
input,button{width:100%;box-sizing:border-box;margin-top:12px}
input{border:1px solid #223044;border-radius:12px;padding:12px;background:#0f151d;color:#eef4ff}
button{border:0;border-radius:12px;padding:13px;background:#1d324f;color:#eef4ff;font-weight:800}
.warn{color:#ffd166;font-size:14px;line-height:1.4}
.small{color:#7f8da3;font-size:13px}
</style>
</head>
<body>
<div class="wrap">
<div class="card">
<h1>Cargo ESP OTA</h1>
<p class="warn">Upload only firmware built for the Cargo ESP. The controller will reboot after a successful update.</p>
<form method="POST" action="/api/v1/system/ota" enctype="multipart/form-data">
<input type="file" name="firmware" accept=".bin" required>
<button type="submit">Upload Firmware</button>
</form>
<p class="small">Tip: Arduino IDE can create this with Sketch → Export Compiled Binary.</p>
</div>
</div>
</body>
</html>
)rawliteral";
static bool otaUploadHadError = false;
void handleOtaPage() {
server.send_P(200, "text/html", OTA_HTML);
}
void handleOtaUploadDone() {
DynamicJsonDocument doc(512);
doc["ok"] = !otaUploadHadError && !Update.hasError();
doc["firmware_name"] = FIRMWARE_NAME;
doc["message"] = doc["ok"].as<bool>() ? "OTA update complete. Rebooting." : "OTA update failed.";
String output;
serializeJson(doc, output);
if (doc["ok"].as<bool>()) {
server.send(200, "application/json", output);
delay(500);
ESP.restart();
} else {
server.send(500, "application/json", output);
}
}
void handleOtaUploadStream() {
HTTPUpload& upload = server.upload();
if (upload.status == UPLOAD_FILE_START) {
otaUploadHadError = false;
Serial.print("OTA: upload start: ");
Serial.println(upload.filename);
if (!Update.begin(UPDATE_SIZE_UNKNOWN)) {
otaUploadHadError = true;
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_WRITE) {
if (!otaUploadHadError && Update.write(upload.buf, upload.currentSize) != upload.currentSize) {
otaUploadHadError = true;
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_END) {
if (!otaUploadHadError && Update.end(true)) {
Serial.print("OTA: success, bytes=");
Serial.println(upload.totalSize);
} else {
otaUploadHadError = true;
Update.printError(Serial);
}
} else if (upload.status == UPLOAD_FILE_ABORTED) {
otaUploadHadError = true;
Update.abort();
Serial.println("OTA: upload aborted");
}
}
void handleResetApConfig() { void handleResetApConfig() {
resetApConfigToDefaults(); resetApConfigToDefaults();
restartAccessPoint(); restartAccessPoint();
@@ -1998,8 +2091,10 @@ void connectStaWifi() {
WiFi.begin(staSsids[i].c_str(), staPasswords[i].c_str()); WiFi.begin(staSsids[i].c_str(), staPasswords[i].c_str());
unsigned long start = millis(); unsigned long start = millis();
while (WiFi.status() != WL_CONNECTED && millis() - start < 10000) { while (WiFi.status() != WL_CONNECTED && millis() - start < 8000) {
delay(500); server.handleClient();
oledLoop();
delay(100);
Serial.print("."); Serial.print(".");
} }
@@ -3404,6 +3499,7 @@ void handleCapabilities() {
endpoints.add("POST /api/v1/bms/setup/exit"); endpoints.add("POST /api/v1/bms/setup/exit");
endpoints.add("POST /api/v1/bms/scan"); endpoints.add("POST /api/v1/bms/scan");
endpoints.add("POST /api/v1/bms/select"); endpoints.add("POST /api/v1/bms/select");
endpoints.add("POST /api/v1/system/ota");
JsonObject limits = doc.createNestedObject("limits"); JsonObject limits = doc.createNestedObject("limits");
limits["relay_count"] = MAX_RELAYS; limits["relay_count"] = MAX_RELAYS;
@@ -4007,11 +4103,15 @@ void setup() {
startAccessPoint(); startAccessPoint();
oledShowSetupPage(); oledShowSetupPage();
connectStaWifi(); // Do not block setup on STA WiFi. Start the AP and web server first so the
// 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("/status"), handleStatus);
server.on(API_V1("/health"), HTTP_GET, handleHealth); server.on(API_V1("/health"), HTTP_GET, handleHealth);
@@ -1,4 +1,4 @@
#pragma once #pragma once
#define FIRMWARE_VERSION "0.5.0" #define FIRMWARE_VERSION "0.5.5-wifi-connect-window-8s"
#define FIRMWARE_NAME "overland-controller" #define FIRMWARE_NAME "overland-controller"