From e5a9f1149228708e8eb598637174bf80ef6681a2 Mon Sep 17 00:00:00 2001 From: Paul <36135447+PaulDWhite@users.noreply.github.com> Date: Mon, 8 Jun 2026 12:35:36 -0400 Subject: [PATCH 01/15] BLE OTA: keepalive + longer supervision timeout to prevent mid-flash disconnect During OTA the firmware suppresses the ~50 Hz FastLink telemetry stream to give the flash full bandwidth. With no liveness signal a BLE central can tear down the link mid-flash (HCI 0x13 / disconnect reason 531), aborting the update partway through (~20-30%). - fastlink_service.cpp: emit a ~1 Hz FastLink keepalive notify while OTA is in progress so the central keeps the link up. The keepalive ships the packet already setValue()'d, whose advancing packet_id/uptime_ms the app counts as telemetry progress (no app changes needed). At 1 Hz vs the 15 ms OTA interval it does not meaningfully slow the flash. - ble_core.cpp (requestFastConnParams): lengthen the OTA-time supervision timeout from 2 s to 8 s so a multi-second flash-erase stall or a sluggish phone cannot drop the link at the link-layer level. OTA_TIMEOUT_MS (30 s) remains the dead-link backstop. Reliability over speed: trades negligible flash throughput for a link that stays up across the whole flash. --- src/sp140/ble/ble_core.cpp | 8 ++++++-- src/sp140/ble/fastlink_service.cpp | 25 ++++++++++++++++++++++++- 2 files changed, 30 insertions(+), 3 deletions(-) diff --git a/src/sp140/ble/ble_core.cpp b/src/sp140/ble/ble_core.cpp index f7afbdf5..4fde6476 100644 --- a/src/sp140/ble/ble_core.cpp +++ b/src/sp140/ble/ble_core.cpp @@ -402,8 +402,12 @@ void requestFastConnParams() { if (pServer == nullptr || !deviceConnected) { return; } - // Tighten to 15ms interval for OTA throughput - pServer->updateConnParams(activeConnHandle, 12, 12, 0, 200); + // Tighten to 15ms interval for OTA throughput, but lengthen the supervision + // timeout to 8s (was 2s). A multi-second flash-erase stall or a sluggish phone + // must not drop the link at the link-layer level mid-flash. Slower recovery of + // a genuinely dead link is acceptable (OTA_TIMEOUT_MS=30s is the backstop); + // reliability of the flash matters more than fast dead-link detection. + pServer->updateConnParams(activeConnHandle, 12, 12, 0, 800); } void requestNormalConnParams() { diff --git a/src/sp140/ble/fastlink_service.cpp b/src/sp140/ble/fastlink_service.cpp index cb435214..b89a7757 100644 --- a/src/sp140/ble/fastlink_service.cpp +++ b/src/sp140/ble/fastlink_service.cpp @@ -19,6 +19,12 @@ uint32_t gFastLinkSkippedNoConnCount = 0; uint32_t gFastLinkSkippedOtaCount = 0; uint32_t gFastLinkLastStatsMs = 0; uint32_t gFastLinkPacketId = 0; +// During OTA the normal ~50Hz telemetry stream is suppressed, but we still emit +// a low-rate keepalive so the phone's connection-health watchdog sees the link +// as alive (otherwise the central tears it down mid-flash -> HCI 0x13 / +// reason=531, aborting OTA ~20% in). +uint32_t gFastLinkLastOtaKeepaliveMs = 0; +constexpr uint32_t kOtaKeepaliveIntervalMs = 1000; constexpr uint32_t kFastLinkTelemetryProperties = NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::NOTIFY | @@ -140,7 +146,24 @@ void updateFastLinkTelemetry(const BLE_FastLink_Telemetry &data) { pFastLinkCharacteristic->setValue((uint8_t *)&data, sizeof(BLE_FastLink_Telemetry)); if (isOtaInProgress()) { - ++gFastLinkSkippedOtaCount; + // Suppress the full ~50Hz stream during OTA to give the flash bandwidth, + // but emit a ~1Hz keepalive notify so the central keeps the link up. The + // keepalive ships the packet just setValue()'d above, whose advancing + // packet_id/uptime_ms the app counts as telemetry progress. Wrap-safe + // unsigned subtraction. At 1Hz vs the 15ms OTA interval it does not + // meaningfully slow the flash. + const uint32_t nowMs = millis(); + if (deviceConnected && + (nowMs - gFastLinkLastOtaKeepaliveMs >= kOtaKeepaliveIntervalMs)) { + gFastLinkLastOtaKeepaliveMs = nowMs; + if (pFastLinkCharacteristic->notify()) { + ++gFastLinkNotifyOkCount; + } else { + ++gFastLinkNotifyFailCount; + } + } else { + ++gFastLinkSkippedOtaCount; + } } else if (deviceConnected) { const bool sent = pFastLinkCharacteristic->notify(); if (sent) { From 7c4b5e05f7d94b4f64fb0171316c262dd8d10ec0 Mon Sep 17 00:00:00 2001 From: Paul <36135447+PaulDWhite@users.noreply.github.com> Date: Mon, 8 Jun 2026 21:32:03 -0400 Subject: [PATCH 02/15] Update vario scaling and deadzone logic MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace hard clamp and magic numbers in the climb-rate vario display with named constants. Introduce kVarioSegment (0.5 m/s per segment) and kVarioDeadzone (0.25 m/s) and compute sectionsToFill from kVarioSegment, capping at 6. Remove the previous ±0.6 m/s clamp and use the deadzone as the neutral threshold so values beyond ±3 m/s pin the gauge to full deflection rather than being clamped. --- src/sp140/lvgl/lvgl_updates.cpp | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/sp140/lvgl/lvgl_updates.cpp b/src/sp140/lvgl/lvgl_updates.cpp index 5885ee72..350740f7 100644 --- a/src/sp140/lvgl/lvgl_updates.cpp +++ b/src/sp140/lvgl/lvgl_updates.cpp @@ -344,9 +344,11 @@ void stopBLEPairingIconFlash() { // Update the climb rate indicator void updateClimbRateIndicator(float climbRate) { - // Clamp climb rate to displayable range (-0.6 to +0.6 m/s) - if (climbRate > 0.6f) climbRate = 0.6f; - if (climbRate < -0.6f) climbRate = -0.6f; + // Vario display scale: 6 segments per direction cover a +/-3 m/s climb/descent + // envelope. The raw value is NOT clamped here - sectionsToFill is capped at 6 + // below, so rates beyond +/-3 m/s simply pin the gauge to full deflection. + const float kVarioSegment = 3.0f / 6.0f; // m/s per segment (0.5) + const float kVarioDeadzone = kVarioSegment / 2.0f; // neutral zone near 0 (0.25 m/s) // Reset all sections to transparent for (int i = 0; i < 12; i++) { @@ -375,9 +377,9 @@ void updateClimbRateIndicator(float climbRate) { lv_color_make(75, 0, 130) // Dark purple (again) }; - if (climbRate >= 0.15f) { + if (climbRate >= kVarioDeadzone) { // Positive climb rate - fill sections above center line - int sectionsToFill = (int)(climbRate / 0.3f); + int sectionsToFill = (int)(climbRate / kVarioSegment); if (sectionsToFill > 6) sectionsToFill = 6; for (int i = 0; i < sectionsToFill; i++) { @@ -387,9 +389,9 @@ void updateClimbRateIndicator(float climbRate) { lv_obj_set_style_bg_color(climb_rate_fill_sections[sectionIndex], positive_colors[i], LV_PART_MAIN); } } - } else if (climbRate <= -0.15f) { + } else if (climbRate <= -kVarioDeadzone) { // Negative climb rate - fill sections below center line - int sectionsToFill = (int)(-climbRate / 0.3f); + int sectionsToFill = (int)(-climbRate / kVarioSegment); if (sectionsToFill > 6) sectionsToFill = 6; for (int i = 0; i < sectionsToFill; i++) { @@ -400,7 +402,7 @@ void updateClimbRateIndicator(float climbRate) { } } } - // If climb rate is between -0.15 and +0.15, no sections are filled (neutral) + // If |climb rate| is within kVarioDeadzone, no sections are filled (neutral) } void updateLvglMainScreen( From ac1487836aaa015f58d95a7d11e31466df909031 Mon Sep 17 00:00:00 2001 From: Paul <36135447+PaulDWhite@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:26:58 -0400 Subject: [PATCH 03/15] Optimize LVGL updates and SPI access Reduce display redraws and SPI contention by introducing change-detecting LVGL setters and safer SPI handling. Key changes: - Add resetLvglUpdateCache() and change-detecting helpers (setLabelText, setBgColor, etc.) to avoid redundant LVGL invalidations and per-frame full redraws. - Rework many main-screen update paths (battery, power, altitude, climb rate, temps, icons) to compute desired state then diff-apply only changed widgets/styles. - Add flushSkipped flag: if a display flush is skipped due to SPI busy, mark it and force a full invalidate on next refresh to recover stale pixels. - Let LVGL read time directly (lv_tick_set_cb) and remove ad-hoc lv_tick_handler/lvgl_last_update; call lv_timer_handler() from updateLvgl(). - Move BMS SPI CS toggling to occur only after acquiring the shared SPI mutex and release the mutex immediately after the CAN library's update() (getters are read-only), preventing mid-transfer deselects and reducing wait times for display flushes. - Increase UI task frequency to ~30 Hz (33 ms) to match LVGL refresh period. - Update headers and tests to reflect removed variables/functions and new reset API. These changes reduce unnecessary rendering, avoid lost mid-transfer display updates, and improve responsiveness under SPI contention. --- inc/sp140/lvgl/lvgl_core.h | 4 +- inc/sp140/lvgl/lvgl_updates.h | 4 + src/sp140/bms.cpp | 21 +- src/sp140/lvgl/lvgl_core.cpp | 46 +-- src/sp140/lvgl/lvgl_main_screen.cpp | 5 + src/sp140/lvgl/lvgl_updates.cpp | 378 +++++++++++++-------- src/sp140/main.cpp | 4 +- test/test_screenshots/emulator_display.cpp | 1 - 8 files changed, 286 insertions(+), 177 deletions(-) diff --git a/inc/sp140/lvgl/lvgl_core.h b/inc/sp140/lvgl/lvgl_core.h index 0b3be14a..bb0d0aac 100644 --- a/inc/sp140/lvgl/lvgl_core.h +++ b/inc/sp140/lvgl/lvgl_core.h @@ -16,14 +16,13 @@ // v9 uses byte-based buffers: width * height * bytes_per_pixel / 2 #define LVGL_BUF_BYTES (SCREEN_WIDTH * SCREEN_HEIGHT * 2 / 2) -// LVGL refresh time in ms - match the config file setting +// Poll period for the blocking splash-screen loop in ms #define LVGL_REFRESH_TIME 40 // Core LVGL globals extern int8_t displayCS; // Display chip select pin extern lv_display_t* main_display; extern Adafruit_ST7735* tft_driver; -extern uint32_t lvgl_last_update; // Shared SPI bus mutex (guards TFT + MCP2515 access) extern SemaphoreHandle_t spiBusMutex; @@ -31,7 +30,6 @@ extern SemaphoreHandle_t spiBusMutex; void setupLvglBuffer(); void setupLvglDisplay(const STR_DEVICE_DATA_140_V1& deviceData, int8_t dc_pin, int8_t rst_pin, SPIClass* spi); void lvgl_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map); -void lv_tick_handler(); void updateLvgl(); void displayLvglSplash(const STR_DEVICE_DATA_140_V1& deviceData, int duration); diff --git a/inc/sp140/lvgl/lvgl_updates.h b/inc/sp140/lvgl/lvgl_updates.h index 89ac66ee..5c68b85f 100644 --- a/inc/sp140/lvgl/lvgl_updates.h +++ b/inc/sp140/lvgl/lvgl_updates.h @@ -28,6 +28,10 @@ extern bool isFlashingCriticalBorder; extern lv_timer_t* ble_pairing_flash_timer; extern bool isFlashingBLEPairingIcon; +// Forget cached last-applied UI state (alignment modes, attached tile +// styles). Must be called whenever the main screen widgets are (re)created. +void resetLvglUpdateCache(); + // Main update function void updateLvglMainScreen( const STR_DEVICE_DATA_140_V1& deviceData, diff --git a/src/sp140/bms.cpp b/src/sp140/bms.cpp index 77664fc7..ce552c34 100644 --- a/src/sp140/bms.cpp +++ b/src/sp140/bms.cpp @@ -50,8 +50,6 @@ void updateBMSData() { if (bms_can == nullptr || !bmsCanInitialized) return; // TODO track bms incrementing cycle count - // Ensure display CS is deselected and BMS CS is selected - digitalWrite(displayCS, HIGH); // Take the shared SPI mutex to prevent contention with TFT flush if (spiBusMutex != NULL) { if (xSemaphoreTake(spiBusMutex, pdMS_TO_TICKS(150)) != pdTRUE) { @@ -60,12 +58,25 @@ void updateBMSData() { return; // Use stale BMS data this cycle rather than hang } } + // Ensure display CS is deselected and BMS CS is selected. This must happen + // only AFTER the bus mutex is held: writing displayCS while a TFT flush is + // streaming pixels deselects the panel mid-transfer and the rest of that + // flush is silently lost, leaving stale bands on screen. + digitalWrite(displayCS, HIGH); digitalWrite(bmsCS, LOW); // USBSerial.println("Updating BMS Data"); unsigned long tStart = millis(); bms_can->update(); + // All SPI traffic happens inside update() — the getters below only read + // values the library cached in RAM. Release the bus first so a pending + // display flush waits only for the CAN drain, not the whole copy-out. + digitalWrite(bmsCS, HIGH); + if (spiBusMutex != NULL) { + xSemaphoreGive(spiBusMutex); + } + // Basic measurements bmsTelemetryData.battery_voltage = bms_can->getBatteryVoltage(); bmsTelemetryData.battery_current = bms_can->getBatteryCurrent(); @@ -139,12 +150,6 @@ void updateBMSData() { USBSerial.println("ms"); } // printBMSData(); - - // Deselect BMS CS when done and release mutex - digitalWrite(bmsCS, HIGH); - if (spiBusMutex != NULL) { - xSemaphoreGive(spiBusMutex); - } } void printBMSData() { diff --git a/src/sp140/lvgl/lvgl_core.cpp b/src/sp140/lvgl/lvgl_core.cpp index b4df9d35..e115cba3 100644 --- a/src/sp140/lvgl/lvgl_core.cpp +++ b/src/sp140/lvgl/lvgl_core.cpp @@ -8,13 +8,20 @@ lv_display_t* main_display = nullptr; static uint8_t buf[LVGL_BUF_BYTES]; static uint8_t buf2[LVGL_BUF_BYTES]; Adafruit_ST7735* tft_driver = nullptr; -uint32_t lvgl_last_update = 0; // Define the shared SPI bus mutex SemaphoreHandle_t spiBusMutex = NULL; +// Set when a flush had to be skipped because the SPI bus was busy. LVGL is +// told the flush succeeded (required to keep rendering), so the skipped area +// would otherwise never be repainted — updateLvgl() checks this flag and +// forces a full repaint to recover. +static volatile bool flushSkipped = false; void setupLvglBuffer() { // Initialize LVGL library lv_init(); + // Let LVGL read time directly so animation/refresh pacing stays accurate + // regardless of how often lv_timer_handler() gets called. + lv_tick_set_cb([]() -> uint32_t { return millis(); }); } void setupLvglDisplay( @@ -77,7 +84,10 @@ void lvgl_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { if (xSemaphoreTake(spiBusMutex, pdMS_TO_TICKS(200)) != pdTRUE) { // SPI bus timeout - BMS might be doing long operation, skip display flush USBSerial.println("[DISPLAY] SPI bus timeout - skipping display flush"); - // Must still signal LVGL that flush is done to avoid deadlock + // Must still signal LVGL that flush is done to avoid deadlock. That + // marks the area clean without it ever reaching the panel, so flag it + // for a recovery repaint (handled in updateLvgl). + flushSkipped = true; lv_display_flush_ready(disp); return; } @@ -103,26 +113,20 @@ void lvgl_flush_cb(lv_display_t* disp, const lv_area_t* area, uint8_t* px_map) { lv_display_flush_ready(disp); } -// LVGL tick handler - to be called from timer or in main loop -void lv_tick_handler() { - static uint32_t last_tick = 0; - uint32_t current_ms = millis(); - - if (current_ms - last_tick > 5) { // 5ms tick rate for LVGL - lv_tick_inc(current_ms - last_tick); - last_tick = current_ms; - } -} - -// Update LVGL - call this regularly +// Update LVGL - call this regularly with lvglMutex held. LVGL paces actual +// redraws internally (LV_DEF_REFR_PERIOD), so no extra gating is needed here. void updateLvgl() { - uint32_t current_ms = millis(); - - // Update LVGL at the defined refresh rate - if (current_ms - lvgl_last_update > LVGL_REFRESH_TIME) { - lv_tick_handler(); - lv_timer_handler(); - lvgl_last_update = current_ms; + lv_timer_handler(); + + // A flush was dropped because the SPI bus was busy: LVGL believes those + // pixels reached the panel, so invalidate everything once to repaint any + // stale content on the next refresh cycle. + if (flushSkipped) { + flushSkipped = false; + lv_obj_t* screen = lv_screen_active(); + if (screen != NULL) { + lv_obj_invalidate(screen); + } } } diff --git a/src/sp140/lvgl/lvgl_main_screen.cpp b/src/sp140/lvgl/lvgl_main_screen.cpp index e0d75ff8..e0d2dca6 100644 --- a/src/sp140/lvgl/lvgl_main_screen.cpp +++ b/src/sp140/lvgl/lvgl_main_screen.cpp @@ -1,5 +1,6 @@ #include "../../../inc/sp140/lvgl/lvgl_main_screen.h" #include "../../../inc/sp140/lvgl/lvgl_alerts.h" +#include "../../../inc/sp140/lvgl/lvgl_updates.h" #include "../../../inc/sp140/esp32s3-config.h" #include "../../assets/img/cruise-control-340255-30.c" // Cruise control icon // NOLINT(build/include) @@ -106,6 +107,10 @@ void setupMainScreen(bool darkMode) { lv_obj_delete(main_screen); } + // Fresh widgets have no cached update state — forget the last-applied values + // so updateLvglMainScreen() re-applies styles/positions to the new objects. + resetLvglUpdateCache(); + // Create main screen main_screen = lv_obj_create(NULL); diff --git a/src/sp140/lvgl/lvgl_updates.cpp b/src/sp140/lvgl/lvgl_updates.cpp index 350740f7..43f71953 100644 --- a/src/sp140/lvgl/lvgl_updates.cpp +++ b/src/sp140/lvgl/lvgl_updates.cpp @@ -7,6 +7,63 @@ #include "../../../inc/sp140/ble.h" #include "../../../inc/sp140/ble/ble_core.h" #include +#include + +// --------------------------------------------------------------------------- +// Change-detecting setters for the per-frame update path. +// LVGL invalidates (and re-flushes over SPI) any object touched by +// lv_label_set_text() or a style setter even when the new value is identical +// to the current one. updateLvglMainScreen() runs every UI frame, so +// unguarded setters redraw nearly the whole screen at the full frame rate. +// These helpers skip the LVGL call when nothing actually changed, so only +// areas with new content get invalidated and flushed. + +static void setLabelText(lv_obj_t* label, const char* text) { + if (label == NULL || text == NULL) return; + const char* current = lv_label_get_text(label); + if (current != NULL && strcmp(current, text) == 0) return; + lv_label_set_text(label, text); +} + +static void setLabelTextColor(lv_obj_t* label, lv_color_t color) { + if (label == NULL) return; + if (lv_color_eq(lv_obj_get_style_text_color(label, LV_PART_MAIN), color)) return; + lv_obj_set_style_text_color(label, color, 0); +} + +static void setBgColor(lv_obj_t* obj, lv_color_t color, lv_part_t part) { + if (obj == NULL) return; + if (lv_color_eq(lv_obj_get_style_bg_color(obj, part), color)) return; + lv_obj_set_style_bg_color(obj, color, part); +} + +static void setBgOpa(lv_obj_t* obj, lv_opa_t opa) { + if (obj == NULL) return; + if (lv_obj_get_style_bg_opa(obj, LV_PART_MAIN) == opa) return; + lv_obj_set_style_bg_opa(obj, opa, LV_PART_MAIN); +} + +static void setImageRecolor(lv_obj_t* obj, lv_color_t color) { + if (obj == NULL) return; + if (lv_color_eq(lv_obj_get_style_image_recolor(obj, LV_PART_MAIN), color)) return; + lv_obj_set_style_image_recolor(obj, color, LV_PART_MAIN); +} + +// Last-applied UI state that cannot be read back from the widgets (label +// alignment mode, which bg style is attached). Kept at file scope so +// resetLvglUpdateCache() can invalidate it whenever the screen widgets are +// (re)created - e.g. setupMainScreen() on boot or between screenshot tests. +static int8_t lastVoltLeftMode = -1; // 0 = BMS, 1 = "NO BMS", 2 = none +static uint8_t lastBattTempLevel = 0xFF; // 0 = normal, 1 = warning, 2 = critical +static uint8_t lastEscTempLevel = 0xFF; +static uint8_t lastMotorTempLevel = 0xFF; + +void resetLvglUpdateCache() { + lastVoltLeftMode = -1; + lastBattTempLevel = 0xFF; + lastEscTempLevel = 0xFF; + lastMotorTempLevel = 0xFF; +} // Flash timer globals - definitions lv_timer_t* cruise_flash_timer = NULL; @@ -350,13 +407,6 @@ void updateClimbRateIndicator(float climbRate) { const float kVarioSegment = 3.0f / 6.0f; // m/s per segment (0.5) const float kVarioDeadzone = kVarioSegment / 2.0f; // neutral zone near 0 (0.25 m/s) - // Reset all sections to transparent - for (int i = 0; i < 12; i++) { - if (climb_rate_fill_sections[i] != NULL) { - lv_obj_set_style_bg_opa(climb_rate_fill_sections[i], LV_OPA_0, LV_PART_MAIN); - } - } - // Define colors for positive climb rates (from center upward) lv_color_t positive_colors[6] = { lv_color_make(0, 255, 0), // Bright green @@ -377,6 +427,15 @@ void updateClimbRateIndicator(float climbRate) { lv_color_make(75, 0, 130) // Dark purple (again) }; + // Compute the desired state for all 12 sections, then diff-apply so only + // sections that actually changed are invalidated and redrawn. + lv_opa_t section_opa[12]; + lv_color_t section_color[12]; + for (int i = 0; i < 12; i++) { + section_opa[i] = LV_OPA_0; + section_color[i] = lv_color_black(); // unused while transparent + } + if (climbRate >= kVarioDeadzone) { // Positive climb rate - fill sections above center line int sectionsToFill = (int)(climbRate / kVarioSegment); @@ -384,10 +443,8 @@ void updateClimbRateIndicator(float climbRate) { for (int i = 0; i < sectionsToFill; i++) { int sectionIndex = 5 - i; // Section 5 is closest to center line, going up to 0 - if (climb_rate_fill_sections[sectionIndex] != NULL) { - lv_obj_set_style_bg_opa(climb_rate_fill_sections[sectionIndex], LV_OPA_100, LV_PART_MAIN); - lv_obj_set_style_bg_color(climb_rate_fill_sections[sectionIndex], positive_colors[i], LV_PART_MAIN); - } + section_opa[sectionIndex] = LV_OPA_100; + section_color[sectionIndex] = positive_colors[i]; } } else if (climbRate <= -kVarioDeadzone) { // Negative climb rate - fill sections below center line @@ -396,13 +453,20 @@ void updateClimbRateIndicator(float climbRate) { for (int i = 0; i < sectionsToFill; i++) { int sectionIndex = 6 + i; // Section 6 is closest to center line, going down to 11 - if (climb_rate_fill_sections[sectionIndex] != NULL) { - lv_obj_set_style_bg_opa(climb_rate_fill_sections[sectionIndex], LV_OPA_100, LV_PART_MAIN); - lv_obj_set_style_bg_color(climb_rate_fill_sections[sectionIndex], negative_colors[i], LV_PART_MAIN); - } + section_opa[sectionIndex] = LV_OPA_100; + section_color[sectionIndex] = negative_colors[i]; } } // If |climb rate| is within kVarioDeadzone, no sections are filled (neutral) + + for (int i = 0; i < 12; i++) { + if (climb_rate_fill_sections[i] == NULL) continue; + // Only recolor visible sections; color is irrelevant while transparent + if (section_opa[i] == LV_OPA_100) { + setBgColor(climb_rate_fill_sections[i], section_color[i], LV_PART_MAIN); + } + setBgOpa(climb_rate_fill_sections[i], section_opa[i]); + } } void updateLvglMainScreen( @@ -436,7 +500,7 @@ void updateLvglMainScreen( hasValidBatteryTemp = true; } } - // Show whichever ESC temperature is hotter — capacitor or MOSFET — and color + // Show whichever ESC temperature is hotter - capacitor or MOSFET - and color // it against that sensor's own thresholds (cap and MOSFET have different // limits). Skip a sensor that reads NaN (invalid); if both are invalid // escTemp stays NaN and the tile shows "-". @@ -468,40 +532,49 @@ void updateLvglMainScreen( batteryColor = LVGL_YELLOW; } - lv_obj_set_style_bg_color(battery_bar, batteryColor, LV_PART_INDICATOR); + setBgColor(battery_bar, batteryColor, LV_PART_INDICATOR); // Update battery percentage text char buffer[10]; snprintf(buffer, sizeof(buffer), "%d%%", (int)batteryPercent); - lv_label_set_text(battery_label, buffer); - lv_obj_set_style_text_color(battery_label, LVGL_BLACK, 0); + setLabelText(battery_label, buffer); + setLabelTextColor(battery_label, LVGL_BLACK); } else if (escConnected) { // clear the battery bar, we handle voltage later lv_bar_set_value(battery_bar, 0, LV_ANIM_OFF); } else { lv_bar_set_value(battery_bar, 0, LV_ANIM_OFF); - lv_label_set_text(battery_label, "NO DATA"); - lv_obj_set_style_text_color(battery_label, LVGL_RED, 0); + setLabelText(battery_label, "NO DATA"); + setLabelTextColor(battery_label, LVGL_RED); } // Update left voltage (cell voltage) if (voltage_left_label != NULL) { // Check object exists + // Re-align only on source change: lv_obj_align() invalidates the label + // every call even when the resulting position is identical. if (bmsConnected) { char buffer[10]; snprintf(buffer, sizeof(buffer), "%2.2fv", lowestCellV); - lv_label_set_text(voltage_left_label, buffer); + setLabelText(voltage_left_label, buffer); // Always use black text for better readability - lv_obj_set_style_text_color(voltage_left_label, LVGL_BLACK, 0); - // Restore default position when BMS is connected - lv_obj_align(voltage_left_label, LV_ALIGN_TOP_LEFT, 3, 12); + setLabelTextColor(voltage_left_label, LVGL_BLACK); + if (lastVoltLeftMode != 0) { + // Restore default position when BMS is connected + lv_obj_align(voltage_left_label, LV_ALIGN_TOP_LEFT, 3, 12); + lastVoltLeftMode = 0; + } } else if (escConnected) { - lv_obj_set_style_text_color(voltage_left_label, LVGL_BLACK, 0); - lv_label_set_text(voltage_left_label, "NO\nBMS"); - // Move up by 10 pixels when showing NO BMS - lv_obj_align(voltage_left_label, LV_ALIGN_TOP_LEFT, 3, 2); + setLabelTextColor(voltage_left_label, LVGL_BLACK); + setLabelText(voltage_left_label, "NO\nBMS"); + if (lastVoltLeftMode != 1) { + // Move up by 10 pixels when showing NO BMS + lv_obj_align(voltage_left_label, LV_ALIGN_TOP_LEFT, 3, 2); + lastVoltLeftMode = 1; + } } else { - lv_label_set_text(voltage_left_label, ""); + setLabelText(voltage_left_label, ""); + lastVoltLeftMode = 2; } } @@ -511,29 +584,36 @@ void updateLvglMainScreen( if (bmsConnected) { char buffer[10]; snprintf(buffer, sizeof(buffer), "%2.0fv", totalVolts); - lv_label_set_text(voltage_right_label, buffer); + setLabelText(voltage_right_label, buffer); // Ensure battery label shows percentage if BMS is connected if (batteryPercent >= 0) { char batt_buffer[10]; snprintf(batt_buffer, sizeof(batt_buffer), "%d%%", (int)batteryPercent); - lv_label_set_text(battery_label, batt_buffer); - lv_obj_set_style_text_color(battery_label, LVGL_BLACK, 0); + setLabelText(battery_label, batt_buffer); + setLabelTextColor(battery_label, LVGL_BLACK); } } else if (escConnected) { - lv_label_set_text(voltage_right_label, ""); + setLabelText(voltage_right_label, ""); char buffer[10]; snprintf(buffer, sizeof(buffer), "%2.1fv", totalVolts); - lv_label_set_text(battery_label, buffer); - lv_obj_set_style_text_color(battery_label, LVGL_BLACK, 0); + setLabelText(battery_label, buffer); + setLabelTextColor(battery_label, LVGL_BLACK); } else { - lv_label_set_text(voltage_right_label, ""); + setLabelText(voltage_right_label, ""); } } - // Update power display with individual character positions + // Update power display with individual character positions. + // Compute the desired text for every position first, then diff-apply: the + // old clear-then-set pattern invalidated all 4 labels twice per frame. if (power_char_labels[0] != NULL) { // Check if power display is initialized + const char* power_texts[4] = {"", "", "", ""}; + char power_tens_buf[12]; + char power_ones_buf[12]; + char power_tenths_buf[12]; + if (bmsConnected || escConnected) { float kWatts = unifiedBatteryData.power; @@ -545,11 +625,6 @@ void updateLvglMainScreen( kWatts = 0.0f; } - // Clear all positions first - for (int i = 0; i < 4; i++) { - lv_label_set_text(power_char_labels[i], ""); - } - // Carry-safe rounding to one decimal place (e.g., 9.95 -> 10.0) int tenths_total = static_cast(kWatts * 10.0f + 0.5f); int whole_part = tenths_total / 10; @@ -559,39 +634,35 @@ void updateLvglMainScreen( int tens = whole_part / 10; int ones = whole_part % 10; - // Populate character positions using static buffers - static char power_digit_buffers[4][12]; - // Tens digit (only show if >= 10kW) if (tens > 0) { - snprintf(power_digit_buffers[0], sizeof(power_digit_buffers[0]), "%d", tens); - lv_label_set_text(power_char_labels[0], power_digit_buffers[0]); + snprintf(power_tens_buf, sizeof(power_tens_buf), "%d", tens); + power_texts[0] = power_tens_buf; } // Ones digit (always show) - snprintf(power_digit_buffers[1], sizeof(power_digit_buffers[1]), "%d", ones); - lv_label_set_text(power_char_labels[1], power_digit_buffers[1]); + snprintf(power_ones_buf, sizeof(power_ones_buf), "%d", ones); + power_texts[1] = power_ones_buf; // Decimal point - lv_label_set_text(power_char_labels[2], "."); + power_texts[2] = "."; // Tenths digit - snprintf(power_digit_buffers[3], sizeof(power_digit_buffers[3]), "%d", decimal_part); - lv_label_set_text(power_char_labels[3], power_digit_buffers[3]); + snprintf(power_tenths_buf, sizeof(power_tenths_buf), "%d", decimal_part); + power_texts[3] = power_tenths_buf; // Unit label is static and already set to "kW" - } else { - // Clear all positions when no data - for (int i = 0; i < 4; i++) { - lv_label_set_text(power_char_labels[i], ""); - } - // Keep unit label visible even when no data + } + // else: all positions clear when no data; unit label stays visible + + for (int i = 0; i < 4; i++) { + setLabelText(power_char_labels[i], power_texts[i]); } } // Update performance mode - Re-added this section if (perf_mode_label != NULL) { // Check object exists - lv_label_set_text(perf_mode_label, deviceData.performance_mode == 0 ? "CHILL " : "SPORT"); + setLabelText(perf_mode_label, deviceData.performance_mode == 0 ? "CHILL " : "SPORT"); } // Update armed time @@ -603,19 +674,20 @@ void updateLvglMainScreen( const int sessionSeconds = (armedStartMillis > 0) ? ((_lastArmedMillis - armedStartMillis) / 1000) : 0; char timeBuffer[10]; snprintf(timeBuffer, sizeof(timeBuffer), "%02d:%02d", sessionSeconds / 60, sessionSeconds % 60); - lv_label_set_text(armed_time_label, timeBuffer); + setLabelText(armed_time_label, timeBuffer); } - // Update altitude - populate individual character positions + // Update altitude - compute the desired text for all 7 character positions + // first, then diff-apply so unchanged digits are not redrawn. (The old + // clear-then-set pattern invalidated all 7 labels twice per frame.) + const char* altitude_texts[7] = {"", "", "", "", "", "", ""}; + char altitude_digit_buffers[7][12]; + if (altitude == __FLT_MIN__) { // Show error in first few positions - lv_label_set_text(altitude_char_labels[0], "E"); - lv_label_set_text(altitude_char_labels[1], "R"); - lv_label_set_text(altitude_char_labels[2], "R"); - lv_label_set_text(altitude_char_labels[3], ""); - lv_label_set_text(altitude_char_labels[4], ""); - lv_label_set_text(altitude_char_labels[5], ""); - lv_label_set_text(altitude_char_labels[6], ""); + altitude_texts[0] = "E"; + altitude_texts[1] = "R"; + altitude_texts[2] = "R"; } else { if (deviceData.metric_alt) { // Display meters if metric altitude is enabled // Explicitly handle small negative values to avoid "-0.0" @@ -638,14 +710,6 @@ void updateLvglMainScreen( int tens = (whole_part / 10) % 10; int ones = whole_part % 10; - // Populate each character position using static character buffers - static char digit_buffers[7][12]; // Static buffers for single digits - - // Clear all positions first - for (int i = 0; i < 7; i++) { - lv_label_set_text(altitude_char_labels[i], ""); - } - // Determine the leftmost digit position needed int leftmost_pos = 3; // ones position (always needed) if (whole_part >= 10) leftmost_pos = 2; // tens @@ -654,43 +718,45 @@ void updateLvglMainScreen( // Place negative sign if needed (one position left of leftmost digit) if (isNegative && leftmost_pos > 0) { - lv_label_set_text(altitude_char_labels[leftmost_pos - 1], "-"); + altitude_texts[leftmost_pos - 1] = "-"; } // Thousands digit if (thousands > 0) { - snprintf(digit_buffers[0], sizeof(digit_buffers[0]), "%d", thousands); - lv_label_set_text(altitude_char_labels[0], digit_buffers[0]); + snprintf(altitude_digit_buffers[0], sizeof(altitude_digit_buffers[0]), "%d", thousands); + altitude_texts[0] = altitude_digit_buffers[0]; } // Hundreds digit if (thousands > 0 || hundreds > 0) { - snprintf(digit_buffers[1], sizeof(digit_buffers[1]), "%d", hundreds); - lv_label_set_text(altitude_char_labels[1], digit_buffers[1]); + snprintf(altitude_digit_buffers[1], sizeof(altitude_digit_buffers[1]), "%d", hundreds); + altitude_texts[1] = altitude_digit_buffers[1]; } // Tens digit if (whole_part >= 10) { - snprintf(digit_buffers[2], sizeof(digit_buffers[2]), "%d", tens); - lv_label_set_text(altitude_char_labels[2], digit_buffers[2]); + snprintf(altitude_digit_buffers[2], sizeof(altitude_digit_buffers[2]), "%d", tens); + altitude_texts[2] = altitude_digit_buffers[2]; } // Ones digit (always show) - snprintf(digit_buffers[3], sizeof(digit_buffers[3]), "%d", ones); - lv_label_set_text(altitude_char_labels[3], digit_buffers[3]); + snprintf(altitude_digit_buffers[3], sizeof(altitude_digit_buffers[3]), "%d", ones); + altitude_texts[3] = altitude_digit_buffers[3]; // Decimal point - lv_label_set_text(altitude_char_labels[4], "."); + altitude_texts[4] = "."; // Tenths digit - snprintf(digit_buffers[5], sizeof(digit_buffers[5]), "%d", decimal_part); - lv_label_set_text(altitude_char_labels[5], digit_buffers[5]); + snprintf(altitude_digit_buffers[5], sizeof(altitude_digit_buffers[5]), "%d", decimal_part); + altitude_texts[5] = altitude_digit_buffers[5]; - // Adjust width of position 4 back to narrow for meters (decimal point) + // Adjust width of position 4 back to narrow for meters (decimal point). + // lv_obj_set_size is internally change-detected, so this is free when + // the unit mode hasn't changed. lv_obj_set_size(altitude_char_labels[4], 8, 30); // decimal_width=8, char_height=30 // Unit - lv_label_set_text(altitude_char_labels[6], "m"); + altitude_texts[6] = "m"; } else { // For feet - no decimal point needed float feet_float = altitude * 3.28084f; @@ -704,16 +770,9 @@ void updateLvglMainScreen( int tens = (feet / 10) % 10; int ones = feet % 10; - static char digit_buffers_ft[7][12]; // Static buffers for feet - // Adjust width of position 4 for feet (should be normal width, not narrow) lv_obj_set_size(altitude_char_labels[4], 17, 30); // char_width=19, char_height=30 - // Clear all positions first - for (int i = 0; i < 7; i++) { - lv_label_set_text(altitude_char_labels[i], ""); - } - // Determine the leftmost digit position needed int leftmost_pos = 4; // ones position (always needed for feet) if (feet >= 10) leftmost_pos = 3; // tens @@ -723,117 +782,152 @@ void updateLvglMainScreen( // Place negative sign if needed (one position left of leftmost digit) if (isNegative && leftmost_pos > 0) { - lv_label_set_text(altitude_char_labels[leftmost_pos - 1], "-"); + altitude_texts[leftmost_pos - 1] = "-"; } // Ten-thousands digit in position 0 if (ten_thousands > 0) { - snprintf(digit_buffers_ft[0], sizeof(digit_buffers_ft[0]), "%d", ten_thousands); - lv_label_set_text(altitude_char_labels[0], digit_buffers_ft[0]); + snprintf(altitude_digit_buffers[0], sizeof(altitude_digit_buffers[0]), "%d", ten_thousands); + altitude_texts[0] = altitude_digit_buffers[0]; } // Thousands digit in position 1 if (ten_thousands > 0 || thousands > 0) { - snprintf(digit_buffers_ft[1], sizeof(digit_buffers_ft[1]), "%d", thousands); - lv_label_set_text(altitude_char_labels[1], digit_buffers_ft[1]); + snprintf(altitude_digit_buffers[1], sizeof(altitude_digit_buffers[1]), "%d", thousands); + altitude_texts[1] = altitude_digit_buffers[1]; } // Hundreds digit in position 2 if (ten_thousands > 0 || thousands > 0 || hundreds > 0) { - snprintf(digit_buffers_ft[2], sizeof(digit_buffers_ft[2]), "%d", hundreds); - lv_label_set_text(altitude_char_labels[2], digit_buffers_ft[2]); + snprintf(altitude_digit_buffers[2], sizeof(altitude_digit_buffers[2]), "%d", hundreds); + altitude_texts[2] = altitude_digit_buffers[2]; } // Tens digit in position 3 if (feet >= 10) { - snprintf(digit_buffers_ft[3], sizeof(digit_buffers_ft[3]), "%d", tens); - lv_label_set_text(altitude_char_labels[3], digit_buffers_ft[3]); + snprintf(altitude_digit_buffers[3], sizeof(altitude_digit_buffers[3]), "%d", tens); + altitude_texts[3] = altitude_digit_buffers[3]; } // Ones digit in position 4 (always show) - now has normal width - snprintf(digit_buffers_ft[4], sizeof(digit_buffers_ft[4]), "%d", ones); - lv_label_set_text(altitude_char_labels[4], digit_buffers_ft[4]); + snprintf(altitude_digit_buffers[4], sizeof(altitude_digit_buffers[4]), "%d", ones); + altitude_texts[4] = altitude_digit_buffers[4]; // Feet unit in position 6 (same as meters, with proper spacing) - lv_label_set_text(altitude_char_labels[6], "f"); + altitude_texts[6] = "f"; } } - // Update temperature labels + for (int i = 0; i < 7; i++) { + setLabelText(altitude_char_labels[i], altitude_texts[i]); + } + + // Update temperature labels. + // Tile background styles (normal/warning/critical) are applied only on + // level transitions: add/remove_style invalidates the tile on every call, + // which used to redraw active warning tiles every single frame. // -- Battery Temperature -- if (batt_temp_label != NULL && batt_letter_label != NULL) { // Check labels exist - lv_obj_remove_style(batt_temp_bg, &style_warning, 0); - lv_obj_remove_style(batt_temp_bg, &style_critical, 0); - + uint8_t battTempLevel = 0; // 0 = normal/no data, 1 = warning, 2 = critical if (bmsTelemetry.bmsState == TelemetryState::CONNECTED && hasValidBatteryTemp) { - lv_label_set_text_fmt(batt_temp_label, "%d", static_cast(batteryTemp)); + char buffer[12]; + snprintf(buffer, sizeof(buffer), "%d", static_cast(batteryTemp)); + setLabelText(batt_temp_label, buffer); if (batteryTemp >= bmsCellTempThresholds.critHigh) { + battTempLevel = 2; + } else if (batteryTemp >= bmsCellTempThresholds.warnHigh) { + battTempLevel = 1; + } + } else { + // No valid cell probe connected: show "-" instead of a fake low reading. + setLabelText(batt_temp_label, "-"); + } + + if (battTempLevel != lastBattTempLevel) { + lv_obj_remove_style(batt_temp_bg, &style_warning, 0); + lv_obj_remove_style(batt_temp_bg, &style_critical, 0); + if (battTempLevel == 2) { lv_obj_add_style(batt_temp_bg, &style_critical, 0); lv_obj_remove_flag(batt_temp_bg, LV_OBJ_FLAG_HIDDEN); - } else if (batteryTemp >= bmsCellTempThresholds.warnHigh) { + } else if (battTempLevel == 1) { lv_obj_add_style(batt_temp_bg, &style_warning, 0); lv_obj_remove_flag(batt_temp_bg, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(batt_temp_bg, LV_OBJ_FLAG_HIDDEN); } - } else { - // No valid cell probe connected: show "-" instead of a fake low reading. - lv_label_set_text(batt_temp_label, "-"); - lv_obj_add_flag(batt_temp_bg, LV_OBJ_FLAG_HIDDEN); + lastBattTempLevel = battTempLevel; } } // -- ESC Temperature -- if (esc_temp_label != NULL && esc_letter_label != NULL) { // Check labels exist - lv_obj_remove_style(esc_temp_bg, &style_warning, 0); - lv_obj_remove_style(esc_temp_bg, &style_critical, 0); - + uint8_t escTempLevel = 0; if (escTelemetry.escState == TelemetryState::CONNECTED && !isnan(escTemp)) { - lv_label_set_text_fmt(esc_temp_label, "%d", static_cast(escTemp)); - + char buffer[12]; + snprintf(buffer, sizeof(buffer), "%d", static_cast(escTemp)); + setLabelText(esc_temp_label, buffer); if (escTemp >= escTempThresholds->critHigh) { + escTempLevel = 2; + } else if (escTemp >= escTempThresholds->warnHigh) { + escTempLevel = 1; + } + } else { + setLabelText(esc_temp_label, "-"); + } + + if (escTempLevel != lastEscTempLevel) { + lv_obj_remove_style(esc_temp_bg, &style_warning, 0); + lv_obj_remove_style(esc_temp_bg, &style_critical, 0); + if (escTempLevel == 2) { lv_obj_add_style(esc_temp_bg, &style_critical, 0); lv_obj_remove_flag(esc_temp_bg, LV_OBJ_FLAG_HIDDEN); - } else if (escTemp >= escTempThresholds->warnHigh) { + } else if (escTempLevel == 1) { lv_obj_add_style(esc_temp_bg, &style_warning, 0); lv_obj_remove_flag(esc_temp_bg, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(esc_temp_bg, LV_OBJ_FLAG_HIDDEN); } - } else { - lv_label_set_text(esc_temp_label, "-"); - lv_obj_add_flag(esc_temp_bg, LV_OBJ_FLAG_HIDDEN); + lastEscTempLevel = escTempLevel; } } // -- Motor Temperature -- if (motor_temp_label != NULL && motor_letter_label != NULL) { // Check labels exist - lv_obj_remove_style(motor_temp_bg, &style_warning, 0); - lv_obj_remove_style(motor_temp_bg, &style_critical, 0); - + uint8_t motorTempLevel = 0; // Show motor temp only for a valid numeric reading while ESC is connected. if (escTelemetry.escState == TelemetryState::CONNECTED && !isnan(motorTemp)) { - lv_label_set_text_fmt(motor_temp_label, "%d", static_cast(motorTemp)); - + char buffer[12]; + snprintf(buffer, sizeof(buffer), "%d", static_cast(motorTemp)); + setLabelText(motor_temp_label, buffer); if (motorTemp >= motorTempThresholds.critHigh) { + motorTempLevel = 2; + } else if (motorTemp >= motorTempThresholds.warnHigh) { + motorTempLevel = 1; + } + } else { + setLabelText(motor_temp_label, "-"); + } + + if (motorTempLevel != lastMotorTempLevel) { + lv_obj_remove_style(motor_temp_bg, &style_warning, 0); + lv_obj_remove_style(motor_temp_bg, &style_critical, 0); + if (motorTempLevel == 2) { lv_obj_add_style(motor_temp_bg, &style_critical, 0); lv_obj_remove_flag(motor_temp_bg, LV_OBJ_FLAG_HIDDEN); - } else if (motorTemp >= motorTempThresholds.warnHigh) { + } else if (motorTempLevel == 1) { lv_obj_add_style(motor_temp_bg, &style_warning, 0); lv_obj_remove_flag(motor_temp_bg, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(motor_temp_bg, LV_OBJ_FLAG_HIDDEN); } - } else { - lv_label_set_text(motor_temp_label, "-"); - lv_obj_add_flag(motor_temp_bg, LV_OBJ_FLAG_HIDDEN); + lastMotorTempLevel = motorTempLevel; } } // Update armed indicator if (armed) { // Set background to CYAN when armed, regardless of cruise state - lv_obj_set_style_bg_color(arm_indicator, LVGL_CYAN, LV_PART_MAIN); + setBgColor(arm_indicator, LVGL_CYAN, LV_PART_MAIN); lv_obj_remove_flag(arm_indicator, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(arm_indicator, LV_OBJ_FLAG_HIDDEN); @@ -848,7 +942,7 @@ void updateLvglMainScreen( lv_obj_add_flag(cruise_icon_img, LV_OBJ_FLAG_HIDDEN); } // Restore original color after flashing is done - lv_obj_set_style_image_recolor(cruise_icon_img, original_cruise_icon_color, LV_PART_MAIN); + setImageRecolor(cruise_icon_img, original_cruise_icon_color); } // Update Charging Icon Visibility - only when BMS is connected and reports charging @@ -866,11 +960,11 @@ void updateLvglMainScreen( if (!isFlashingArmFailIcon && arm_fail_warning_icon_img != NULL) { lv_obj_add_flag(arm_fail_warning_icon_img, LV_OBJ_FLAG_HIDDEN); // Ensure color is reset if flashing ended abruptly elsewhere (though cb should handle it) - lv_obj_set_style_image_recolor(arm_fail_warning_icon_img, original_arm_fail_icon_color, LV_PART_MAIN); + setImageRecolor(arm_fail_warning_icon_img, original_arm_fail_icon_color); } // Keep the BLE icon synced from the UI task so missed callback updates recover. - // Pairing mode flash takes priority over the solid-connected state — the + // Pairing mode flash takes priority over the solid-connected state - the // flashing Bluetooth symbol communicates "ready to pair" for the entire 60s // window, even after the phone has connected. Once the window ends the icon // settles solid (if still connected) or hides. diff --git a/src/sp140/main.cpp b/src/sp140/main.cpp index 4c64584d..dcebf027 100644 --- a/src/sp140/main.cpp +++ b/src/sp140/main.cpp @@ -469,10 +469,10 @@ void monitoringTask(void *pvParameters) { } } -// UI task: fixed 20 Hz refresh and snapshot publish +// UI task: fixed ~30 Hz refresh and snapshot publish void uiTask(void *pvParameters) { TickType_t lastWake = xTaskGetTickCount(); - const TickType_t uiTicks = pdMS_TO_TICKS(50); // 20 Hz + const TickType_t uiTicks = pdMS_TO_TICKS(33); // ~30 Hz, matches LV_DEF_REFR_PERIOD for (;;) { refreshDisplay(); lastUiRunMs = millis(); diff --git a/test/test_screenshots/emulator_display.cpp b/test/test_screenshots/emulator_display.cpp index 307385f9..b0fce4a6 100644 --- a/test/test_screenshots/emulator_display.cpp +++ b/test/test_screenshots/emulator_display.cpp @@ -20,7 +20,6 @@ static uint8_t lvgl_buf1[SCREEN_WIDTH * SCREEN_HEIGHT * 2] __attribute__((aligne lv_display_t* main_display = nullptr; int8_t displayCS = -1; Adafruit_ST7735* tft_driver = nullptr; -uint32_t lvgl_last_update = 0; SemaphoreHandle_t spiBusMutex = nullptr; // Define lvglMutex from lvgl_updates.h From c65b0c87f16e0828772fe931ea1c5586021a54bb Mon Sep 17 00:00:00 2001 From: Paul <36135447+PaulDWhite@users.noreply.github.com> Date: Tue, 9 Jun 2026 23:39:05 -0400 Subject: [PATCH 04/15] Remove leftover merge conflict markers in climb rate indicator --- src/sp140/lvgl/lvgl_updates.cpp | 6 ------ 1 file changed, 6 deletions(-) diff --git a/src/sp140/lvgl/lvgl_updates.cpp b/src/sp140/lvgl/lvgl_updates.cpp index d3b65951..43f71953 100644 --- a/src/sp140/lvgl/lvgl_updates.cpp +++ b/src/sp140/lvgl/lvgl_updates.cpp @@ -427,7 +427,6 @@ void updateClimbRateIndicator(float climbRate) { lv_color_make(75, 0, 130) // Dark purple (again) }; -<<<<<<< HEAD // Compute the desired state for all 12 sections, then diff-apply so only // sections that actually changed are invalidated and redrawn. lv_opa_t section_opa[12]; @@ -437,8 +436,6 @@ void updateClimbRateIndicator(float climbRate) { section_color[i] = lv_color_black(); // unused while transparent } -======= ->>>>>>> origin/master if (climbRate >= kVarioDeadzone) { // Positive climb rate - fill sections above center line int sectionsToFill = (int)(climbRate / kVarioSegment); @@ -461,7 +458,6 @@ void updateClimbRateIndicator(float climbRate) { } } // If |climb rate| is within kVarioDeadzone, no sections are filled (neutral) -<<<<<<< HEAD for (int i = 0; i < 12; i++) { if (climb_rate_fill_sections[i] == NULL) continue; @@ -471,8 +467,6 @@ void updateClimbRateIndicator(float climbRate) { } setBgOpa(climb_rate_fill_sections[i], section_opa[i]); } -======= ->>>>>>> origin/master } void updateLvglMainScreen( From d20a7471c470b3dc2bca8fbb7bea4b2afd49739f Mon Sep 17 00:00:00 2001 From: Paul <36135447+PaulDWhite@users.noreply.github.com> Date: Wed, 10 Jun 2026 14:47:45 -0400 Subject: [PATCH 05/15] Update sdkconfig.OpenPPG-CESP32S3-CAN-SP140 --- sdkconfig.OpenPPG-CESP32S3-CAN-SP140 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sdkconfig.OpenPPG-CESP32S3-CAN-SP140 b/sdkconfig.OpenPPG-CESP32S3-CAN-SP140 index 745f5402..07fc5eef 100644 --- a/sdkconfig.OpenPPG-CESP32S3-CAN-SP140 +++ b/sdkconfig.OpenPPG-CESP32S3-CAN-SP140 @@ -1068,7 +1068,7 @@ CONFIG_FREERTOS_ASSERT_FAIL_ABORT=y # CONFIG_FREERTOS_ASSERT_FAIL_PRINT_CONTINUE is not set # CONFIG_FREERTOS_ASSERT_DISABLE is not set CONFIG_FREERTOS_IDLE_TASK_STACKSIZE=1536 -CONFIG_FREERTOS_ISR_STACKSIZE=1536 +CONFIG_FREERTOS_ISR_STACKSIZE=2096 # CONFIG_FREERTOS_LEGACY_HOOKS is not set CONFIG_FREERTOS_MAX_TASK_NAME_LEN=16 CONFIG_FREERTOS_SUPPORT_STATIC_ALLOCATION=y From 7df86a4f325db0f89e9e44a806e3671c76c9a2c7 Mon Sep 17 00:00:00 2001 From: Paul <36135447+PaulDWhite@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:51:12 -0400 Subject: [PATCH 06/15] Add ESC config relay over BLE/CAN Introduce an ESC configuration relay that forwards app BLE commands to the ESC over CAN and verifies the write across an ESC reboot. Adds BLE UUIDs and two characteristics (command + status), a new esc_config_relay module (header + implementation) implementing a non-blocking state machine (write -> save -> restart -> verify) that runs on the throttle/CAN owner task, and a small API (escAdapter, init/service tick, request/status). Enforce safety: sessions only run when DISARMED and arming is blocked while a session is active. Modified files: BLE service registration and callbacks, ESC init/read loop to integrate the relay, main arming check, and esc.h/ble ids updates. --- inc/sp140/ble/ble_ids.h | 6 + inc/sp140/esc.h | 5 + inc/sp140/esc_config_relay.h | 102 ++++++ src/sp140/ble/config_service.cpp | 66 ++++ src/sp140/esc.cpp | 11 + src/sp140/esc_config_relay.cpp | 515 +++++++++++++++++++++++++++++++ src/sp140/main.cpp | 8 + 7 files changed, 713 insertions(+) create mode 100644 inc/sp140/esc_config_relay.h create mode 100644 src/sp140/esc_config_relay.cpp diff --git a/inc/sp140/ble/ble_ids.h b/inc/sp140/ble/ble_ids.h index ad651c22..f635dc10 100644 --- a/inc/sp140/ble/ble_ids.h +++ b/inc/sp140/ble/ble_ids.h @@ -19,6 +19,12 @@ #define THROTTLE_VALUE_UUID "50AB3859-9FBF-4D30-BF97-2516EE632FAD" #define DEVICE_STATE_UUID "8F80BCF5-B58F-4908-B079-E8AD6F5EE257" +// ESC config relay (phone -> controller -> ESC over CAN). CMD is written by the +// app (opcode-multiplexed); STATUS is read/notify for the async result. +// See: powerpack-flash-qc/configs/ESC-Config-Relay-Design.md +#define ESC_RELAY_CMD_UUID "E5C0C0DE-0001-4A5C-9B21-7E5C0F1A2B30" +#define ESC_RELAY_STATUS_UUID "E5C0C0DE-0002-4A5C-9B21-7E5C0F1A2B30" + // Device info service #define DEVICE_INFO_SERVICE_UUID "180A" #define MANUFACTURER_NAME_UUID "2A29" diff --git a/inc/sp140/esc.h b/inc/sp140/esc.h index 86f43529..972d858c 100644 --- a/inc/sp140/esc.h +++ b/inc/sp140/esc.h @@ -30,6 +30,11 @@ void setESCThrottle(int throttlePWM); void readESCTelemetry(); bool setupTWAI(); +// Accessor to the single shared CanardAdapter (the controller's sole CAN owner). +// Used by the ESC config relay so its requests go out on the same adapter the +// throttle task drives — keeping all CAN traffic on one task, no mutex needed. +CanardAdapter& escAdapter(); + // Request ESC hardware info (HW ID, FW version, bootloader, serial number). // Thread-safe: sets a flag consumed by readESCTelemetry() on its next tick. // Also called automatically the first time the ESC connects. diff --git a/inc/sp140/esc_config_relay.h b/inc/sp140/esc_config_relay.h new file mode 100644 index 00000000..aed4a2d2 --- /dev/null +++ b/inc/sp140/esc_config_relay.h @@ -0,0 +1,102 @@ +#ifndef INC_SP140_ESC_CONFIG_RELAY_H_ +#define INC_SP140_ESC_CONFIG_RELAY_H_ + +#include + +// ============================================================================= +// ESC Configuration Relay +// +// Relays SINE / Mad Motors ESC parameter-config commands from the phone app +// (over BLE) to the ESC (over CAN), using the proven SetConfig / SaveConfig / +// RestartNode / GetConfig sequence ported from powerpack-flash-qc. +// +// Concurrency model: the controller's single CAN owner is the throttle task +// (readESCTelemetry() -> adapter.processTxRxOnce()). All CAN traffic for a +// relay session therefore runs on that task via escConfigRelayServiceTick(). +// The BLE task only ENQUEUES a request (escConfigRelayRequest*), exactly like +// the existing requestEscHardwareInfo() flag handoff in esc.cpp. The CanardAdapter +// is never touched from two tasks, so no mutex is needed on it. +// +// Safety: a session only starts while the device is DISARMED. The session is +// fully non-blocking — it never calls delay()/vTaskDelay(), so it cannot stall +// the 50 Hz control loop. Only the ESC reboots; the controller does not. +// +// See: powerpack-flash-qc/configs/ESC-Config-Relay-Design.md +// ============================================================================= + +// Canonical "reverse motor direction" parameter (SINE config_id 0x0080, UINT16, +// 0 = positive/normal, 1 = inversion/reversed). It is a "basic" parameter, +// documented as accessible WITHOUT the password — see +// powerpack-flash-qc/configs/ESC-CAN-Config-Protocol.md. +static const uint16_t ESC_PARAM_DIRECTION = 0x0080; + +enum class EscRelayPhase : uint8_t { + IDLE = 0, + UNLOCK, + WRITE, + SAVE, + RESTART, + WAIT_REBOOT, + VERIFY, + DONE_OK, + DONE_FAIL, +}; + +// Status codes surfaced to the app (ESC_RELAY_STATUS notify byte 0 in the +// design doc). +enum class EscRelayStatusCode : uint8_t { + IDLE = 0x00, + ACCEPTED = 0x01, + RUNNING = 0x02, + REBOOTING = 0x03, + VERIFIED_OK = 0x04, + FAILED = 0x80, + REJECTED_ARMED = 0x81, + TIMEOUT = 0x82, + VERIFY_MISMATCH = 0x83, + ESC_FLAG_ERROR = 0x85, +}; + +struct EscRelayStatus { + EscRelayStatusCode code; + EscRelayPhase phase; + uint16_t config_id; + uint8_t detail; // raw ESC flag byte on failure, else 0 + uint8_t readback[8]; // verified read-back bytes (valid on VERIFIED_OK) + uint8_t readback_len; +}; + +// Call once from initESC() (after the CanardAdapter has been begun). +void escConfigRelayInit(); + +// Drive the session state machine. MUST be called from the throttle task — it +// is invoked from readESCTelemetry(). Non-blocking. +void escConfigRelayServiceTick(); + +// ---- Request API (safe to call from the BLE task) -------------------------- +// Returns false if a session is already in progress, the payload is too large, +// or the relay is not initialized. The DISARMED check is (re)enforced on the +// throttle task before the session actually starts. +// +// Writes a single parameter, persists it (SaveConfig), restarts the ESC, then +// verifies the read-back across the reboot (GetConfig byte-compare). +bool escConfigRelayRequestSetParam(uint16_t config_id, + const uint8_t* data, uint8_t len); + +// Convenience for the canonical reverse-direction toggle. +bool escConfigRelayRequestReverseDirection(bool reversed); + +// Latched status snapshot for the BLE status characteristic / UI. +EscRelayStatus escConfigRelayGetStatus(); + +// True while a session is in progress OR a request is pending (for the arm +// interlock — block arming while this is true). +bool escConfigRelayIsActive(); + +// Require PasswordUnlock (SINE service 225) before writing, and re-unlock after +// the ESC reboots. Default false. The reverse-direction param does NOT need it; +// restricted params do. The decisive bench test runs with this false to confirm +// that 0x0080 is writable from the controller's node 0x01 with no unlock. +void escConfigRelaySetRequireUnlock(bool require); + +#endif // INC_SP140_ESC_CONFIG_RELAY_H_ diff --git a/src/sp140/ble/config_service.cpp b/src/sp140/ble/config_service.cpp index 8196d25b..3020ea68 100644 --- a/src/sp140/ble/config_service.cpp +++ b/src/sp140/ble/config_service.cpp @@ -18,6 +18,7 @@ #include "sp140/throttle.h" #include "version.h" #include "sp140/ble/ota_service.h" +#include "sp140/esc_config_relay.h" extern void writeDeviceData(); extern QueueHandle_t throttleUpdateQueue; @@ -260,6 +261,60 @@ class MetricTempCallbacks : public NimBLECharacteristicCallbacks { } }; +// ESC config relay command characteristic. The app writes an opcode-multiplexed +// payload; we parse it and hand a single-parameter request to the relay module, +// which runs the write/save/restart/verify on the throttle task (CAN owner). +// 0x10 SET_PARAM / 0x1F SET_AND_COMMIT : [op][config_id u16 LE][len u8][data...] +// The DISARMED gate is enforced inside the relay (status reports REJECTED_ARMED). +class EscRelayCmdCallbacks : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { + (void)connInfo; + std::string value = characteristic->getValue(); + if (value.size() < 1) return; + const uint8_t op = static_cast(value[0]); + switch (op) { + case 0x10: // SET_PARAM (single param, write + commit + verify) + case 0x1F: { // SET_AND_COMMIT + if (value.size() < 4) return; // op + config_id(2) + len(1) + uint16_t configId = static_cast(value[1]) | + (static_cast(static_cast(value[2])) << 8); + uint8_t len = static_cast(value[3]); + if (len > 48 || value.size() < static_cast(4 + len)) return; + bool accepted = escConfigRelayRequestSetParam( + configId, reinterpret_cast(value.data()) + 4, len); + USBSerial.printf("ESC relay cmd op=0x%02X id=0x%04X len=%d accepted=%d\n", + op, configId, len, accepted); + break; + } + default: + USBSerial.printf("ESC relay: unknown opcode 0x%02X\n", op); + break; + } + } +}; + +// ESC config relay status characteristic (read + notify). Returns the latched +// session status so the app can poll until a terminal result (the design's +// poll-until-terminal contract, which also survives a BLE drop + reconnect). +// [code][config_id u16 LE][phase][detail][progress u16 LE][readback...] +class EscRelayStatusCallbacks : public NimBLECharacteristicCallbacks { + void onRead(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { + (void)connInfo; + EscRelayStatus s = escConfigRelayGetStatus(); + uint8_t buf[16]; + buf[0] = static_cast(s.code); + buf[1] = s.config_id & 0xFF; + buf[2] = (s.config_id >> 8) & 0xFF; + buf[3] = static_cast(s.phase); + buf[4] = s.detail; + buf[5] = 0; // progress permille LSB (config = 0) + buf[6] = 0; // progress permille MSB + uint8_t n = (s.readback_len > 8) ? 8 : s.readback_len; + for (uint8_t i = 0; i < n; i++) buf[7 + i] = s.readback[i]; + characteristic->setValue(buf, 7 + n); + } +}; + } // namespace void initConfigBleService(NimBLEServer* server, const std::string& uniqueId) { @@ -361,6 +416,17 @@ void initConfigBleService(NimBLEServer* server, const std::string& uniqueId) { static ThrottleValueCallbacks throttleValueCallbacks; pThrottleCharacteristic->setCallbacks(&throttleValueCallbacks); + // ESC config relay: command (write) + status (read/notify). + NimBLECharacteristic* escRelayCmd = configService->createCharacteristic( + NimBLEUUID(ESC_RELAY_CMD_UUID), kReadWriteSecure); + static EscRelayCmdCallbacks escRelayCmdCallbacks; + escRelayCmd->setCallbacks(&escRelayCmdCallbacks); + + NimBLECharacteristic* escRelayStatus = configService->createCharacteristic( + NimBLEUUID(ESC_RELAY_STATUS_UUID), kNotifyReadSecure); + static EscRelayStatusCallbacks escRelayStatusCallbacks; + escRelayStatus->setCallbacks(&escRelayStatusCallbacks); + NimBLEService* deviceInfoService = server->createService(NimBLEUUID(DEVICE_INFO_SERVICE_UUID)); NimBLECharacteristic* manufacturer = deviceInfoService->createCharacteristic( NimBLEUUID(MANUFACTURER_NAME_UUID), kReadSecure); diff --git a/src/sp140/esc.cpp b/src/sp140/esc.cpp index 7c470503..24f05f04 100644 --- a/src/sp140/esc.cpp +++ b/src/sp140/esc.cpp @@ -1,5 +1,6 @@ #include "sp140/esc.h" #include "sp140/globals.h" +#include "sp140/esc_config_relay.h" #include #pragma GCC diagnostic ignored "-Wmissing-field-initializers" @@ -14,6 +15,9 @@ static CanardAdapter adapter; static uint8_t memory_pool[1024] __attribute__((aligned(8))); static SineEsc esc(adapter); + +// Expose the single shared adapter to the ESC config relay module. +CanardAdapter& escAdapter() { return adapter; } static unsigned long lastSuccessfulCommTimeMs = 0; // Store millis() time of last successful ESC comm // Flag set by requestEscHardwareInfo() (may be called from BLE task), // consumed safely inside readESCTelemetry() on the throttle task. @@ -206,6 +210,9 @@ void initESC() { esc.setThrottleSettings2(IdleThrottle_us); adapter.processTxRxOnce(); vTaskDelay(pdMS_TO_TICKS(20)); // Wait for ESC to process the command + + // Register the ESC config-relay node on the shared adapter (no-op if already). + escConfigRelayInit(); } /** @@ -337,6 +344,10 @@ void readESCTelemetry() { syncEscOutputs(); adapter.processTxRxOnce(); // Process CAN messages + + // Drive any in-flight ESC config-relay session. Runs on the throttle task so + // all CAN traffic stays single-owner. Non-blocking; only acts when DISARMED. + escConfigRelayServiceTick(); } /** diff --git a/src/sp140/esc_config_relay.cpp b/src/sp140/esc_config_relay.cpp new file mode 100644 index 00000000..4eb844ae --- /dev/null +++ b/src/sp140/esc_config_relay.cpp @@ -0,0 +1,515 @@ +#include "sp140/esc_config_relay.h" + +#include +#include +#include +#include +#include + +#include "sp140/esc.h" // escAdapter() +#include "sp140/device_state.h" // DeviceState + +// Device arming state, owned by the device-state machine (same symbol the OTA +// service gates on — see ble/ota_service.cpp). Relay only runs when DISARMED. +extern volatile DeviceState currentState; + +// ============================================================================= +// SINE / Mad Motors vendor config service definitions +// (ported from powerpack-flash-qc/inc/esc_config_protocol.h — kept local so the +// controller does not depend on the flash-qc tree) +// ============================================================================= +#define SINE_ESC_SETCONFIG_ID 220 +#define SINE_ESC_SETCONFIG_SIGNATURE 0x8764693A96F2952EULL +#define SINE_ESC_GETCONFIG_ID 221 +#define SINE_ESC_GETCONFIG_SIGNATURE 0x2EEE28C9EB22CF0CULL +#define SINE_ESC_SAVECONFIG_ID 222 +#define SINE_ESC_SAVECONFIG_SIGNATURE 0xA3F15F80DDB60005ULL +#define SINE_ESC_PASSWORD_UNLOCK_ID 225 +#define SINE_ESC_PASSWORD_UNLOCK_SIGNATURE 0xB970F68E635BAD53ULL + +static const uint8_t ESC_CAN_NODE_ID = 0x20; // ESC's CAN node id (esc.cpp esc.begin(0x20)) +static const char ESC_PASSWORD[] = "123456"; // confirmed via Mad Motors PC-tool capture + +// Per-request timing. Each tick (~20 ms) issues at most one request and polls +// for its response across subsequent ticks — never blocking. +static const unsigned long RELAY_TIMEOUT_MS = 200; // per-request response wait +static const uint8_t RELAY_MAX_RETRIES = 8; +static const unsigned long REBOOT_WAIT_MS = 5000; // ESC reboot before it answers again +static const unsigned long WARMUP_WINDOW_MS = 9000; // probe window after reboot wait +static const unsigned long REUNLOCK_WINDOW_MS = 5000; // post-reboot unlock retries window +static const unsigned long PROBE_INTERVAL_MS = 400; + +// ============================================================================= +// Decoded responses (written on the throttle task from the Canard callback, +// read in the same task by the state machine — single-task, so plain statics). +// ============================================================================= +struct SetConfigResp { uint16_t recv_config_id; int8_t flag; }; +struct GetConfigResp { uint16_t recv_config_id; int8_t flag; uint8_t data[48]; uint8_t data_len; }; +struct SaveConfigResp { uint8_t save_success; uint8_t save_error; uint8_t state; }; +struct UnlockResp { uint16_t cmd_id; uint8_t flag; }; + +static volatile bool s_gotSet = false, s_gotGet = false, s_gotSave = false, s_gotUnlock = false; +static SetConfigResp s_setResp; +static GetConfigResp s_getResp; +static SaveConfigResp s_saveResp; +static UnlockResp s_unlockResp; + +static uint8_t s_tidSet = 0, s_tidGet = 0, s_tidSave = 0, s_tidUnlock = 0, s_tidRestart = 0; + +// ============================================================================= +// CanardAdapterNode that captures the ESC's config-service responses. +// Mirrors EscConfigNode in powerpack-flash-qc/src/esc_config.cpp. +// ============================================================================= +class EscConfigRelayNode : public CanardAdapterNode { + public: + explicit EscConfigRelayNode(CanardAdapter& adapter) : CanardAdapterNode(adapter) {} + void begin() { _beginNode(); } + CanardInstance* canard() { return _canard; } + + bool shouldAcceptTransfer(uint64_t* out_signature, + uint16_t data_type_id, + CanardTransferType transfer_type, + uint8_t source_node_id) override { + if (transfer_type != CanardTransferTypeResponse || source_node_id != ESC_CAN_NODE_ID) + return false; + switch (data_type_id) { + case SINE_ESC_SETCONFIG_ID: *out_signature = SINE_ESC_SETCONFIG_SIGNATURE; return true; + case SINE_ESC_GETCONFIG_ID: *out_signature = SINE_ESC_GETCONFIG_SIGNATURE; return true; + case SINE_ESC_SAVECONFIG_ID: *out_signature = SINE_ESC_SAVECONFIG_SIGNATURE; return true; + case SINE_ESC_PASSWORD_UNLOCK_ID:*out_signature = SINE_ESC_PASSWORD_UNLOCK_SIGNATURE; return true; + case UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_ID: + *out_signature = UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_SIGNATURE; return true; + default: return false; + } + } + + void onTransferReceived(CanardRxTransfer* t) override { + if (t->transfer_type != CanardTransferTypeResponse) return; + uint32_t ofs = 0; + switch (t->data_type_id) { + case SINE_ESC_SETCONFIG_ID: { + canardDecodeScalar(t, 0, 16, false, &s_setResp.recv_config_id); + int8_t flag = 0; canardDecodeScalar(t, 16, 8, true, &flag); + s_setResp.flag = flag; s_gotSet = true; + break; + } + case SINE_ESC_GETCONFIG_ID: { + canardDecodeScalar(t, 0, 16, false, &s_getResp.recv_config_id); + int8_t flag = 0; canardDecodeScalar(t, 16, 8, true, &flag); + s_getResp.flag = flag; ofs = 24; + uint16_t total_bits = (uint16_t)t->payload_len * 8; + s_getResp.data_len = 0; + while (ofs + 8 <= total_bits && s_getResp.data_len < 48) { + canardDecodeScalar(t, ofs, 8, false, &s_getResp.data[s_getResp.data_len]); + ofs += 8; s_getResp.data_len++; + } + s_gotGet = true; + break; + } + case SINE_ESC_SAVECONFIG_ID: { + canardDecodeScalar(t, 0, 8, false, &s_saveResp.save_success); + s_gotSave = true; + break; + } + case SINE_ESC_PASSWORD_UNLOCK_ID: { + canardDecodeScalar(t, 0, 16, false, &s_unlockResp.cmd_id); + canardDecodeScalar(t, 16, 8, false, &s_unlockResp.flag); + s_gotUnlock = true; + break; + } + default: break; + } + } +}; + +static EscConfigRelayNode* s_node = nullptr; + +// ============================================================================= +// Send helpers (all run on the throttle task) +// ============================================================================= +static void sendSetConfig(uint16_t config_id, const uint8_t* data, uint8_t len) { + uint8_t buffer[64]; + memset(buffer, 0, sizeof(buffer)); + uint32_t bit = 0; + canardEncodeScalar(buffer, bit, 16, &config_id); bit += 16; + for (uint8_t i = 0; i < len && i < 48; i++) { canardEncodeScalar(buffer, bit, 8, &data[i]); bit += 8; } + uint32_t payload_len = (bit + 7) / 8; + + CanardTxTransfer transfer = { + .transfer_type = CanardTransferTypeRequest, + .data_type_signature = SINE_ESC_SETCONFIG_SIGNATURE, + .data_type_id = SINE_ESC_SETCONFIG_ID, + .inout_transfer_id = &s_tidSet, + .priority = CANARD_TRANSFER_PRIORITY_HIGHEST, + .payload = buffer, + .payload_len = (uint16_t)payload_len + }; + canardRequestOrRespondObj(s_node->canard(), ESC_CAN_NODE_ID, &transfer); +} + +static void sendGetConfig(uint16_t config_id) { + uint8_t buffer[4]; + memset(buffer, 0, sizeof(buffer)); + canardEncodeScalar(buffer, 0, 16, &config_id); + + CanardTxTransfer transfer = { + .transfer_type = CanardTransferTypeRequest, + .data_type_signature = SINE_ESC_GETCONFIG_SIGNATURE, + .data_type_id = SINE_ESC_GETCONFIG_ID, + .inout_transfer_id = &s_tidGet, + .priority = CANARD_TRANSFER_PRIORITY_HIGHEST, + .payload = buffer, + .payload_len = 2 + }; + canardRequestOrRespondObj(s_node->canard(), ESC_CAN_NODE_ID, &transfer); +} + +static void sendSaveConfig() { + uint8_t buffer[4]; // empty payload, but pass a valid pointer + memset(buffer, 0, sizeof(buffer)); + CanardTxTransfer transfer = { + .transfer_type = CanardTransferTypeRequest, + .data_type_signature = SINE_ESC_SAVECONFIG_SIGNATURE, + .data_type_id = SINE_ESC_SAVECONFIG_ID, + .inout_transfer_id = &s_tidSave, + .priority = CANARD_TRANSFER_PRIORITY_HIGHEST, + .payload = buffer, + .payload_len = 0 + }; + canardRequestOrRespondObj(s_node->canard(), ESC_CAN_NODE_ID, &transfer); +} + +static void sendPasswordUnlock() { + uint8_t buffer[64]; + memset(buffer, 0, sizeof(buffer)); + uint16_t cmd_id = 0x0001; + uint32_t bit = 0; + canardEncodeScalar(buffer, bit, 16, &cmd_id); bit += 16; + uint8_t pwlen = (uint8_t)strlen(ESC_PASSWORD); + uint8_t pad = (pwlen < 10) ? 10 : pwlen; // zero-pad to 10 bytes (matches PC tool) + for (uint8_t i = 0; i < pad; i++) { + uint8_t c = (i < pwlen) ? (uint8_t)ESC_PASSWORD[i] : 0; + canardEncodeScalar(buffer, bit, 8, &c); bit += 8; + } + uint32_t payload_len = (bit + 7) / 8; + + CanardTxTransfer transfer = { + .transfer_type = CanardTransferTypeRequest, + .data_type_signature = SINE_ESC_PASSWORD_UNLOCK_SIGNATURE, + .data_type_id = SINE_ESC_PASSWORD_UNLOCK_ID, + .inout_transfer_id = &s_tidUnlock, + .priority = CANARD_TRANSFER_PRIORITY_HIGHEST, + .payload = buffer, + .payload_len = (uint16_t)payload_len + }; + canardRequestOrRespondObj(s_node->canard(), ESC_CAN_NODE_ID, &transfer); +} + +static void sendRestart() { + uavcan_protocol_RestartNodeRequest req; + req.magic_number = UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_MAGIC_NUMBER; // 0xACCE551B1E + uint8_t buffer[UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_MAX_SIZE]; + memset(buffer, 0, sizeof(buffer)); + uint32_t len = uavcan_protocol_RestartNodeRequest_encode(&req, buffer); + + CanardTxTransfer transfer = { + .transfer_type = CanardTransferTypeRequest, + .data_type_signature = UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_SIGNATURE, + .data_type_id = UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_ID, + .inout_transfer_id = &s_tidRestart, + .priority = CANARD_TRANSFER_PRIORITY_HIGHEST, + .payload = buffer, + .payload_len = (uint16_t)len + }; + canardRequestOrRespondObj(s_node->canard(), ESC_CAN_NODE_ID, &transfer); +} + +// ============================================================================= +// Session state +// ============================================================================= +static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; + +// Pending request handoff (written by BLE task, consumed by throttle task). +static volatile bool s_reqPending = false; +static uint16_t s_reqId = 0; +static uint8_t s_reqData[48]; +static uint8_t s_reqLen = 0; + +static volatile bool s_requireUnlock = false; + +static EscRelayPhase s_phase = EscRelayPhase::IDLE; +static EscRelayStatus s_status = {}; + +// Active session working set. +static uint16_t s_targetId = 0; +static uint8_t s_expData[48]; +static uint8_t s_expLen = 0; +static unsigned long s_lastSendMs = 0; +static unsigned long s_phaseStartMs = 0; +static unsigned long s_lastProbeMs = 0; +static uint8_t s_retries = 0; +static bool s_reUnlocked = false; + +static void setStatus(EscRelayStatusCode code, EscRelayPhase phase, uint8_t detail) { + portENTER_CRITICAL(&s_mux); + s_status.code = code; + s_status.phase = phase; + s_status.config_id = s_targetId; + s_status.detail = detail; + portEXIT_CRITICAL(&s_mux); +} + +static void finishOk(const uint8_t* readback, uint8_t len) { + portENTER_CRITICAL(&s_mux); + s_status.code = EscRelayStatusCode::VERIFIED_OK; + s_status.phase = EscRelayPhase::DONE_OK; + s_status.config_id = s_targetId; + s_status.detail = 0; + s_status.readback_len = (len > 8) ? 8 : len; + for (uint8_t i = 0; i < s_status.readback_len; i++) s_status.readback[i] = readback[i]; + portEXIT_CRITICAL(&s_mux); + s_phase = EscRelayPhase::DONE_OK; +} + +static void finishFail(EscRelayStatusCode code, uint8_t detail) { + setStatus(code, EscRelayPhase::DONE_FAIL, detail); + s_phase = EscRelayPhase::DONE_FAIL; +} + +static bool dataMatches(const uint8_t* got, uint8_t gotLen, const uint8_t* exp, uint8_t expLen) { + if (gotLen < expLen) return false; + for (uint8_t i = 0; i < expLen; i++) if (got[i] != exp[i]) return false; + return true; +} + +static void beginSession(uint16_t id, const uint8_t* data, uint8_t len) { + s_targetId = id; + s_expLen = (len > 48) ? 48 : len; + memcpy(s_expData, data, s_expLen); + s_retries = 0; + s_reUnlocked = false; + s_gotSet = s_gotGet = s_gotSave = s_gotUnlock = false; + + if (s_requireUnlock) { + s_phase = EscRelayPhase::UNLOCK; + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::UNLOCK, 0); + sendPasswordUnlock(); + } else { + s_phase = EscRelayPhase::WRITE; + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::WRITE, 0); + sendSetConfig(s_targetId, s_expData, s_expLen); + } + s_lastSendMs = millis(); +} + +// ============================================================================= +// Public API +// ============================================================================= +void escConfigRelayInit() { + if (!s_node) { + s_node = new EscConfigRelayNode(escAdapter()); + s_node->begin(); + } + s_phase = EscRelayPhase::IDLE; + s_status = {}; + s_status.code = EscRelayStatusCode::IDLE; + s_status.phase = EscRelayPhase::IDLE; +} + +void escConfigRelaySetRequireUnlock(bool require) { s_requireUnlock = require; } + +bool escConfigRelayIsActive() { + if (s_reqPending) return true; + return s_phase != EscRelayPhase::IDLE && + s_phase != EscRelayPhase::DONE_OK && + s_phase != EscRelayPhase::DONE_FAIL; +} + +bool escConfigRelayRequestSetParam(uint16_t config_id, const uint8_t* data, uint8_t len) { + if (!s_node || len > 48 || data == nullptr) return false; + if (escConfigRelayIsActive()) return false; + + portENTER_CRITICAL(&s_mux); + s_reqId = config_id; + s_reqLen = len; + memcpy((void*)s_reqData, data, len); + s_reqPending = true; + s_status.code = EscRelayStatusCode::ACCEPTED; + s_status.config_id = config_id; + portEXIT_CRITICAL(&s_mux); + return true; +} + +bool escConfigRelayRequestReverseDirection(bool reversed) { + uint16_t v = reversed ? 1 : 0; + uint8_t d[2] = { (uint8_t)(v & 0xFF), (uint8_t)((v >> 8) & 0xFF) }; + return escConfigRelayRequestSetParam(ESC_PARAM_DIRECTION, d, 2); +} + +EscRelayStatus escConfigRelayGetStatus() { + EscRelayStatus copy; + portENTER_CRITICAL(&s_mux); + copy = s_status; + portEXIT_CRITICAL(&s_mux); + return copy; +} + +// ============================================================================= +// State machine — driven each throttle tick (~20 ms). NEVER blocks. +// ============================================================================= +void escConfigRelayServiceTick() { + if (!s_node) return; + + const bool terminal = (s_phase == EscRelayPhase::IDLE || + s_phase == EscRelayPhase::DONE_OK || + s_phase == EscRelayPhase::DONE_FAIL); + // Nothing to do: don't touch the CAN bus when fully idle (keeps the existing + // telemetry cadence undisturbed). + if (terminal && !s_reqPending) return; + + // Pump CAN so responses to our requests are captured between ticks. + for (int i = 0; i < 6; i++) escAdapter().processTxRxOnce(); + + // --- pick up a new request only when idle/terminal ----------------------- + if (terminal) { + bool pending = false; + uint16_t id = 0; uint8_t data[48]; uint8_t len = 0; + portENTER_CRITICAL(&s_mux); + if (s_reqPending) { + pending = true; id = s_reqId; len = s_reqLen; + memcpy(data, (const void*)s_reqData, len); + s_reqPending = false; + } + portEXIT_CRITICAL(&s_mux); + if (!pending) return; + + // Safety gate (defense in depth — the BLE handler also checks). + if (currentState != DISARMED) { + s_targetId = id; + finishFail(EscRelayStatusCode::REJECTED_ARMED, 0); + return; + } + beginSession(id, data, len); + return; + } + + // Defense in depth: if the device arms while a session is in flight, abort + // immediately and stop driving the CAN bus. (The param may already be saved, + // but we will not continue restart/verify traffic once armed.) + if (currentState != DISARMED) { + finishFail(EscRelayStatusCode::REJECTED_ARMED, 0); + return; + } + + const unsigned long now = millis(); + const bool timedOut = (now - s_lastSendMs) > RELAY_TIMEOUT_MS; + + switch (s_phase) { + // --------------------------------------------------------------------- + case EscRelayPhase::UNLOCK: + if (s_gotUnlock) { + s_phase = EscRelayPhase::WRITE; + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::WRITE, 0); + s_retries = 0; + s_gotSet = false; + sendSetConfig(s_targetId, s_expData, s_expLen); + s_lastSendMs = now; + } else if (timedOut) { + if (s_retries++ < RELAY_MAX_RETRIES) { s_gotUnlock = false; sendPasswordUnlock(); s_lastSendMs = now; } + else finishFail(EscRelayStatusCode::TIMEOUT, 0); + } + break; + + // --------------------------------------------------------------------- + case EscRelayPhase::WRITE: + if (s_gotSet) { + int8_t flag = s_setResp.flag; + bool ok = (s_setResp.recv_config_id == s_targetId) && (flag == 0 || flag == 6 || flag == 7); + if (ok) { + s_phase = EscRelayPhase::SAVE; + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::SAVE, 0); + s_retries = 0; + s_gotSave = false; + sendSaveConfig(); + s_lastSendMs = now; + } else if (s_retries++ < RELAY_MAX_RETRIES) { + s_gotSet = false; sendSetConfig(s_targetId, s_expData, s_expLen); s_lastSendMs = now; + } else { + finishFail(EscRelayStatusCode::ESC_FLAG_ERROR, (uint8_t)flag); + } + } else if (timedOut) { + if (s_retries++ < RELAY_MAX_RETRIES) { s_gotSet = false; sendSetConfig(s_targetId, s_expData, s_expLen); s_lastSendMs = now; } + else finishFail(EscRelayStatusCode::TIMEOUT, 0); + } + break; + + // --------------------------------------------------------------------- + case EscRelayPhase::SAVE: + if (s_gotSave) { + if (s_saveResp.save_success == 0) { + s_phase = EscRelayPhase::RESTART; + // fall through to RESTART on the next tick after sending below + setStatus(EscRelayStatusCode::REBOOTING, EscRelayPhase::RESTART, 0); + sendRestart(); + for (int i = 0; i < 10; i++) escAdapter().processTxRxOnce(); // flush, non-blocking + s_phase = EscRelayPhase::WAIT_REBOOT; + s_phaseStartMs = now; + s_lastProbeMs = 0; + } else { + finishFail(EscRelayStatusCode::FAILED, s_saveResp.save_success); + } + } else if (timedOut) { + if (s_retries++ < RELAY_MAX_RETRIES) { s_gotSave = false; sendSaveConfig(); s_lastSendMs = now; } + else finishFail(EscRelayStatusCode::TIMEOUT, 0); + } + break; + + // --------------------------------------------------------------------- + // WAIT_REBOOT: non-blocking. Wait for the ESC to reboot, (optionally) + // re-unlock, then probe with GetConfig until it answers; that answer IS + // the verification. + // --------------------------------------------------------------------- + case EscRelayPhase::WAIT_REBOOT: { + const unsigned long elapsed = now - s_phaseStartMs; + + // Phase A: give the ESC time to reboot before it answers. + if (elapsed < REBOOT_WAIT_MS) { + setStatus(EscRelayStatusCode::REBOOTING, EscRelayPhase::WAIT_REBOOT, 0); + break; + } + + // Phase B: re-unlock if required (password state is lost on reboot). + if (s_requireUnlock && !s_reUnlocked) { + if (now - s_lastSendMs > 500) { s_gotUnlock = false; sendPasswordUnlock(); s_lastSendMs = now; } + if (s_gotUnlock) { s_reUnlocked = true; } + if (elapsed > REBOOT_WAIT_MS + REUNLOCK_WINDOW_MS) { + finishFail(EscRelayStatusCode::TIMEOUT, 0); // post-reboot unlock failed + } + break; + } + + // Phase C: probe / verify. + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::VERIFY, 0); + if (now - s_lastProbeMs > PROBE_INTERVAL_MS) { + s_gotGet = false; + sendGetConfig(s_targetId); + s_lastProbeMs = now; + } + if (s_gotGet && s_getResp.recv_config_id == s_targetId && s_getResp.flag == 0) { + if (dataMatches(s_getResp.data, s_getResp.data_len, s_expData, s_expLen)) { + finishOk(s_getResp.data, s_getResp.data_len); + } else { + finishFail(EscRelayStatusCode::VERIFY_MISMATCH, 0); + } + break; + } + if (elapsed > REBOOT_WAIT_MS + WARMUP_WINDOW_MS) { + finishFail(EscRelayStatusCode::TIMEOUT, 0); // ESC never answered after reboot + } + break; + } + + default: + break; + } +} diff --git a/src/sp140/main.cpp b/src/sp140/main.cpp index dcebf027..18039313 100644 --- a/src/sp140/main.cpp +++ b/src/sp140/main.cpp @@ -29,6 +29,7 @@ #include "../../inc/sp140/ble/fastlink_service.h" #include "../../inc/sp140/bms.h" #include "../../inc/sp140/esc.h" +#include "../../inc/sp140/esc_config_relay.h" #include "../../inc/sp140/globals.h" // device config #include "../../inc/sp140/lvgl/lvgl_alerts.h" #include "../../inc/sp140/lvgl/lvgl_core.h" @@ -998,6 +999,13 @@ void toggleArm() { return; } + // Block arming while an ESC config-relay session is in flight (the ESC may + // be mid-reboot). Mirrors the OTA interlock above. + if (escConfigRelayIsActive()) { + USBSerial.println("Arm blocked: ESC config relay in progress"); + return; + } + // Check if enough time has passed since last disarm if (millis() - lastDisarmTime >= DISARM_COOLDOWN) { if (!throttleEngaged()) { From 3e4ecf5cfe6e3d84267fdcb6bb28213b2c1a103a Mon Sep 17 00:00:00 2001 From: Paul <36135447+PaulDWhite@users.noreply.github.com> Date: Wed, 17 Jun 2026 18:03:37 -0400 Subject: [PATCH 07/15] Add ESC config & firmware relay over BLE/CAN Implements an ESC configuration relay and firmware flasher relay reachable from the phone app via new BLE characteristics and relayed to the ESC over CAN. Adds BLE UUIDs (ctrl/data/param/notify), new headers (esc_flasher_relay.h, esc_param_ids.h) and a full flasher implementation (esc_flasher_relay.cpp). Extends the config BLE service to expose FW control/data, parameter result fetch, and a notify stream; includes pumpEscRelayNotify() to stream status and paged result blobs. Enhances esc_config_relay with a read-all session (reads ESC_PARAM_IDS into a result blob, longer timeouts for array params, result fetch API, status codes/phases, and telemetry-throttle settle window) and integrates mutual gating with the firmware relay. Integrates both relays into init/read tick paths, adds temporary serial test hooks, and bumps NimBLE CCCD config in platformio.ini to avoid exhaustion during rebonds. Safety: sessions only run while DISARMED and host node switching/restoration is handled; buffer sizes and timeouts are bounded. --- inc/sp140/ble/ble_ids.h | 16 + inc/sp140/ble/config_service.h | 4 + inc/sp140/esc_config_relay.h | 24 ++ inc/sp140/esc_flasher_relay.h | 94 ++++++ inc/sp140/esc_param_ids.h | 35 ++ platformio.ini | 4 + src/sp140/ble/config_service.cpp | 206 +++++++++++- src/sp140/ble/fastlink_service.cpp | 8 +- src/sp140/device_settings.cpp | 22 ++ src/sp140/esc.cpp | 10 +- src/sp140/esc_config_relay.cpp | 228 ++++++++++++- src/sp140/esc_flasher_relay.cpp | 524 +++++++++++++++++++++++++++++ src/sp140/main.cpp | 25 +- 13 files changed, 1173 insertions(+), 27 deletions(-) create mode 100644 inc/sp140/esc_flasher_relay.h create mode 100644 inc/sp140/esc_param_ids.h create mode 100644 src/sp140/esc_flasher_relay.cpp diff --git a/inc/sp140/ble/ble_ids.h b/inc/sp140/ble/ble_ids.h index f635dc10..cf7fa640 100644 --- a/inc/sp140/ble/ble_ids.h +++ b/inc/sp140/ble/ble_ids.h @@ -25,6 +25,22 @@ #define ESC_RELAY_CMD_UUID "E5C0C0DE-0001-4A5C-9B21-7E5C0F1A2B30" #define ESC_RELAY_STATUS_UUID "E5C0C0DE-0002-4A5C-9B21-7E5C0F1A2B30" +// ESC firmware relay: CTRL is written with FW_START/FW_END/ABORT opcodes and +// read/notify for flasher status; DATA receives offset-addressed image chunks +// (write-without-response for throughput). See ESC-Config-Relay-Design.md §4. +#define ESC_FW_CTRL_UUID "E5C0C0DE-0003-4A5C-9B21-7E5C0F1A2B30" +#define ESC_FW_DATA_UUID "E5C0C0DE-0004-4A5C-9B21-7E5C0F1A2B30" + +// ESC parameter read-all result blob: app writes [offset u32 LE] then reads back +// up to ~240 bytes of the result blob from that offset (paged fetch). +#define ESC_PARAM_DATA_UUID "E5C0C0DE-0005-4A5C-9B21-7E5C0F1A2B30" + +// ESC relay NOTIFY: the controller pushes status + streams the result blob here, +// because GATT reads of the config service return null on this stack while +// notify is reliable. Frames: [0x01]=STATUS[code][phase][detail][len u16], +// [0x02]=DATA[offset u16][bytes]. See ESC-Config-Relay-Design.md. +#define ESC_RELAY_NOTIFY_UUID "E5C0C0DE-0006-4A5C-9B21-7E5C0F1A2B30" + // Device info service #define DEVICE_INFO_SERVICE_UUID "180A" #define MANUFACTURER_NAME_UUID "2A29" diff --git a/inc/sp140/ble/config_service.h b/inc/sp140/ble/config_service.h index f4abc423..a9bcb3e9 100644 --- a/inc/sp140/ble/config_service.h +++ b/inc/sp140/ble/config_service.h @@ -8,4 +8,8 @@ class NimBLEServer; void initConfigBleService(NimBLEServer* server, const std::string& uniqueId); void updateThrottleBLE(int value); +// Push ESC relay status + stream the result blob over notify. Call from the +// 50 Hz BLE notify task. +void pumpEscRelayNotify(); + #endif // INC_SP140_BLE_CONFIG_SERVICE_H_ diff --git a/inc/sp140/esc_config_relay.h b/inc/sp140/esc_config_relay.h index aed4a2d2..2fa514ba 100644 --- a/inc/sp140/esc_config_relay.h +++ b/inc/sp140/esc_config_relay.h @@ -38,6 +38,7 @@ enum class EscRelayPhase : uint8_t { RESTART, WAIT_REBOOT, VERIFY, + READING, // read-all: iterating GetConfig over every param DONE_OK, DONE_FAIL, }; @@ -50,6 +51,8 @@ enum class EscRelayStatusCode : uint8_t { RUNNING = 0x02, REBOOTING = 0x03, VERIFIED_OK = 0x04, + READING = 0x06, // read-all in progress (detail = percent) + READ_DONE = 0x07, // read-all complete (config_id field = result blob length) FAILED = 0x80, REJECTED_ARMED = 0x81, TIMEOUT = 0x82, @@ -86,6 +89,21 @@ bool escConfigRelayRequestSetParam(uint16_t config_id, // Convenience for the canonical reverse-direction toggle. bool escConfigRelayRequestReverseDirection(bool reversed); +// Start a "read all parameters" session: switch to host node 0x40, unlock, then +// GetConfig every id in ESC_PARAM_IDS into a result blob, then restore node 0x01. +// On completion the status code is READ_DONE and config_id holds the blob length. +// Returns false if a session is already in progress. +bool escConfigRelayRequestReadAll(); + +// Length of the last completed read-all result blob (0 until READ_DONE). +uint16_t escConfigRelayResultLen(); + +// Copy up to maxLen bytes of the result blob starting at offset into out. +// Returns the number of bytes copied. Blob format is a sequence of tuples: +// [config_id u16 LE][flag u8][len u8][data[len]] +// (flag 0xFF = the controller timed out reading that param.) +uint16_t escConfigRelayReadResult(uint32_t offset, uint8_t* out, uint16_t maxLen); + // Latched status snapshot for the BLE status characteristic / UI. EscRelayStatus escConfigRelayGetStatus(); @@ -93,6 +111,12 @@ EscRelayStatus escConfigRelayGetStatus(); // interlock — block arming while this is true). bool escConfigRelayIsActive(); +// True while a session is active OR within a short settle window after it ends. +// Used to keep the high-rate telemetry notify throttled so the phone's status +// poll and result-blob reads aren't starved by the notify flood. See +// fastlink_service.cpp / ESC-Config-Relay-Design.md. +bool escConfigRelayResultPending(); + // Require PasswordUnlock (SINE service 225) before writing, and re-unlock after // the ESC reboots. Default false. The reverse-direction param does NOT need it; // restricted params do. The decisive bench test runs with this false to confirm diff --git a/inc/sp140/esc_flasher_relay.h b/inc/sp140/esc_flasher_relay.h new file mode 100644 index 00000000..b610319d --- /dev/null +++ b/inc/sp140/esc_flasher_relay.h @@ -0,0 +1,94 @@ +#ifndef INC_SP140_ESC_FLASHER_RELAY_H_ +#define INC_SP140_ESC_FLASHER_RELAY_H_ + +#include + +// ============================================================================= +// ESC Firmware Relay +// +// Relays an ESC firmware image from the phone app (over BLE) to the ESC (over +// CAN), using the SINE/Mad Motors bootloader protocol (GetBootStatus 200 / +// StartFwUpgrade 201 / SendFwData 202 / EndFwUpgrade 203) ported from +// powerpack-flash-qc/src/esc_flasher.cpp. +// +// Transport model: BUFFER-THEN-FLASH. +// 1. The app sends FW_START(hwId, totalSize), then streams the complete image +// (32-byte header + firmware) in offset-addressed chunks into a controller +// heap/PSRAM buffer (BLE task), then FW_END. +// 2. Only after the full image is received does the throttle task (the sole +// CAN owner) stream it to the ESC bootloader in 256-byte SendFwData chunks, +// paced by the ESC's next_index flow control. +// This decouples the BLE transfer from the CAN streaming entirely, so a slow +// ESC flash cannot trip a BLE idle timeout, and the buffer is written by only +// one task at a time (BLE during RECEIVING, throttle during flashing). +// +// Safety: only starts while DISARMED; aborts if the device arms; the controller +// never reboots (only the ESC does); the hardware_id is validated against the +// connected ESC before entering the bootloader. Fully non-blocking on the +// throttle task (no delay()/vTaskDelay()), so the 50 Hz control loop is never +// stalled. See: powerpack-flash-qc/configs/ESC-Config-Relay-Design.md (§4). +// ============================================================================= + +enum class EscFwPhase : uint8_t { + IDLE = 0, + RECEIVING, // BLE filling the image buffer + RESTARTING, // RestartNode sent, waiting (non-blocking) for ESC reboot + CHECKING_BOOT, // polling GetBootStatus for bootloader mode + STARTING, // StartFwUpgrade sent + SENDING, // streaming SendFwData chunks + ENDING, // EndFwUpgrade sent + DONE_OK, + DONE_FAIL, +}; + +enum class EscFwCode : uint8_t { + IDLE = 0x00, + RECEIVING = 0x01, + ENTER_BOOTLDR = 0x02, + FLASHING = 0x03, + SUCCESS = 0x04, + FAILED = 0x80, + REJECTED_ARMED = 0x81, + TIMEOUT = 0x82, + REJECTED_HWID = 0x83, // image hardware_id != connected ESC + REJECTED_BUSY = 0x84, // another ESC session active / alloc failed / bad size + ESC_REJECTED = 0x85, // ESC refused the image (bootloader/Start/End state != 0) +}; + +struct EscFwStatus { + EscFwCode code; + EscFwPhase phase; + uint16_t progressPermille; // 0..1000 +}; + +// Call once from initESC() (after the CanardAdapter has been begun). +void escFlasherRelayInit(); + +// Drive the flasher state machine. MUST be called from the throttle task — it +// is invoked from readESCTelemetry(). Non-blocking. +void escFlasherRelayServiceTick(); + +// ---- BLE-side API (safe to call from the BLE task) ------------------------- +// Begin a transfer: allocate a buffer for totalSize bytes (the complete image +// incl. 32-byte header) and validate hwId against the connected ESC. Returns +// false if armed, busy, hwId mismatch, size out of range, or allocation fails. +bool escFlasherRelayBegin(uint16_t hardwareId, uint32_t totalSize); + +// Copy a received chunk into the image buffer at the given offset. Returns the +// total number of contiguous-from-zero bytes received so far, or -1 on error. +int32_t escFlasherRelayWriteChunk(uint32_t offset, const uint8_t* data, uint16_t len); + +// All bytes sent: validate and hand off to the throttle task to flash. Returns +// false if the image is incomplete/invalid. +bool escFlasherRelayEnd(); + +// Abort and free the buffer. +void escFlasherRelayAbort(); + +// Latched status snapshot for the BLE status read/notify. +EscFwStatus escFlasherRelayGetStatus(); + +// True while receiving OR flashing (for the arm interlock). +bool escFlasherRelayIsActive(); + +#endif // INC_SP140_ESC_FLASHER_RELAY_H_ diff --git a/inc/sp140/esc_param_ids.h b/inc/sp140/esc_param_ids.h new file mode 100644 index 00000000..71840d0a --- /dev/null +++ b/inc/sp140/esc_param_ids.h @@ -0,0 +1,35 @@ +#ifndef INC_SP140_ESC_PARAM_IDS_H_ +#define INC_SP140_ESC_PARAM_IDS_H_ + +#include + +// The full set of SINE/Mad Motors ESC config_ids to read back during a +// "read all" session. Ported from powerpack-flash-qc/inc/param_table.h +// (PRODUCTION_CONFIG order). The controller only needs the id list — GetConfig +// returns each value as TAO data with its own length; the app holds the catalog +// (name / type / scaling) for display. See ESC-Config-Relay-Design.md. +static const uint16_t ESC_PARAM_IDS[] = { + 0x0001, // config_name + 0x0002, 0x0004, 0x0003, 0x0030, 0x0031, 0x0032, 0x0033, 0x0034, 0x0038, + 0x0040, 0x0041, 0x0042, // board + 0x0050, 0x0051, 0x0052, 0x0053, 0x0056, 0x0057, 0x0058, 0x0060, 0x0061, // motor + 0x0071, 0x0072, 0x0080, 0x0081, 0x0082, 0x0091, 0x0100, 0x0101, // control + 0x0110, 0x0111, 0x0112, 0x0118, 0x0119, 0x0120, 0x0121, 0x0130, 0x0131, + 0x0150, 0x0151, 0x0152, 0x0154, 0x0155, 0x0157, 0x0158, 0x0140, 0x0141, // speed + 0x0600, 0x0601, 0x0602, 0x0603, 0x0604, 0x0605, 0x0606, 0x060B, 0x060E, + 0x060F, 0x0610, // throttle + 0x0210, 0x0212, 0x0211, 0x0240, 0x0241, 0x0230, 0x0231, 0x0220, 0x0221, + 0x0250, 0x0251, // advance + 0x0300, 0x0301, 0x0302, // observer + 0x0400, 0x0404, 0x0405, 0x0402, 0x0403, 0x0401, 0x0410, 0x0420, 0x0421, + 0x0422, 0x0423, 0x0424, 0x0425, 0x0426, 0x0381, 0x0035, 0x0036, 0x0382, + 0x0391, 0x0392, 0x0393, 0x0390, // protect + 0x0607, 0x0608, 0x0609, 0x060A, 0x060C, 0x060D, // bidirectional throttle + 0x0180, 0x0181, 0x0182, 0x0183, 0x0184, 0x0185, 0x0186, 0x0187, 0x0188, + 0x0189, 0x018A, 0x018B, 0x018C, 0x018D, // position mode +}; + +static const uint16_t ESC_PARAM_IDS_COUNT = + sizeof(ESC_PARAM_IDS) / sizeof(ESC_PARAM_IDS[0]); + +#endif // INC_SP140_ESC_PARAM_IDS_H_ diff --git a/platformio.ini b/platformio.ini index f7c0d9f1..4f1654ee 100644 --- a/platformio.ini +++ b/platformio.ini @@ -41,6 +41,10 @@ build_flags = -D LV_LVGL_H_INCLUDE_SIMPLE -D CORE_DEBUG_LEVEL=2 -D CONFIG_ARDUINO_LOOP_STACK_SIZE=8192 + ; Headroom for stored notify/indicate subscriptions (default 8). The ESC + ; relay added read/write chars (no new notify), but bump for safety margin so + ; dev re-bonds don't exhaust the CCCD store. See ESC-Config-Relay-Design.md. + -D CONFIG_BT_NIMBLE_MAX_CCCDS=16 -Wno-error=format ; NimBLE ble_hs.h: BLE_HS_HCI_ERR(x) uses (x) in ternary; GCC -Wint-in-bool-context is -Werror in IDF -Wno-error=int-in-bool-context diff --git a/src/sp140/ble/config_service.cpp b/src/sp140/ble/config_service.cpp index 3020ea68..6843f7ba 100644 --- a/src/sp140/ble/config_service.cpp +++ b/src/sp140/ble/config_service.cpp @@ -19,11 +19,16 @@ #include "version.h" #include "sp140/ble/ota_service.h" #include "sp140/esc_config_relay.h" +#include "sp140/esc_flasher_relay.h" extern void writeDeviceData(); extern QueueHandle_t throttleUpdateQueue; extern volatile DeviceState currentState; +// ESC relay NOTIFY characteristic — the controller pushes status + result blob +// here (config-service GATT reads return null on this BLE stack). +static NimBLECharacteristic* pEscRelayNotifyCharacteristic = nullptr; + namespace { constexpr uint32_t kReadSecure = NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::READ_ENC; @@ -37,6 +42,10 @@ constexpr uint32_t kReadWriteNotifyIndicateSecure = NIMBLE_PROPERTY::READ | NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::NOTIFY | NIMBLE_PROPERTY::INDICATE | NIMBLE_PROPERTY::READ_ENC | NIMBLE_PROPERTY::WRITE_ENC; +// Write-without-response (plus plain write) on an encrypted link — used for the +// high-throughput ESC firmware data stream. +__attribute__((unused)) constexpr uint32_t kWriteNrSecure = +NIMBLE_PROPERTY::WRITE | NIMBLE_PROPERTY::WRITE_NR | NIMBLE_PROPERTY::WRITE_ENC; class MetricAltCallbacks : public NimBLECharacteristicCallbacks { void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { @@ -286,6 +295,11 @@ class EscRelayCmdCallbacks : public NimBLECharacteristicCallbacks { op, configId, len, accepted); break; } + case 0x30: { // READ_ALL parameters + bool accepted = escConfigRelayRequestReadAll(); + USBSerial.printf("ESC relay READ_ALL accepted=%d\n", accepted); + break; + } default: USBSerial.printf("ESC relay: unknown opcode 0x%02X\n", op); break; @@ -293,6 +307,29 @@ class EscRelayCmdCallbacks : public NimBLECharacteristicCallbacks { } }; +// ESC parameter read-all result fetch. The app writes [offset u32 LE] to set the +// read cursor, then reads back up to ~240 bytes of the result blob from there. +class EscParamDataCallbacks : public NimBLECharacteristicCallbacks { + uint32_t offset_ = 0; + + void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { + (void)connInfo; + std::string value = characteristic->getValue(); + if (value.size() < 4) return; + offset_ = static_cast(static_cast(value[0])) | + (static_cast(static_cast(value[1])) << 8) | + (static_cast(static_cast(value[2])) << 16) | + (static_cast(static_cast(value[3])) << 24); + } + + void onRead(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { + (void)connInfo; + uint8_t buf[240]; + uint16_t n = escConfigRelayReadResult(offset_, buf, sizeof(buf)); + characteristic->setValue(buf, n); + } +}; + // ESC config relay status characteristic (read + notify). Returns the latched // session status so the app can poll until a terminal result (the design's // poll-until-terminal contract, which also survives a BLE drop + reconnect). @@ -315,6 +352,71 @@ class EscRelayStatusCallbacks : public NimBLECharacteristicCallbacks { } }; +// ESC firmware relay control: opcodes from the app, status on read/notify. +// 0x20 FW_START : [0x20][hardware_id u16 LE][total_size u32 LE] +// 0x21 FW_END : [0x21] +// 0x22 FW_ABORT : [0x22] +// Status read layout: [code][phase][progress permille u16 LE]. +class EscFwCtrlCallbacks : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { + (void)connInfo; + std::string value = characteristic->getValue(); + if (value.size() < 1) return; + const uint8_t op = static_cast(value[0]); + switch (op) { + case 0x20: { // FW_START + if (value.size() < 7) return; // op + hwId(2) + size(4) + uint16_t hwId = static_cast(value[1]) | + (static_cast(static_cast(value[2])) << 8); + uint32_t size = static_cast(static_cast(value[3])) | + (static_cast(static_cast(value[4])) << 8) | + (static_cast(static_cast(value[5])) << 16) | + (static_cast(static_cast(value[6])) << 24); + bool ok = escFlasherRelayBegin(hwId, size); + USBSerial.printf("ESC FW START hwId=0x%04X size=%u ok=%d\n", hwId, size, ok); + break; + } + case 0x21: // FW_END + escFlasherRelayEnd(); + break; + case 0x22: // FW_ABORT + escFlasherRelayAbort(); + break; + default: + USBSerial.printf("ESC FW: unknown opcode 0x%02X\n", op); + break; + } + } + + void onRead(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { + (void)connInfo; + EscFwStatus s = escFlasherRelayGetStatus(); + uint8_t buf[4]; + buf[0] = static_cast(s.code); + buf[1] = static_cast(s.phase); + buf[2] = s.progressPermille & 0xFF; + buf[3] = (s.progressPermille >> 8) & 0xFF; + characteristic->setValue(buf, 4); + } +}; + +// ESC firmware image data: [offset u32 LE][image bytes...]. Write-no-response +// for throughput; the controller copies each chunk into its image buffer. +class EscFwDataCallbacks : public NimBLECharacteristicCallbacks { + void onWrite(NimBLECharacteristic* characteristic, NimBLEConnInfo& connInfo) override { + (void)connInfo; + std::string value = characteristic->getValue(); + if (value.size() < 4) return; // need at least the offset + uint32_t offset = static_cast(static_cast(value[0])) | + (static_cast(static_cast(value[1])) << 8) | + (static_cast(static_cast(value[2])) << 16) | + (static_cast(static_cast(value[3])) << 24); + const uint8_t* data = reinterpret_cast(value.data()) + 4; + uint16_t len = static_cast(value.size() - 4); + escFlasherRelayWriteChunk(offset, data, len); + } +}; + } // namespace void initConfigBleService(NimBLEServer* server, const std::string& uniqueId) { @@ -416,17 +518,41 @@ void initConfigBleService(NimBLEServer* server, const std::string& uniqueId) { static ThrottleValueCallbacks throttleValueCallbacks; pThrottleCharacteristic->setCallbacks(&throttleValueCallbacks); - // ESC config relay: command (write) + status (read/notify). +#if 1 // ESC relay chars: all read/write/write-nr (NO notify) — zero CCCD footprint. + // ESC config relay: command (write) + status (read). NimBLECharacteristic* escRelayCmd = configService->createCharacteristic( NimBLEUUID(ESC_RELAY_CMD_UUID), kReadWriteSecure); static EscRelayCmdCallbacks escRelayCmdCallbacks; escRelayCmd->setCallbacks(&escRelayCmdCallbacks); NimBLECharacteristic* escRelayStatus = configService->createCharacteristic( - NimBLEUUID(ESC_RELAY_STATUS_UUID), kNotifyReadSecure); + NimBLEUUID(ESC_RELAY_STATUS_UUID), kReadSecure); static EscRelayStatusCallbacks escRelayStatusCallbacks; escRelayStatus->setCallbacks(&escRelayStatusCallbacks); + // ESC firmware relay: control (read status + write opcodes; NO notify) + data. + NimBLECharacteristic* escFwCtrl = configService->createCharacteristic( + NimBLEUUID(ESC_FW_CTRL_UUID), kReadWriteSecure); + static EscFwCtrlCallbacks escFwCtrlCallbacks; + escFwCtrl->setCallbacks(&escFwCtrlCallbacks); + + NimBLECharacteristic* escFwData = configService->createCharacteristic( + NimBLEUUID(ESC_FW_DATA_UUID), kWriteNrSecure); + static EscFwDataCallbacks escFwDataCallbacks; + escFwData->setCallbacks(&escFwDataCallbacks); + + // ESC parameter read-all result fetch (write offset, read chunk). + NimBLECharacteristic* escParamData = configService->createCharacteristic( + NimBLEUUID(ESC_PARAM_DATA_UUID), kReadWriteSecure); + static EscParamDataCallbacks escParamDataCallbacks; + escParamData->setCallbacks(&escParamDataCallbacks); + + // ESC relay NOTIFY: status + result-blob stream (GATT reads return null on + // this stack; notify is reliable). Pumped from the 50 Hz notify task. + pEscRelayNotifyCharacteristic = configService->createCharacteristic( + NimBLEUUID(ESC_RELAY_NOTIFY_UUID), kNotifyReadSecure); +#endif // ESC_RELAY_BLE_CHARS + NimBLEService* deviceInfoService = server->createService(NimBLEUUID(DEVICE_INFO_SERVICE_UUID)); NimBLECharacteristic* manufacturer = deviceInfoService->createCharacteristic( NimBLEUUID(MANUFACTURER_NAME_UUID), kReadSecure); @@ -455,3 +581,79 @@ void updateThrottleBLE(int value) { USBSerial.println("Error sending BLE notification"); } } + +// Push ESC relay status + stream the result blob over the notify characteristic. +// Called from the 50 Hz notify task. Config-service GATT reads return null on +// this BLE stack, so the result is delivered via notify (reliable, like +// telemetry). Frames: 0x01=STATUS[code][phase][detail][len u16], +// 0x02=DATA[offset u16][bytes]. The blob is streamed a few redundant passes +// because notify is lossy; the app reassembles by offset. +void pumpEscRelayNotify() { + if (pEscRelayNotifyCharacteristic == nullptr || !deviceConnected) return; + + static EscRelayStatusCode lastCode = EscRelayStatusCode::IDLE; + static uint8_t lastPhase = 0xFF; + static uint8_t lastDetail = 0xFF; + static uint16_t streamOffset = 0; + static uint8_t streamPass = 0; + static uint8_t terminalResend = 0; // redundant re-sends of a write's terminal status + constexpr uint8_t kStreamPasses = 3; + constexpr uint8_t kTerminalResends = 4; + + EscRelayStatus s = escConfigRelayGetStatus(); + + // During a read-all the code/phase stay READING while only the percent (detail) + // advances — so without this the app's progress would stick at 0%. Re-send when + // the read-all percentage changes too. + const bool readingProgressed = + (s.code == EscRelayStatusCode::READING) && (s.detail != lastDetail); + + // A write's terminal result (VERIFIED_OK or a failure code) is a single status + // change — but notify is lossy, and losing it would make a persisted write look + // like a timeout. Re-send terminal write statuses a few times for reliability. + const bool isTerminalWrite = + (s.code == EscRelayStatusCode::VERIFIED_OK) || + (static_cast(s.code) >= static_cast(EscRelayStatusCode::FAILED)); + + const bool changed = (s.code != lastCode || static_cast(s.phase) != lastPhase + || readingProgressed); + if (changed || (isTerminalWrite && terminalResend > 0)) { + if (changed) { + lastCode = s.code; + lastPhase = static_cast(s.phase); + lastDetail = s.detail; + terminalResend = isTerminalWrite ? kTerminalResends : 0; + } else { + terminalResend--; // redundant resend of an unchanged terminal status + } + uint8_t f[8]; + f[0] = 0x01; // STATUS + f[1] = static_cast(s.code); + f[2] = static_cast(s.phase); + f[3] = s.detail; + f[4] = s.config_id & 0xFF; // result blob length on READ_DONE + f[5] = (s.config_id >> 8) & 0xFF; + pEscRelayNotifyCharacteristic->setValue(f, 6); + pEscRelayNotifyCharacteristic->notify(); + if (s.code == EscRelayStatusCode::READ_DONE) { streamOffset = 0; streamPass = 0; } + } + + if (s.code == EscRelayStatusCode::READ_DONE && streamPass < kStreamPasses) { + const uint16_t total = escConfigRelayResultLen(); + if (total == 0) { streamPass = kStreamPasses; return; } + if (streamOffset >= total) { streamPass++; streamOffset = 0; return; } + // DATA frame carries the total length too, so the app can allocate from any + // chunk even if it missed the READ_DONE status notify (single-point loss). + uint8_t f[208]; + f[0] = 0x02; // DATA + f[1] = streamOffset & 0xFF; + f[2] = (streamOffset >> 8) & 0xFF; + f[3] = total & 0xFF; + f[4] = (total >> 8) & 0xFF; + uint16_t n = escConfigRelayReadResult(streamOffset, f + 5, 198); + if (n == 0) { streamPass++; streamOffset = 0; return; } + pEscRelayNotifyCharacteristic->setValue(f, 5 + n); + pEscRelayNotifyCharacteristic->notify(); + streamOffset += n; + } +} diff --git a/src/sp140/ble/fastlink_service.cpp b/src/sp140/ble/fastlink_service.cpp index b89a7757..dc3c5d10 100644 --- a/src/sp140/ble/fastlink_service.cpp +++ b/src/sp140/ble/fastlink_service.cpp @@ -7,6 +7,8 @@ #include "sp140/ble/ble_ids.h" #include "sp140/ble/ota_service.h" #include "sp140/esc.h" // For requestEscHardwareInfo() +#include "sp140/esc_config_relay.h" // throttle telemetry during config relay +#include "sp140/esc_flasher_relay.h" // ... and firmware relay #include #include #include @@ -145,7 +147,11 @@ void updateFastLinkTelemetry(const BLE_FastLink_Telemetry &data) { if (pFastLinkCharacteristic != nullptr) { pFastLinkCharacteristic->setValue((uint8_t *)&data, sizeof(BLE_FastLink_Telemetry)); - if (isOtaInProgress()) { + // Throttle the ~50Hz stream during OTA AND during an ESC config/firmware + // relay session (plus its short read-back settle window): at ~66Hz the + // notify flood saturates every connection event and starves the phone's + // config-service reads (status poll + result blob), so they never complete. + if (isOtaInProgress() || escConfigRelayResultPending() || escFlasherRelayIsActive()) { // Suppress the full ~50Hz stream during OTA to give the flash bandwidth, // but emit a ~1Hz keepalive notify so the central keeps the link up. The // keepalive ships the packet just setValue()'d above, whose advancing diff --git a/src/sp140/device_settings.cpp b/src/sp140/device_settings.cpp index e7004242..9ecef345 100644 --- a/src/sp140/device_settings.cpp +++ b/src/sp140/device_settings.cpp @@ -9,6 +9,7 @@ #include // Add ESP32 Preferences library #include "../../inc/sp140/throttle.h" #include "../../inc/sp140/diagnostics.h" +#include "../../inc/sp140/esc_config_relay.h" // TEMP: serial-driven ESC write/read test /** * WebSerial Protocol Documentation @@ -240,6 +241,27 @@ void parse_serial_command_line(const char* json_line) { diagnosticsSendJson(USBSerial); USBSerial.println(); return; + } else if (command == "esc_read") { // TEMP: trigger a read-all over CAN + bool ok = escConfigRelayRequestReadAll(); + USBSerial.printf("[ESCTEST] esc_read accepted=%d\n", (int)ok); + return; + } else if (command == "esc_dir") { // TEMP: {"command":"esc_dir","val":0|1} + int v = doc["val"] | 1; + uint8_t d[2] = { (uint8_t)(v & 0xFF), 0 }; + bool ok = escConfigRelayRequestSetParam(0x0080, d, 2); + USBSerial.printf("[ESCTEST] esc_dir val=%d accepted=%d\n", v, (int)ok); + return; + } else if (command == "esc_set") { // TEMP: {"command":"esc_set","id":146,"val":2200,"len":2} + uint16_t id = (uint16_t)(doc["id"] | 0); + int32_t val = (int32_t)(doc["val"] | 0); + int len = doc["len"] | 2; + uint8_t d[4] = { + (uint8_t)(val & 0xFF), (uint8_t)((val >> 8) & 0xFF), + (uint8_t)((val >> 16) & 0xFF), (uint8_t)((val >> 24) & 0xFF) }; + bool ok = escConfigRelayRequestSetParam(id, d, (uint8_t)len); + USBSerial.printf("[ESCTEST] esc_set id=0x%04X val=%ld len=%d accepted=%d\n", + id, (long)val, len, (int)ok); + return; } } diff --git a/src/sp140/esc.cpp b/src/sp140/esc.cpp index 24f05f04..9643ba29 100644 --- a/src/sp140/esc.cpp +++ b/src/sp140/esc.cpp @@ -1,6 +1,7 @@ #include "sp140/esc.h" #include "sp140/globals.h" #include "sp140/esc_config_relay.h" +#include "sp140/esc_flasher_relay.h" #include #pragma GCC diagnostic ignored "-Wmissing-field-initializers" @@ -211,8 +212,9 @@ void initESC() { adapter.processTxRxOnce(); vTaskDelay(pdMS_TO_TICKS(20)); // Wait for ESC to process the command - // Register the ESC config-relay node on the shared adapter (no-op if already). + // Register the ESC config-relay + firmware-relay nodes on the shared adapter. escConfigRelayInit(); + escFlasherRelayInit(); } /** @@ -345,9 +347,11 @@ void readESCTelemetry() { syncEscOutputs(); adapter.processTxRxOnce(); // Process CAN messages - // Drive any in-flight ESC config-relay session. Runs on the throttle task so - // all CAN traffic stays single-owner. Non-blocking; only acts when DISARMED. + // Drive any in-flight ESC config-relay / firmware-relay session. Both run on + // the throttle task so all CAN traffic stays single-owner. Non-blocking; only + // one can be active at a time (mutually gated) and only when DISARMED. escConfigRelayServiceTick(); + escFlasherRelayServiceTick(); } /** diff --git a/src/sp140/esc_config_relay.cpp b/src/sp140/esc_config_relay.cpp index 4eb844ae..367e87df 100644 --- a/src/sp140/esc_config_relay.cpp +++ b/src/sp140/esc_config_relay.cpp @@ -8,6 +8,8 @@ #include "sp140/esc.h" // escAdapter() #include "sp140/device_state.h" // DeviceState +#include "sp140/esc_flasher_relay.h" // escFlasherRelayIsActive() cross-gate +#include "sp140/esc_param_ids.h" // ESC_PARAM_IDS for read-all // Device arming state, owned by the device-state machine (same symbol the OTA // service gates on — see ble/ota_service.cpp). Relay only runs when DISARMED. @@ -28,16 +30,35 @@ extern volatile DeviceState currentState; #define SINE_ESC_PASSWORD_UNLOCK_SIGNATURE 0xB970F68E635BAD53ULL static const uint8_t ESC_CAN_NODE_ID = 0x20; // ESC's CAN node id (esc.cpp esc.begin(0x20)) +static const uint8_t CONFIG_HOST_NODE_ID = 0x40; // ESC restricts config access to host node 0x40 +static const uint8_t CTRL_NORMAL_NODE_ID = 0x01; // controller's normal CAN node id (esc.cpp LOCAL_NODE_ID) static const char ESC_PASSWORD[] = "123456"; // confirmed via Mad Motors PC-tool capture // Per-request timing. Each tick (~20 ms) issues at most one request and polls // for its response across subsequent ticks — never blocking. static const unsigned long RELAY_TIMEOUT_MS = 200; // per-request response wait +// Array/curve params (int16[21]) come back as a multi-frame CAN transfer that +// takes much longer to reassemble than a single-frame scalar response; 200 ms +// is too short and they were getting marked timed-out and skipped during a +// read-all. Give array reads a longer window so all 115 params come back. +static const unsigned long RELAY_ARRAY_TIMEOUT_MS = 1200; static const uint8_t RELAY_MAX_RETRIES = 8; static const unsigned long REBOOT_WAIT_MS = 5000; // ESC reboot before it answers again static const unsigned long WARMUP_WINDOW_MS = 9000; // probe window after reboot wait static const unsigned long REUNLOCK_WINDOW_MS = 5000; // post-reboot unlock retries window static const unsigned long PROBE_INTERVAL_MS = 400; +// While reading all params the device is DISARMED, so we can pump the CAN bus +// hard for a slice of each tick (process many params per tick) instead of one +// param per 20 ms control tick — that's the difference between a multi-second +// read and a sub-second one. Bounded so we still yield to the rest of the system. +static const unsigned long READ_TICK_BUDGET_MS = 12; + +// True for the SINE int16[21] curve params, which answer over a multi-frame +// transfer and therefore need RELAY_ARRAY_TIMEOUT_MS rather than the scalar wait. +static inline bool relayIsArrayParamId(uint16_t id) { + return id == 0x0140 || id == 0x0141 || // normal acc/dec curves + id == 0x060E || id == 0x060F || id == 0x0610; // speed curves +} // ============================================================================= // Decoded responses (written on the throttle task from the Canard callback, @@ -235,7 +256,9 @@ static uint16_t s_reqId = 0; static uint8_t s_reqData[48]; static uint8_t s_reqLen = 0; -static volatile bool s_requireUnlock = false; +// Default ON: the ESC requires host node 0x40 + password unlock for config +// access (confirmed on hardware — node 0x01 without unlock is ignored). +static volatile bool s_requireUnlock = true; static EscRelayPhase s_phase = EscRelayPhase::IDLE; static EscRelayStatus s_status = {}; @@ -250,6 +273,20 @@ static unsigned long s_lastProbeMs = 0; static uint8_t s_retries = 0; static bool s_reUnlocked = false; +// Read-all session state. +static volatile bool s_readReqPending = false; +static bool s_readMode = false; +static uint16_t s_readIndex = 0; +static uint8_t s_resultBlob[3072]; // tuples: [config_id u16][flag u8][len u8][data] +static uint16_t s_resultLen = 0; + +// Telemetry-throttle settle window: after a session ends, keep telemetry notify +// suppressed for this long so the app can poll the final status and fetch the +// result blob over a quiet link (telemetry at ~66 Hz otherwise starves reads). +static volatile unsigned long s_terminalAtMs = 0; +static volatile bool s_hadSession = false; +static const unsigned long RESULT_SETTLE_MS = 9000; + static void setStatus(EscRelayStatusCode code, EscRelayPhase phase, uint8_t detail) { portENTER_CRITICAL(&s_mux); s_status.code = code; @@ -260,6 +297,8 @@ static void setStatus(EscRelayStatusCode code, EscRelayPhase phase, uint8_t deta } static void finishOk(const uint8_t* readback, uint8_t len) { + USBSerial.printf("[ESCWR] VERIFIED_OK target=0x%04X readbackLen=%d\n", + s_targetId, (int)len); // TEMP diag portENTER_CRITICAL(&s_mux); s_status.code = EscRelayStatusCode::VERIFIED_OK; s_status.phase = EscRelayPhase::DONE_OK; @@ -268,11 +307,19 @@ static void finishOk(const uint8_t* readback, uint8_t len) { s_status.readback_len = (len > 8) ? 8 : len; for (uint8_t i = 0; i < s_status.readback_len; i++) s_status.readback[i] = readback[i]; portEXIT_CRITICAL(&s_mux); + escAdapter().setLocalNodeId(CTRL_NORMAL_NODE_ID); // restore normal CAN identity + s_terminalAtMs = millis(); + s_hadSession = true; // open the telemetry-throttle settle window for read-back s_phase = EscRelayPhase::DONE_OK; } static void finishFail(EscRelayStatusCode code, uint8_t detail) { + USBSerial.printf("[ESCWR] FAIL code=0x%02X atPhase=%d target=0x%04X reUnlocked=%d\n", + (int)code, (int)s_phase, s_targetId, (int)s_reUnlocked); // TEMP diag setStatus(code, EscRelayPhase::DONE_FAIL, detail); + escAdapter().setLocalNodeId(CTRL_NORMAL_NODE_ID); // restore normal CAN identity + s_terminalAtMs = millis(); + s_hadSession = true; // open the telemetry-throttle settle window for read-back s_phase = EscRelayPhase::DONE_FAIL; } @@ -282,7 +329,67 @@ static bool dataMatches(const uint8_t* got, uint8_t gotLen, const uint8_t* exp, return true; } +// ---- read-all helpers ------------------------------------------------------- +static void readStoreTuple(uint16_t id, uint8_t flag, const uint8_t* data, uint8_t dlen) { + if ((uint32_t)s_resultLen + 4 + dlen > sizeof(s_resultBlob)) return; // drop if full + s_resultBlob[s_resultLen++] = id & 0xFF; + s_resultBlob[s_resultLen++] = (id >> 8) & 0xFF; + s_resultBlob[s_resultLen++] = flag; + s_resultBlob[s_resultLen++] = dlen; + if (dlen && data) { memcpy(&s_resultBlob[s_resultLen], data, dlen); s_resultLen += dlen; } +} + +static void finishReadDone() { + portENTER_CRITICAL(&s_mux); + s_status.code = EscRelayStatusCode::READ_DONE; + s_status.phase = EscRelayPhase::DONE_OK; + s_status.config_id = s_resultLen; // blob length for the app to fetch + s_status.detail = 100; + portEXIT_CRITICAL(&s_mux); + escAdapter().setLocalNodeId(CTRL_NORMAL_NODE_ID); // restore normal CAN identity + s_terminalAtMs = millis(); + s_hadSession = true; // open the telemetry-throttle settle window for read-back + s_phase = EscRelayPhase::DONE_OK; +} + +static void readAdvance(unsigned long now) { + s_readIndex++; + s_retries = 0; + s_gotGet = false; + if (s_readIndex >= ESC_PARAM_IDS_COUNT) { finishReadDone(); return; } + uint8_t pct = (uint8_t)((uint32_t)s_readIndex * 100 / ESC_PARAM_IDS_COUNT); + setStatus(EscRelayStatusCode::READING, EscRelayPhase::READING, pct); + sendGetConfig(ESC_PARAM_IDS[s_readIndex]); + s_lastSendMs = now; +} + +static void beginReadSession() { + escAdapter().setLocalNodeId(CONFIG_HOST_NODE_ID); + s_readMode = true; + s_readIndex = 0; + s_resultLen = 0; + s_retries = 0; + s_reUnlocked = false; + s_gotSet = s_gotGet = s_gotSave = s_gotUnlock = false; + if (s_requireUnlock) { + s_phase = EscRelayPhase::UNLOCK; + setStatus(EscRelayStatusCode::READING, EscRelayPhase::UNLOCK, 0); + sendPasswordUnlock(); + } else { + s_phase = EscRelayPhase::READING; + setStatus(EscRelayStatusCode::READING, EscRelayPhase::READING, 0); + sendGetConfig(ESC_PARAM_IDS[0]); + } + s_lastSendMs = millis(); +} + static void beginSession(uint16_t id, const uint8_t* data, uint8_t len) { + // The ESC restricts config access to host node 0x40 — present as 0x40 for the + // session so the ESC accepts our requests and addresses its responses to us. + // Restored to 0x01 on every exit path (finishOk / finishFail). + escAdapter().setLocalNodeId(CONFIG_HOST_NODE_ID); + + s_readMode = false; s_targetId = id; s_expLen = (len > 48) ? 48 : len; memcpy(s_expData, data, s_expLen); @@ -319,15 +426,21 @@ void escConfigRelayInit() { void escConfigRelaySetRequireUnlock(bool require) { s_requireUnlock = require; } bool escConfigRelayIsActive() { - if (s_reqPending) return true; + if (s_reqPending || s_readReqPending) return true; return s_phase != EscRelayPhase::IDLE && s_phase != EscRelayPhase::DONE_OK && s_phase != EscRelayPhase::DONE_FAIL; } +bool escConfigRelayResultPending() { + if (escConfigRelayIsActive()) return true; + if (!s_hadSession) return false; + return (millis() - s_terminalAtMs) < RESULT_SETTLE_MS; +} + bool escConfigRelayRequestSetParam(uint16_t config_id, const uint8_t* data, uint8_t len) { if (!s_node || len > 48 || data == nullptr) return false; - if (escConfigRelayIsActive()) return false; + if (escConfigRelayIsActive() || escFlasherRelayIsActive()) return false; portENTER_CRITICAL(&s_mux); s_reqId = config_id; @@ -346,6 +459,26 @@ bool escConfigRelayRequestReverseDirection(bool reversed) { return escConfigRelayRequestSetParam(ESC_PARAM_DIRECTION, d, 2); } +bool escConfigRelayRequestReadAll() { + if (!s_node) return false; + if (escConfigRelayIsActive() || escFlasherRelayIsActive()) return false; + portENTER_CRITICAL(&s_mux); + s_readReqPending = true; + s_status.code = EscRelayStatusCode::ACCEPTED; + portEXIT_CRITICAL(&s_mux); + return true; +} + +uint16_t escConfigRelayResultLen() { return s_resultLen; } + +uint16_t escConfigRelayReadResult(uint32_t offset, uint8_t* out, uint16_t maxLen) { + if (!out || offset >= s_resultLen) return 0; + uint16_t avail = s_resultLen - (uint16_t)offset; + uint16_t n = (avail < maxLen) ? avail : maxLen; + memcpy(out, &s_resultBlob[offset], n); + return n; +} + EscRelayStatus escConfigRelayGetStatus() { EscRelayStatus copy; portENTER_CRITICAL(&s_mux); @@ -365,31 +498,35 @@ void escConfigRelayServiceTick() { s_phase == EscRelayPhase::DONE_FAIL); // Nothing to do: don't touch the CAN bus when fully idle (keeps the existing // telemetry cadence undisturbed). - if (terminal && !s_reqPending) return; + if (terminal && !s_reqPending && !s_readReqPending) return; // Pump CAN so responses to our requests are captured between ticks. for (int i = 0; i < 6; i++) escAdapter().processTxRxOnce(); // --- pick up a new request only when idle/terminal ----------------------- if (terminal) { - bool pending = false; + bool pending = false, readPending = false; uint16_t id = 0; uint8_t data[48]; uint8_t len = 0; portENTER_CRITICAL(&s_mux); if (s_reqPending) { pending = true; id = s_reqId; len = s_reqLen; memcpy(data, (const void*)s_reqData, len); s_reqPending = false; + } else if (s_readReqPending) { + readPending = true; + s_readReqPending = false; } portEXIT_CRITICAL(&s_mux); - if (!pending) return; + if (!pending && !readPending) return; // Safety gate (defense in depth — the BLE handler also checks). if (currentState != DISARMED) { - s_targetId = id; + if (pending) s_targetId = id; finishFail(EscRelayStatusCode::REJECTED_ARMED, 0); return; } - beginSession(id, data, len); + if (pending) beginSession(id, data, len); + else beginReadSession(); return; } @@ -402,29 +539,79 @@ void escConfigRelayServiceTick() { } const unsigned long now = millis(); - const bool timedOut = (now - s_lastSendMs) > RELAY_TIMEOUT_MS; + // Array/curve params answer over a slower multi-frame transfer — give them a + // longer per-request window while reading; everything else uses the scalar wait. + unsigned long timeoutWindow = RELAY_TIMEOUT_MS; + if (s_phase == EscRelayPhase::READING && s_readIndex < ESC_PARAM_IDS_COUNT && + relayIsArrayParamId(ESC_PARAM_IDS[s_readIndex])) { + timeoutWindow = RELAY_ARRAY_TIMEOUT_MS; + } + const bool timedOut = (now - s_lastSendMs) > timeoutWindow; switch (s_phase) { // --------------------------------------------------------------------- case EscRelayPhase::UNLOCK: if (s_gotUnlock) { - s_phase = EscRelayPhase::WRITE; - setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::WRITE, 0); s_retries = 0; - s_gotSet = false; - sendSetConfig(s_targetId, s_expData, s_expLen); + if (s_readMode) { + s_phase = EscRelayPhase::READING; + setStatus(EscRelayStatusCode::READING, EscRelayPhase::READING, 0); + s_gotGet = false; + sendGetConfig(ESC_PARAM_IDS[0]); + } else { + s_phase = EscRelayPhase::WRITE; + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::WRITE, 0); + s_gotSet = false; + sendSetConfig(s_targetId, s_expData, s_expLen); + } s_lastSendMs = now; } else if (timedOut) { if (s_retries++ < RELAY_MAX_RETRIES) { s_gotUnlock = false; sendPasswordUnlock(); s_lastSendMs = now; } - else finishFail(EscRelayStatusCode::TIMEOUT, 0); + else { finishFail(EscRelayStatusCode::TIMEOUT, 0); } + } + break; + + // --------------------------------------------------------------------- + // READING: read-all — GetConfig each param into the result blob. + case EscRelayPhase::READING: { + // Process as many params as fit in this tick's budget (disarmed → safe to + // busy-pump the bus). We still send one GetConfig at a time and wait for + // its response, but with short sub-tick waits instead of a full 20 ms tick. + const unsigned long deadline = now + READ_TICK_BUDGET_MS; + for (;;) { + if (s_gotGet) { + readStoreTuple(ESC_PARAM_IDS[s_readIndex], (uint8_t)s_getResp.flag, + s_getResp.data, s_getResp.data_len); + readAdvance(millis()); // store + send next GetConfig + if (s_phase != EscRelayPhase::READING) break; // finished (node restored) + escAdapter().processTxRxOnce(); // push the next request out + } else { + const unsigned long win = relayIsArrayParamId(ESC_PARAM_IDS[s_readIndex]) + ? RELAY_ARRAY_TIMEOUT_MS : RELAY_TIMEOUT_MS; + if ((millis() - s_lastSendMs) > win) { + if (s_retries++ < RELAY_MAX_RETRIES) { + s_gotGet = false; sendGetConfig(ESC_PARAM_IDS[s_readIndex]); s_lastSendMs = millis(); + } else { + readStoreTuple(ESC_PARAM_IDS[s_readIndex], 0xFF, nullptr, 0); // timed out — skip + readAdvance(millis()); + if (s_phase != EscRelayPhase::READING) break; + } + } + escAdapter().processTxRxOnce(); // drain RX — response may arrive + delayMicroseconds(150); + } + if (millis() >= deadline) break; // yield; resume next tick } break; + } // --------------------------------------------------------------------- case EscRelayPhase::WRITE: if (s_gotSet) { int8_t flag = s_setResp.flag; bool ok = (s_setResp.recv_config_id == s_targetId) && (flag == 0 || flag == 6 || flag == 7); + USBSerial.printf("[ESCWR] SET recv=0x%04X flag=%d ok=%d\n", + s_setResp.recv_config_id, (int)flag, (int)ok); // TEMP diag if (ok) { s_phase = EscRelayPhase::SAVE; setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::SAVE, 0); @@ -446,6 +633,7 @@ void escConfigRelayServiceTick() { // --------------------------------------------------------------------- case EscRelayPhase::SAVE: if (s_gotSave) { + USBSerial.printf("[ESCWR] SAVE success=%d -> restart\n", s_saveResp.save_success); // TEMP diag if (s_saveResp.save_success == 0) { s_phase = EscRelayPhase::RESTART; // fall through to RESTART on the next tick after sending below @@ -480,8 +668,11 @@ void escConfigRelayServiceTick() { // Phase B: re-unlock if required (password state is lost on reboot). if (s_requireUnlock && !s_reUnlocked) { - if (now - s_lastSendMs > 500) { s_gotUnlock = false; sendPasswordUnlock(); s_lastSendMs = now; } - if (s_gotUnlock) { s_reUnlocked = true; } + if (now - s_lastSendMs > 500) { + s_gotUnlock = false; sendPasswordUnlock(); s_lastSendMs = now; + USBSerial.printf("[ESCWR] reboot+%lums: re-unlock sent\n", elapsed); // TEMP diag + } + if (s_gotUnlock) { s_reUnlocked = true; USBSerial.println("[ESCWR] re-unlock OK"); } // TEMP diag if (elapsed > REBOOT_WAIT_MS + REUNLOCK_WINDOW_MS) { finishFail(EscRelayStatusCode::TIMEOUT, 0); // post-reboot unlock failed } @@ -494,6 +685,11 @@ void escConfigRelayServiceTick() { s_gotGet = false; sendGetConfig(s_targetId); s_lastProbeMs = now; + USBSerial.printf("[ESCWR] reboot+%lums: verify probe sent\n", elapsed); // TEMP diag + } + if (s_gotGet) { // TEMP diag + USBSerial.printf("[ESCWR] verify resp recv=0x%04X flag=%d len=%d\n", + s_getResp.recv_config_id, (int)s_getResp.flag, s_getResp.data_len); } if (s_gotGet && s_getResp.recv_config_id == s_targetId && s_getResp.flag == 0) { if (dataMatches(s_getResp.data, s_getResp.data_len, s_expData, s_expLen)) { diff --git a/src/sp140/esc_flasher_relay.cpp b/src/sp140/esc_flasher_relay.cpp new file mode 100644 index 00000000..3ca514d0 --- /dev/null +++ b/src/sp140/esc_flasher_relay.cpp @@ -0,0 +1,524 @@ +#include "sp140/esc_flasher_relay.h" + +#include +#include +#include +#include +#include +#include +#include "esp_heap_caps.h" + +#include "sp140/esc.h" // escAdapter(), escTelemetryData +#include "sp140/device_state.h" // DeviceState +#include "sp140/esc_config_relay.h" // escConfigRelayIsActive() cross-gate + +extern volatile DeviceState currentState; + +static const uint8_t ESC_CAN_NODE_ID = 0x20; +static const uint8_t CONFIG_HOST_NODE_ID = 0x40; // ESC restricts bootloader/config to host 0x40 +static const uint8_t CTRL_NORMAL_NODE_ID = 0x01; // controller's normal CAN node id +static const uint16_t FW_CHUNK_SIZE = 256; // SendFwData payload (matches flash-qc) +static const unsigned long FW_TIMEOUT_MS = 150; // per-request response wait +static const uint8_t FW_MAX_RETRIES = 10; +static const unsigned long REBOOT_WAIT_MS = 700; // ESC reboot before bootloader answers +static const unsigned long RX_TIMEOUT_MS = 15000; // abort if the app stalls mid-transfer +static const uint32_t MAX_IMAGE_SIZE = 256u * 1024u; // sanity cap +static const uint32_t HEADER_SIZE = 32; // firmware data starts at byte 32 + +// ============================================================================= +// Response capture (decoded on the throttle task inside processTxRxOnce). +// ============================================================================= +static volatile bool s_gotResponse = false; +static volatile uint8_t s_responseState = 0xFF; +static volatile uint16_t s_responseNextIndex = 0; +static volatile uint8_t s_bootMode = 0; + +static uint8_t s_tidRestart = 0, s_tidBoot = 0, s_tidStart = 0, s_tidData = 0, s_tidEnd = 0; + +// ============================================================================= +// CanardAdapterNode capturing the bootloader-protocol responses. +// Mirrors EscFlasherNode in powerpack-flash-qc/src/esc_flasher.cpp. +// ============================================================================= +class EscFlasherRelayNode : public CanardAdapterNode { + public: + explicit EscFlasherRelayNode(CanardAdapter& adapter) : CanardAdapterNode(adapter) {} + void begin() { _beginNode(); } + CanardInstance* canard() { return _canard; } + + bool shouldAcceptTransfer(uint64_t* out_signature, + uint16_t data_type_id, + CanardTransferType transfer_type, + uint8_t source_node_id) override { + if (transfer_type != CanardTransferTypeResponse || source_node_id != ESC_CAN_NODE_ID) + return false; + switch (data_type_id) { + case SINE_ESC_GETBOOTSTATUS_ID: *out_signature = SINE_ESC_GETBOOTSTATUS_SIGNATURE; return true; + case SINE_ESC_STARTFWUPGRADE_ID: *out_signature = SINE_ESC_STARTFWUPGRADE_SIGNATURE; return true; + case SINE_ESC_SENDFWDATA_RESPONSE_ID: *out_signature = SINE_ESC_SENDFWDATA_RESPONSE_SIGNATURE; return true; + case SINE_ESC_ENDFWUPGRADE_ID: *out_signature = SINE_ESC_ENDFWUPGRADE_SIGNATURE; return true; + case UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_ID: + *out_signature = UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_SIGNATURE; return true; + default: return false; + } + } + + void onTransferReceived(CanardRxTransfer* t) override { + if (t->transfer_type != CanardTransferTypeResponse) return; + switch (t->data_type_id) { + case SINE_ESC_GETBOOTSTATUS_ID: { + sine_esc_GetBootStatusResponse res; + if (!sine_esc_GetBootStatusResponse_decode(t, &res)) { s_bootMode = res.mode; s_gotResponse = true; } + break; + } + case SINE_ESC_STARTFWUPGRADE_ID: { + sine_esc_StartFwUpgradeResponse res; + if (!sine_esc_StartFwUpgradeResponse_decode(t, &res)) { s_responseState = res.state; s_gotResponse = true; } + break; + } + case SINE_ESC_SENDFWDATA_RESPONSE_ID: { + sine_esc_SendFwDataResponse res; + if (!sine_esc_SendFwDataResponse_decode(t, &res)) { + s_responseState = res.state; s_responseNextIndex = res.next_index; s_gotResponse = true; + } + break; + } + case SINE_ESC_ENDFWUPGRADE_ID: { + sine_esc_EndFwUpgradeResponse res; + if (!sine_esc_EndFwUpgradeResponse_decode(t, &res)) { s_responseState = res.state; s_gotResponse = true; } + break; + } + case UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_ID: { + uavcan_protocol_RestartNodeResponse res; + if (!uavcan_protocol_RestartNodeResponse_decode(t, &res)) { s_responseState = res.ok ? 0 : 1; s_gotResponse = true; } + break; + } + default: break; + } + } +}; + +static EscFlasherRelayNode* s_node = nullptr; + +// ============================================================================= +// Session state (guarded by s_mux; buffer contents are written only during +// RECEIVING on the BLE task and read only during flashing on the throttle task) +// ============================================================================= +static portMUX_TYPE s_mux = portMUX_INITIALIZER_UNLOCKED; + +static uint8_t* s_buf = nullptr; +static uint32_t s_bufSize = 0; // total image size (header + firmware) +static uint32_t s_received = 0; // contiguous bytes received from offset 0 +static volatile bool s_abortRequested = false; + +static EscFwPhase s_phase = EscFwPhase::IDLE; +static EscFwStatus s_status = { EscFwCode::IDLE, EscFwPhase::IDLE, 0 }; + +// Parsed header / flash progress (throttle task). +static uint16_t s_hwId = 0, s_sizeKb = 0, s_crc = 0; +static uint16_t s_totalChunks = 0, s_curChunk = 0; +static unsigned long s_lastSendMs = 0, s_phaseStartMs = 0, s_lastRxMs = 0; +static uint8_t s_retries = 0; + +static void setStatus(EscFwCode code, EscFwPhase phase, uint16_t permille) { + portENTER_CRITICAL(&s_mux); + s_status.code = code; s_status.phase = phase; s_status.progressPermille = permille; + portEXIT_CRITICAL(&s_mux); +} + +// Free the buffer. Only ever called on the throttle task (in the tick) or by +// Begin's own failure path before any throttle access. +static void freeBuffer() { + portENTER_CRITICAL(&s_mux); + uint8_t* p = s_buf; + s_buf = nullptr; + s_bufSize = 0; + s_received = 0; + portEXIT_CRITICAL(&s_mux); + if (p) free(p); +} + +static void finishFail(EscFwCode code) { + freeBuffer(); + setStatus(code, EscFwPhase::DONE_FAIL, s_status.progressPermille); + escAdapter().setLocalNodeId(CTRL_NORMAL_NODE_ID); // restore normal CAN identity + s_phase = EscFwPhase::DONE_FAIL; +} + +// ============================================================================= +// CAN send helpers (throttle task) — use the library encoders directly. +// ============================================================================= +static void sendRestartNode() { + uavcan_protocol_RestartNodeRequest req; + req.magic_number = UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_MAGIC_NUMBER; // 0xACCE551B1E + uint8_t buffer[UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_MAX_SIZE]; + uint32_t len = uavcan_protocol_RestartNodeRequest_encode(&req, buffer); + CanardTxTransfer transfer = { + .transfer_type = CanardTransferTypeRequest, + .data_type_signature = UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_SIGNATURE, + .data_type_id = UAVCAN_PROTOCOL_RESTARTNODE_REQUEST_ID, + .inout_transfer_id = &s_tidRestart, + .priority = CANARD_TRANSFER_PRIORITY_HIGHEST, + .payload = buffer, + .payload_len = (uint16_t)len, + }; + canardRequestOrRespondObj(s_node->canard(), ESC_CAN_NODE_ID, &transfer); +} + +static void sendGetBootStatus() { + sine_esc_GetBootStatusRequest req; + uint8_t buffer[SINE_ESC_GETBOOTSTATUS_REQUEST_MAX_SIZE]; + uint32_t len = sine_esc_GetBootStatusRequest_encode(&req, buffer); + CanardTxTransfer transfer = { + .transfer_type = CanardTransferTypeRequest, + .data_type_signature = SINE_ESC_GETBOOTSTATUS_REQUEST_SIGNATURE, + .data_type_id = SINE_ESC_GETBOOTSTATUS_REQUEST_ID, + .inout_transfer_id = &s_tidBoot, + .priority = CANARD_TRANSFER_PRIORITY_HIGHEST, + .payload = buffer, + .payload_len = (uint16_t)len, + }; + canardRequestOrRespondObj(s_node->canard(), ESC_CAN_NODE_ID, &transfer); +} + +static void sendStartUpgrade() { + sine_esc_StartFwUpgradeRequest req = { + .hardware_id = s_hwId, + .filse_size_kb = s_sizeKb, + .file_crc = s_crc, + }; + uint8_t buffer[SINE_ESC_STARTFWUPGRADE_REQUEST_MAX_SIZE]; + uint32_t len = sine_esc_StartFwUpgradeRequest_encode(&req, buffer); + CanardTxTransfer transfer = { + .transfer_type = CanardTransferTypeRequest, + .data_type_signature = SINE_ESC_STARTFWUPGRADE_REQUEST_SIGNATURE, + .data_type_id = SINE_ESC_STARTFWUPGRADE_REQUEST_ID, + .inout_transfer_id = &s_tidStart, + .priority = CANARD_TRANSFER_PRIORITY_HIGHEST, + .payload = buffer, + .payload_len = (uint16_t)len, + }; + canardRequestOrRespondObj(s_node->canard(), ESC_CAN_NODE_ID, &transfer); +} + +static void sendFwDataChunk(uint16_t index) { + sine_esc_SendFwDataRequest req; + req.index = index; + memset(req.data, 0xFF, FW_CHUNK_SIZE); + uint32_t off = HEADER_SIZE + (uint32_t)index * FW_CHUNK_SIZE; + if (s_buf && off < s_bufSize) { + uint32_t remaining = s_bufSize - off; + uint32_t copyLen = (remaining < FW_CHUNK_SIZE) ? remaining : FW_CHUNK_SIZE; + memcpy(req.data, s_buf + off, copyLen); + } + uint8_t buffer[SINE_ESC_SENDFWDATA_REQUEST_MAX_SIZE]; + uint32_t len = sine_esc_SendFwDataRequest_encode(&req, buffer); + CanardTxTransfer transfer = { + .transfer_type = CanardTransferTypeRequest, + .data_type_signature = SINE_ESC_SENDFWDATA_REQUEST_SIGNATURE, + .data_type_id = SINE_ESC_SENDFWDATA_REQUEST_ID, + .inout_transfer_id = &s_tidData, + .priority = CANARD_TRANSFER_PRIORITY_HIGHEST, + .payload = buffer, + .payload_len = (uint16_t)len, + }; + canardRequestOrRespondObj(s_node->canard(), ESC_CAN_NODE_ID, &transfer); +} + +static void sendEndUpgrade() { + sine_esc_EndFwUpgradeRequest req; + uint8_t buffer[SINE_ESC_ENDFWUPGRADE_REQUEST_MAX_SIZE]; + uint32_t len = sine_esc_EndFwUpgradeRequest_encode(&req, buffer); + CanardTxTransfer transfer = { + .transfer_type = CanardTransferTypeRequest, + .data_type_signature = SINE_ESC_ENDFWUPGRADE_REQUEST_SIGNATURE, + .data_type_id = SINE_ESC_ENDFWUPGRADE_REQUEST_ID, + .inout_transfer_id = &s_tidEnd, + .priority = CANARD_TRANSFER_PRIORITY_HIGHEST, + .payload = buffer, + .payload_len = (uint16_t)len, + }; + canardRequestOrRespondObj(s_node->canard(), ESC_CAN_NODE_ID, &transfer); +} + +// ============================================================================= +// Public API +// ============================================================================= +void escFlasherRelayInit() { + if (!s_node) { + s_node = new EscFlasherRelayNode(escAdapter()); + s_node->begin(); + } + s_phase = EscFwPhase::IDLE; + setStatus(EscFwCode::IDLE, EscFwPhase::IDLE, 0); +} + +bool escFlasherRelayIsActive() { + return s_phase != EscFwPhase::IDLE && + s_phase != EscFwPhase::DONE_OK && + s_phase != EscFwPhase::DONE_FAIL; +} + +bool escFlasherRelayBegin(uint16_t hardwareId, uint32_t totalSize) { + if (!s_node) return false; + if (escFlasherRelayIsActive() || escConfigRelayIsActive()) { + setStatus(EscFwCode::REJECTED_BUSY, EscFwPhase::DONE_FAIL, 0); + return false; + } + if (currentState != DISARMED) { + setStatus(EscFwCode::REJECTED_ARMED, EscFwPhase::DONE_FAIL, 0); + return false; + } + if (totalSize < HEADER_SIZE || totalSize > MAX_IMAGE_SIZE) { + setStatus(EscFwCode::REJECTED_BUSY, EscFwPhase::DONE_FAIL, 0); + return false; + } + // Validate against the connected ESC: refuse a mismatched image before we + // ever put the ESC into the bootloader (classic brick cause). + uint16_t connectedHw = escTelemetryData.hardware_id; + if (connectedHw == 0 || connectedHw != hardwareId) { + setStatus(EscFwCode::REJECTED_HWID, EscFwPhase::DONE_FAIL, 0); + return false; + } + + uint8_t* buf = static_cast(heap_caps_malloc(totalSize, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT)); + if (!buf) buf = static_cast(malloc(totalSize)); + if (!buf) { + setStatus(EscFwCode::REJECTED_BUSY, EscFwPhase::DONE_FAIL, 0); + return false; + } + + portENTER_CRITICAL(&s_mux); + s_buf = buf; + s_bufSize = totalSize; + s_received = 0; + s_abortRequested = false; + s_phase = EscFwPhase::RECEIVING; + s_status.code = EscFwCode::RECEIVING; + s_status.phase = EscFwPhase::RECEIVING; + s_status.progressPermille = 0; + portEXIT_CRITICAL(&s_mux); + s_lastRxMs = millis(); + s_hwId = hardwareId; + USBSerial.printf("ESC FW relay: begin hwId=0x%04X size=%u\n", hardwareId, totalSize); + return true; +} + +int32_t escFlasherRelayWriteChunk(uint32_t offset, const uint8_t* data, uint16_t len) { + if (!data) return -1; + int32_t received = -1; + portENTER_CRITICAL(&s_mux); + if (s_phase == EscFwPhase::RECEIVING && s_buf && !s_abortRequested && + offset <= s_bufSize && (uint64_t)offset + len <= s_bufSize) { + memcpy(s_buf + offset, data, len); + // Track contiguous-from-zero progress (the app writes sequentially). + if (offset <= s_received && offset + len > s_received) { + s_received = offset + len; + } + received = (int32_t)s_received; + uint16_t permille = s_bufSize ? (uint16_t)((uint64_t)s_received * 500 / s_bufSize) : 0; // receive = first 50% + s_status.progressPermille = permille; + } + portEXIT_CRITICAL(&s_mux); + if (received >= 0) s_lastRxMs = millis(); + return received; +} + +bool escFlasherRelayEnd() { + bool ok = false; + portENTER_CRITICAL(&s_mux); + if (s_phase == EscFwPhase::RECEIVING && s_buf && s_received >= s_bufSize && s_bufSize >= HEADER_SIZE) { + // Parse header (little-endian): hwId[0..1], sizeKb[4..5], crc[6..7]. + s_hwId = s_buf[0] | ((uint16_t)s_buf[1] << 8); + s_sizeKb = s_buf[4] | ((uint16_t)s_buf[5] << 8); + s_crc = s_buf[6] | ((uint16_t)s_buf[7] << 8); + uint32_t fwBytes = s_bufSize - HEADER_SIZE; + s_totalChunks = (uint16_t)((fwBytes + FW_CHUNK_SIZE - 1) / FW_CHUNK_SIZE); + s_curChunk = 0; + s_retries = 0; + s_gotResponse = false; + s_phase = EscFwPhase::RESTARTING; + s_status.code = EscFwCode::ENTER_BOOTLDR; + s_status.phase = EscFwPhase::RESTARTING; + ok = true; + } + portEXIT_CRITICAL(&s_mux); + if (ok) { + s_phaseStartMs = millis(); + s_lastSendMs = 0; // force first restart send on next tick + USBSerial.printf("ESC FW relay: end, flashing hwId=0x%04X sizeKb=%u chunks=%u\n", + s_hwId, s_sizeKb, s_totalChunks); + } else { + USBSerial.println("ESC FW relay: end rejected (incomplete image)"); + } + return ok; +} + +void escFlasherRelayAbort() { + s_abortRequested = true; // the throttle tick frees and finishes +} + +EscFwStatus escFlasherRelayGetStatus() { + EscFwStatus copy; + portENTER_CRITICAL(&s_mux); + copy = s_status; + portEXIT_CRITICAL(&s_mux); + return copy; +} + +// ============================================================================= +// State machine — driven each throttle tick (~20 ms). NEVER blocks. +// ============================================================================= +void escFlasherRelayServiceTick() { + if (!s_node) return; + if (!escFlasherRelayIsActive()) return; + + if (s_abortRequested) { + finishFail(EscFwCode::FAILED); + s_abortRequested = false; + return; + } + + const unsigned long now = millis(); + + // RECEIVING: no CAN traffic; just guard against an app that stalls. + if (s_phase == EscFwPhase::RECEIVING) { + if (now - s_lastRxMs > RX_TIMEOUT_MS) { + USBSerial.println("ESC FW relay: receive timeout"); + finishFail(EscFwCode::TIMEOUT); + } + return; + } + + // Abort if the device arms mid-flash (defense in depth). + if (currentState != DISARMED) { + finishFail(EscFwCode::REJECTED_ARMED); + return; + } + + for (int i = 0; i < 6; i++) escAdapter().processTxRxOnce(); + + const bool timedOut = (s_lastSendMs != 0) && (now - s_lastSendMs) > FW_TIMEOUT_MS; + + switch (s_phase) { + // --------------------------------------------------------------------- + case EscFwPhase::RESTARTING: + if (s_lastSendMs == 0) { // first entry: kick the restart + // The ESC bootloader only accepts the upgrade protocol from host 0x40. + escAdapter().setLocalNodeId(CONFIG_HOST_NODE_ID); + sendRestartNode(); + s_lastSendMs = now; + s_phaseStartMs = now; + setStatus(EscFwCode::ENTER_BOOTLDR, EscFwPhase::RESTARTING, 0); + break; + } + // Non-blocking reboot wait, then move to bootloader check (the ESC may + // not answer RestartNode before rebooting — proceed regardless). + if ((now - s_phaseStartMs) >= REBOOT_WAIT_MS) { + s_phase = EscFwPhase::CHECKING_BOOT; + s_retries = 0; + s_gotResponse = false; + sendGetBootStatus(); + s_lastSendMs = now; + setStatus(EscFwCode::ENTER_BOOTLDR, EscFwPhase::CHECKING_BOOT, 0); + } + break; + + // --------------------------------------------------------------------- + case EscFwPhase::CHECKING_BOOT: + if (s_gotResponse) { + if (s_bootMode == 2) { + s_phase = EscFwPhase::STARTING; + s_retries = 0; + s_gotResponse = false; + sendStartUpgrade(); + s_lastSendMs = now; + } else if (s_retries++ < FW_MAX_RETRIES) { + // Not in bootloader yet: re-kick a restart and re-probe. + s_gotResponse = false; + sendRestartNode(); + s_phase = EscFwPhase::RESTARTING; + s_phaseStartMs = now; + s_lastSendMs = now; + } else { + finishFail(EscFwCode::ESC_REJECTED); // won't enter bootloader + } + } else if (timedOut) { + if (s_retries++ < FW_MAX_RETRIES) { s_gotResponse = false; sendGetBootStatus(); s_lastSendMs = now; } + else finishFail(EscFwCode::TIMEOUT); + } + break; + + // --------------------------------------------------------------------- + case EscFwPhase::STARTING: + if (s_gotResponse) { + if (s_responseState == 0) { + s_phase = EscFwPhase::SENDING; + s_curChunk = 0; + s_retries = 0; + s_gotResponse = false; + setStatus(EscFwCode::FLASHING, EscFwPhase::SENDING, 500); + sendFwDataChunk(0); + s_lastSendMs = now; + } else { + finishFail(EscFwCode::ESC_REJECTED); // image rejected by ESC + } + } else if (timedOut) { + if (s_retries++ < FW_MAX_RETRIES) { s_gotResponse = false; sendStartUpgrade(); s_lastSendMs = now; } + else finishFail(EscFwCode::TIMEOUT); + } + break; + + // --------------------------------------------------------------------- + case EscFwPhase::SENDING: + if (s_gotResponse) { + if (s_responseState == 0) { + s_curChunk = s_responseNextIndex; // ESC flow control + s_retries = 0; + // Progress: receive was 0..500, flashing is 500..1000. + uint16_t permille = s_totalChunks + ? (uint16_t)(500 + (uint32_t)s_curChunk * 500 / s_totalChunks) : 1000; + setStatus(EscFwCode::FLASHING, EscFwPhase::SENDING, permille); + if (s_curChunk >= s_totalChunks) { + s_phase = EscFwPhase::ENDING; + s_retries = 0; + s_gotResponse = false; + sendEndUpgrade(); + s_lastSendMs = now; + } else { + s_gotResponse = false; + sendFwDataChunk(s_curChunk); + s_lastSendMs = now; + } + } else if (s_retries++ < FW_MAX_RETRIES) { + s_gotResponse = false; sendFwDataChunk(s_curChunk); s_lastSendMs = now; // resend same chunk + } else { + finishFail(EscFwCode::ESC_REJECTED); + } + } else if (timedOut) { + if (s_retries++ < FW_MAX_RETRIES) { s_gotResponse = false; sendFwDataChunk(s_curChunk); s_lastSendMs = now; } + else finishFail(EscFwCode::TIMEOUT); + } + break; + + // --------------------------------------------------------------------- + case EscFwPhase::ENDING: + if (s_gotResponse) { + if (s_responseState == 0) { + freeBuffer(); + escAdapter().setLocalNodeId(CTRL_NORMAL_NODE_ID); // restore normal CAN identity + setStatus(EscFwCode::SUCCESS, EscFwPhase::DONE_OK, 1000); + s_phase = EscFwPhase::DONE_OK; + USBSerial.println("ESC FW relay: SUCCESS"); + } else { + finishFail(EscFwCode::ESC_REJECTED); // ESC CRC/verify failed + } + } else if (timedOut) { + if (s_retries++ < FW_MAX_RETRIES) { s_gotResponse = false; sendEndUpgrade(); s_lastSendMs = now; } + else finishFail(EscFwCode::TIMEOUT); + } + break; + + default: + break; + } +} diff --git a/src/sp140/main.cpp b/src/sp140/main.cpp index 18039313..b4f16640 100644 --- a/src/sp140/main.cpp +++ b/src/sp140/main.cpp @@ -30,6 +30,7 @@ #include "../../inc/sp140/bms.h" #include "../../inc/sp140/esc.h" #include "../../inc/sp140/esc_config_relay.h" +#include "../../inc/sp140/esc_flasher_relay.h" #include "../../inc/sp140/globals.h" // device config #include "../../inc/sp140/lvgl/lvgl_alerts.h" #include "../../inc/sp140/lvgl/lvgl_core.h" @@ -349,6 +350,10 @@ void bleNotifyTask(void *pvParameters) { publishFastLinkTelemetry(hub, currentState); } + // Push ESC config-relay status + result blob over notify (config-service + // GATT reads return null on this stack; notify is reliable). + pumpEscRelayNotify(); + if (!isOtaInProgress()) { setAndNotifyOnChange(pDeviceStateCharacteristic, static_cast(currentState), lastNotifiedState); @@ -999,10 +1004,11 @@ void toggleArm() { return; } - // Block arming while an ESC config-relay session is in flight (the ESC may - // be mid-reboot). Mirrors the OTA interlock above. - if (escConfigRelayIsActive()) { - USBSerial.println("Arm blocked: ESC config relay in progress"); + // Block arming while an ESC config-relay or firmware-relay session is in + // flight (the ESC may be mid-reboot / in the bootloader). Mirrors the OTA + // interlock above. + if (escConfigRelayIsActive() || escFlasherRelayIsActive()) { + USBSerial.println("Arm blocked: ESC config/firmware relay in progress"); return; } @@ -1155,7 +1161,16 @@ void handleThrottle() { break; } - setESCThrottle(finalPwm); + // Suppress the ESC control/telemetry command during a config/firmware relay + // session. setESCThrottle() pings the ESC every cycle and the ESC answers with + // a burst of high-rate telemetry; that flood overruns the CAN RX buffer and the + // SaveConfig reply gets dropped, so the write times out. flash-qc works because + // its bus is quiet. The device is always DISARMED during a session, so there is + // no throttle to send anyway. readESCTelemetry() still runs — it pumps the bus + // and drives the relay state machine. + if (!escConfigRelayIsActive() && !escFlasherRelayIsActive()) { + setESCThrottle(finalPwm); + } // Read/Sync ESC Telemetry (runs in all armed states) readESCTelemetry(); From 6e3d69b93c59e80d289a9ec8079905210ab7d5ac Mon Sep 17 00:00:00 2001 From: Paul <36135447+PaulDWhite@users.noreply.github.com> Date: Wed, 17 Jun 2026 22:59:45 -0400 Subject: [PATCH 08/15] Add batch write support to ESC config relay Introduce a buffered batch-write feature for ESC config relay so multiple params can be applied with a single Save+Restart and post-reboot verification. Public API added: escConfigRelayBatchBegin(), escConfigRelayBatchAdd(id,data,len), escConfigRelayRequestBatchCommit(). BLE handlers for opcodes 0x40/0x41/0x42 were added to begin, add, and commit batches. Implementation adds a staging buffer and session state, beginBatchSession(), batch tuple helpers, and new phases (BATCH_WRITE, BATCH_VERIFY) driven by escConfigRelayServiceTick() to write all staged params, persist once, restart, and then verify each param across reboot. Also integrates batch pending checks into isActive/request handling and removes some temporary serial debug/test code and prints. --- inc/sp140/esc_config_relay.h | 21 +++- src/sp140/ble/config_service.cpp | 20 +++ src/sp140/device_settings.cpp | 22 ---- src/sp140/esc_config_relay.cpp | 204 ++++++++++++++++++++++++++++--- 4 files changed, 225 insertions(+), 42 deletions(-) diff --git a/inc/sp140/esc_config_relay.h b/inc/sp140/esc_config_relay.h index 2fa514ba..aac959cf 100644 --- a/inc/sp140/esc_config_relay.h +++ b/inc/sp140/esc_config_relay.h @@ -38,7 +38,9 @@ enum class EscRelayPhase : uint8_t { RESTART, WAIT_REBOOT, VERIFY, - READING, // read-all: iterating GetConfig over every param + READING, // read-all: iterating GetConfig over every param + BATCH_WRITE, // batch: iterating SetConfig over every queued param + BATCH_VERIFY, // batch: iterating GetConfig to confirm each queued param persisted DONE_OK, DONE_FAIL, }; @@ -95,6 +97,23 @@ bool escConfigRelayRequestReverseDirection(bool reversed); // Returns false if a session is already in progress. bool escConfigRelayRequestReadAll(); +// ---- Batch write (apply many params with a SINGLE save + restart) ----------- +// Mirrors the flash-qc sequence: unlock -> SetConfig every queued param -> +// SaveConfig once -> RestartNode once -> verify each param persisted across the +// reboot. The phone streams the batch first (Begin, then Add per param) and then +// Commit triggers the session. Safe to call Begin/Add from the BLE task — they +// only touch the staging buffer, which the throttle task does not read until a +// Commit starts the session. +// +// Begin: clear the staging buffer. Returns false if a session is in progress. +bool escConfigRelayBatchBegin(); +// Add one param to the staging buffer ([config_id u16][len u8][data[len]]). +// Returns false if the buffer is full or a session is in progress. +bool escConfigRelayBatchAdd(uint16_t config_id, const uint8_t* data, uint8_t len); +// Commit: run the batch session over the staged params. Returns false if nothing +// is staged or a session is already in progress. +bool escConfigRelayRequestBatchCommit(); + // Length of the last completed read-all result blob (0 until READ_DONE). uint16_t escConfigRelayResultLen(); diff --git a/src/sp140/ble/config_service.cpp b/src/sp140/ble/config_service.cpp index 6843f7ba..f12b3288 100644 --- a/src/sp140/ble/config_service.cpp +++ b/src/sp140/ble/config_service.cpp @@ -300,6 +300,26 @@ class EscRelayCmdCallbacks : public NimBLECharacteristicCallbacks { USBSerial.printf("ESC relay READ_ALL accepted=%d\n", accepted); break; } + case 0x40: { // BATCH_BEGIN — clear the staging buffer + bool accepted = escConfigRelayBatchBegin(); + USBSerial.printf("ESC relay BATCH_BEGIN accepted=%d\n", accepted); + break; + } + case 0x41: { // BATCH_ADD — [op][config_id u16][len u8][data...] + if (value.size() < 4) return; + uint16_t configId = static_cast(value[1]) | + (static_cast(static_cast(value[2])) << 8); + uint8_t len = static_cast(value[3]); + if (len > 48 || value.size() < static_cast(4 + len)) return; + escConfigRelayBatchAdd(configId, + reinterpret_cast(value.data()) + 4, len); + break; + } + case 0x42: { // BATCH_COMMIT — run write-all + save + restart + verify + bool accepted = escConfigRelayRequestBatchCommit(); + USBSerial.printf("ESC relay BATCH_COMMIT accepted=%d\n", accepted); + break; + } default: USBSerial.printf("ESC relay: unknown opcode 0x%02X\n", op); break; diff --git a/src/sp140/device_settings.cpp b/src/sp140/device_settings.cpp index 9ecef345..e7004242 100644 --- a/src/sp140/device_settings.cpp +++ b/src/sp140/device_settings.cpp @@ -9,7 +9,6 @@ #include // Add ESP32 Preferences library #include "../../inc/sp140/throttle.h" #include "../../inc/sp140/diagnostics.h" -#include "../../inc/sp140/esc_config_relay.h" // TEMP: serial-driven ESC write/read test /** * WebSerial Protocol Documentation @@ -241,27 +240,6 @@ void parse_serial_command_line(const char* json_line) { diagnosticsSendJson(USBSerial); USBSerial.println(); return; - } else if (command == "esc_read") { // TEMP: trigger a read-all over CAN - bool ok = escConfigRelayRequestReadAll(); - USBSerial.printf("[ESCTEST] esc_read accepted=%d\n", (int)ok); - return; - } else if (command == "esc_dir") { // TEMP: {"command":"esc_dir","val":0|1} - int v = doc["val"] | 1; - uint8_t d[2] = { (uint8_t)(v & 0xFF), 0 }; - bool ok = escConfigRelayRequestSetParam(0x0080, d, 2); - USBSerial.printf("[ESCTEST] esc_dir val=%d accepted=%d\n", v, (int)ok); - return; - } else if (command == "esc_set") { // TEMP: {"command":"esc_set","id":146,"val":2200,"len":2} - uint16_t id = (uint16_t)(doc["id"] | 0); - int32_t val = (int32_t)(doc["val"] | 0); - int len = doc["len"] | 2; - uint8_t d[4] = { - (uint8_t)(val & 0xFF), (uint8_t)((val >> 8) & 0xFF), - (uint8_t)((val >> 16) & 0xFF), (uint8_t)((val >> 24) & 0xFF) }; - bool ok = escConfigRelayRequestSetParam(id, d, (uint8_t)len); - USBSerial.printf("[ESCTEST] esc_set id=0x%04X val=%ld len=%d accepted=%d\n", - id, (long)val, len, (int)ok); - return; } } diff --git a/src/sp140/esc_config_relay.cpp b/src/sp140/esc_config_relay.cpp index 367e87df..d1a664a0 100644 --- a/src/sp140/esc_config_relay.cpp +++ b/src/sp140/esc_config_relay.cpp @@ -280,6 +280,33 @@ static uint16_t s_readIndex = 0; static uint8_t s_resultBlob[3072]; // tuples: [config_id u16][flag u8][len u8][data] static uint16_t s_resultLen = 0; +// Batch session state. Staging buffer holds [config_id u16][len u8][data[len]] +// tuples; the batch session writes them all, saves once, restarts once, verifies. +static volatile bool s_batchReqPending = false; +static bool s_batchMode = false; +static uint8_t s_batchBuf[2560]; +static uint16_t s_batchLen = 0; // bytes used in s_batchBuf +static uint16_t s_batchCount = 0; // number of staged params +static uint16_t s_batchOffset = 0; // walk cursor during write/verify iteration +static uint16_t s_batchIndex = 0; // staged-param index during iteration +static uint16_t s_batchMismatch = 0; // verify mismatches + +// Read the staged tuple at s_batchOffset. Returns false at end of buffer. +static bool batchCurrentTuple(uint16_t* id, const uint8_t** data, uint8_t* len) { + if (s_batchOffset + 3 > s_batchLen) return false; + uint16_t off = s_batchOffset; + *id = s_batchBuf[off] | (uint16_t)(s_batchBuf[off + 1] << 8); + *len = s_batchBuf[off + 2]; + *data = &s_batchBuf[off + 3]; + if (off + 3 + *len > s_batchLen) return false; + return true; +} + +static void batchAdvanceCursor(uint8_t len) { + s_batchOffset += 3 + len; + s_batchIndex++; +} + // Telemetry-throttle settle window: after a session ends, keep telemetry notify // suppressed for this long so the app can poll the final status and fetch the // result blob over a quiet link (telemetry at ~66 Hz otherwise starves reads). @@ -297,8 +324,6 @@ static void setStatus(EscRelayStatusCode code, EscRelayPhase phase, uint8_t deta } static void finishOk(const uint8_t* readback, uint8_t len) { - USBSerial.printf("[ESCWR] VERIFIED_OK target=0x%04X readbackLen=%d\n", - s_targetId, (int)len); // TEMP diag portENTER_CRITICAL(&s_mux); s_status.code = EscRelayStatusCode::VERIFIED_OK; s_status.phase = EscRelayPhase::DONE_OK; @@ -314,8 +339,6 @@ static void finishOk(const uint8_t* readback, uint8_t len) { } static void finishFail(EscRelayStatusCode code, uint8_t detail) { - USBSerial.printf("[ESCWR] FAIL code=0x%02X atPhase=%d target=0x%04X reUnlocked=%d\n", - (int)code, (int)s_phase, s_targetId, (int)s_reUnlocked); // TEMP diag setStatus(code, EscRelayPhase::DONE_FAIL, detail); escAdapter().setLocalNodeId(CTRL_NORMAL_NODE_ID); // restore normal CAN identity s_terminalAtMs = millis(); @@ -366,6 +389,7 @@ static void readAdvance(unsigned long now) { static void beginReadSession() { escAdapter().setLocalNodeId(CONFIG_HOST_NODE_ID); s_readMode = true; + s_batchMode = false; s_readIndex = 0; s_resultLen = 0; s_retries = 0; @@ -390,6 +414,7 @@ static void beginSession(uint16_t id, const uint8_t* data, uint8_t len) { escAdapter().setLocalNodeId(CONFIG_HOST_NODE_ID); s_readMode = false; + s_batchMode = false; s_targetId = id; s_expLen = (len > 48) ? 48 : len; memcpy(s_expData, data, s_expLen); @@ -409,6 +434,37 @@ static void beginSession(uint16_t id, const uint8_t* data, uint8_t len) { s_lastSendMs = millis(); } +// Start a batch session over the staged params: unlock -> SetConfig each -> +// SaveConfig once -> RestartNode -> verify each across the reboot. +static void beginBatchSession() { + escAdapter().setLocalNodeId(CONFIG_HOST_NODE_ID); + s_readMode = false; + s_batchMode = true; + s_batchOffset = 0; + s_batchIndex = 0; + s_batchMismatch = 0; + s_retries = 0; + s_reUnlocked = false; + s_gotSet = s_gotGet = s_gotSave = s_gotUnlock = false; + + // First staged tuple is the working target (used as the post-reboot probe id). + uint16_t id = 0; const uint8_t* d = nullptr; uint8_t len = 0; + if (batchCurrentTuple(&id, &d, &len)) { + s_targetId = id; s_expLen = len; memcpy(s_expData, d, len); + } + + if (s_requireUnlock) { + s_phase = EscRelayPhase::UNLOCK; + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::UNLOCK, 0); + sendPasswordUnlock(); + } else { + s_phase = EscRelayPhase::BATCH_WRITE; + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::BATCH_WRITE, 0); + if (d) sendSetConfig(id, d, len); + } + s_lastSendMs = millis(); +} + // ============================================================================= // Public API // ============================================================================= @@ -426,7 +482,7 @@ void escConfigRelayInit() { void escConfigRelaySetRequireUnlock(bool require) { s_requireUnlock = require; } bool escConfigRelayIsActive() { - if (s_reqPending || s_readReqPending) return true; + if (s_reqPending || s_readReqPending || s_batchReqPending) return true; return s_phase != EscRelayPhase::IDLE && s_phase != EscRelayPhase::DONE_OK && s_phase != EscRelayPhase::DONE_FAIL; @@ -469,6 +525,40 @@ bool escConfigRelayRequestReadAll() { return true; } +bool escConfigRelayBatchBegin() { + if (escConfigRelayIsActive() || escFlasherRelayIsActive()) return false; + portENTER_CRITICAL(&s_mux); + s_batchLen = 0; + s_batchCount = 0; + portEXIT_CRITICAL(&s_mux); + return true; +} + +bool escConfigRelayBatchAdd(uint16_t config_id, const uint8_t* data, uint8_t len) { + if (escConfigRelayIsActive() || escFlasherRelayIsActive()) return false; + if (data == nullptr || len == 0 || len > 48) return false; + if ((uint32_t)s_batchLen + 3 + len > sizeof(s_batchBuf)) return false; // full + portENTER_CRITICAL(&s_mux); + s_batchBuf[s_batchLen++] = config_id & 0xFF; + s_batchBuf[s_batchLen++] = (config_id >> 8) & 0xFF; + s_batchBuf[s_batchLen++] = len; + memcpy(&s_batchBuf[s_batchLen], data, len); + s_batchLen += len; + s_batchCount++; + portEXIT_CRITICAL(&s_mux); + return true; +} + +bool escConfigRelayRequestBatchCommit() { + if (!s_node || s_batchCount == 0) return false; + if (escConfigRelayIsActive() || escFlasherRelayIsActive()) return false; + portENTER_CRITICAL(&s_mux); + s_batchReqPending = true; + s_status.code = EscRelayStatusCode::ACCEPTED; + portEXIT_CRITICAL(&s_mux); + return true; +} + uint16_t escConfigRelayResultLen() { return s_resultLen; } uint16_t escConfigRelayReadResult(uint32_t offset, uint8_t* out, uint16_t maxLen) { @@ -498,14 +588,14 @@ void escConfigRelayServiceTick() { s_phase == EscRelayPhase::DONE_FAIL); // Nothing to do: don't touch the CAN bus when fully idle (keeps the existing // telemetry cadence undisturbed). - if (terminal && !s_reqPending && !s_readReqPending) return; + if (terminal && !s_reqPending && !s_readReqPending && !s_batchReqPending) return; // Pump CAN so responses to our requests are captured between ticks. for (int i = 0; i < 6; i++) escAdapter().processTxRxOnce(); // --- pick up a new request only when idle/terminal ----------------------- if (terminal) { - bool pending = false, readPending = false; + bool pending = false, readPending = false, batchPending = false; uint16_t id = 0; uint8_t data[48]; uint8_t len = 0; portENTER_CRITICAL(&s_mux); if (s_reqPending) { @@ -515,9 +605,12 @@ void escConfigRelayServiceTick() { } else if (s_readReqPending) { readPending = true; s_readReqPending = false; + } else if (s_batchReqPending) { + batchPending = true; + s_batchReqPending = false; } portEXIT_CRITICAL(&s_mux); - if (!pending && !readPending) return; + if (!pending && !readPending && !batchPending) return; // Safety gate (defense in depth — the BLE handler also checks). if (currentState != DISARMED) { @@ -526,7 +619,8 @@ void escConfigRelayServiceTick() { return; } if (pending) beginSession(id, data, len); - else beginReadSession(); + else if (readPending) beginReadSession(); + else beginBatchSession(); return; } @@ -558,6 +652,12 @@ void escConfigRelayServiceTick() { setStatus(EscRelayStatusCode::READING, EscRelayPhase::READING, 0); s_gotGet = false; sendGetConfig(ESC_PARAM_IDS[0]); + } else if (s_batchMode) { + s_phase = EscRelayPhase::BATCH_WRITE; + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::BATCH_WRITE, 0); + s_gotSet = false; + uint16_t id; const uint8_t* d; uint8_t len; + if (batchCurrentTuple(&id, &d, &len)) sendSetConfig(id, d, len); } else { s_phase = EscRelayPhase::WRITE; setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::WRITE, 0); @@ -605,13 +705,75 @@ void escConfigRelayServiceTick() { break; } + // --------------------------------------------------------------------- + // BATCH_WRITE: SetConfig every staged param, then SaveConfig once. + case EscRelayPhase::BATCH_WRITE: { + uint16_t id; const uint8_t* d; uint8_t len; + if (!batchCurrentTuple(&id, &d, &len)) { + // All params written — persist once. + s_phase = EscRelayPhase::SAVE; + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::SAVE, 0); + s_retries = 0; s_gotSave = false; sendSaveConfig(); s_lastSendMs = now; + break; + } + if (s_gotSet) { + int8_t flag = s_setResp.flag; + bool ok = (s_setResp.recv_config_id == id) && (flag == 0 || flag == 6 || flag == 7); + if (ok) { + batchAdvanceCursor(len); s_retries = 0; s_gotSet = false; + uint8_t pct = (uint8_t)((uint32_t)s_batchIndex * 100 / (s_batchCount ? s_batchCount : 1)); + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::BATCH_WRITE, pct); + uint16_t nid; const uint8_t* nd; uint8_t nlen; + if (batchCurrentTuple(&nid, &nd, &nlen)) { sendSetConfig(nid, nd, nlen); s_lastSendMs = now; } + } else if (s_retries++ < RELAY_MAX_RETRIES) { + s_gotSet = false; sendSetConfig(id, d, len); s_lastSendMs = now; + } else { + finishFail(EscRelayStatusCode::ESC_FLAG_ERROR, (uint8_t)flag); + } + } else if (timedOut) { + if (s_retries++ < RELAY_MAX_RETRIES) { s_gotSet = false; sendSetConfig(id, d, len); s_lastSendMs = now; } + else finishFail(EscRelayStatusCode::TIMEOUT, 0); + } + break; + } + + // --------------------------------------------------------------------- + // BATCH_VERIFY: after the reboot, GetConfig every staged param and confirm + // it persisted (byte-match). Mismatches are counted, not fatal per-param. + case EscRelayPhase::BATCH_VERIFY: { + uint16_t id; const uint8_t* d; uint8_t len; + if (!batchCurrentTuple(&id, &d, &len)) { + if (s_batchMismatch == 0) finishOk(nullptr, 0); + else finishFail(EscRelayStatusCode::VERIFY_MISMATCH, + (uint8_t)(s_batchMismatch > 255 ? 255 : s_batchMismatch)); + break; + } + if (s_gotGet) { + bool match = (s_getResp.recv_config_id == id) && (s_getResp.flag == 0) && + dataMatches(s_getResp.data, s_getResp.data_len, d, len); + if (!match) s_batchMismatch++; + batchAdvanceCursor(len); s_retries = 0; s_gotGet = false; + uint8_t pct = (uint8_t)((uint32_t)s_batchIndex * 100 / (s_batchCount ? s_batchCount : 1)); + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::BATCH_VERIFY, pct); + uint16_t nid; const uint8_t* nd; uint8_t nlen; + if (batchCurrentTuple(&nid, &nd, &nlen)) { sendGetConfig(nid); s_lastSendMs = now; } + } else if (timedOut) { + if (s_retries++ < RELAY_MAX_RETRIES) { s_gotGet = false; sendGetConfig(id); s_lastSendMs = now; } + else { + s_batchMismatch++; // gave up reading this one + batchAdvanceCursor(len); s_retries = 0; s_gotGet = false; + uint16_t nid; const uint8_t* nd; uint8_t nlen; + if (batchCurrentTuple(&nid, &nd, &nlen)) { sendGetConfig(nid); s_lastSendMs = now; } + } + } + break; + } + // --------------------------------------------------------------------- case EscRelayPhase::WRITE: if (s_gotSet) { int8_t flag = s_setResp.flag; bool ok = (s_setResp.recv_config_id == s_targetId) && (flag == 0 || flag == 6 || flag == 7); - USBSerial.printf("[ESCWR] SET recv=0x%04X flag=%d ok=%d\n", - s_setResp.recv_config_id, (int)flag, (int)ok); // TEMP diag if (ok) { s_phase = EscRelayPhase::SAVE; setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::SAVE, 0); @@ -633,7 +795,6 @@ void escConfigRelayServiceTick() { // --------------------------------------------------------------------- case EscRelayPhase::SAVE: if (s_gotSave) { - USBSerial.printf("[ESCWR] SAVE success=%d -> restart\n", s_saveResp.save_success); // TEMP diag if (s_saveResp.save_success == 0) { s_phase = EscRelayPhase::RESTART; // fall through to RESTART on the next tick after sending below @@ -670,26 +831,31 @@ void escConfigRelayServiceTick() { if (s_requireUnlock && !s_reUnlocked) { if (now - s_lastSendMs > 500) { s_gotUnlock = false; sendPasswordUnlock(); s_lastSendMs = now; - USBSerial.printf("[ESCWR] reboot+%lums: re-unlock sent\n", elapsed); // TEMP diag } - if (s_gotUnlock) { s_reUnlocked = true; USBSerial.println("[ESCWR] re-unlock OK"); } // TEMP diag + if (s_gotUnlock) { s_reUnlocked = true; } if (elapsed > REBOOT_WAIT_MS + REUNLOCK_WINDOW_MS) { finishFail(EscRelayStatusCode::TIMEOUT, 0); // post-reboot unlock failed } break; } + // Phase C (batch): the re-unlock above already proves the ESC is back, so + // go straight to verifying every staged param via GetConfig. + if (s_batchMode) { + s_batchOffset = 0; s_batchIndex = 0; s_batchMismatch = 0; + s_phase = EscRelayPhase::BATCH_VERIFY; + setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::BATCH_VERIFY, 0); + uint16_t id; const uint8_t* d; uint8_t len; + if (batchCurrentTuple(&id, &d, &len)) { s_gotGet = false; sendGetConfig(id); s_lastSendMs = now; } + break; + } + // Phase C: probe / verify. setStatus(EscRelayStatusCode::RUNNING, EscRelayPhase::VERIFY, 0); if (now - s_lastProbeMs > PROBE_INTERVAL_MS) { s_gotGet = false; sendGetConfig(s_targetId); s_lastProbeMs = now; - USBSerial.printf("[ESCWR] reboot+%lums: verify probe sent\n", elapsed); // TEMP diag - } - if (s_gotGet) { // TEMP diag - USBSerial.printf("[ESCWR] verify resp recv=0x%04X flag=%d len=%d\n", - s_getResp.recv_config_id, (int)s_getResp.flag, s_getResp.data_len); } if (s_gotGet && s_getResp.recv_config_id == s_targetId && s_getResp.flag == 0) { if (dataMatches(s_getResp.data, s_getResp.data_len, s_expData, s_expLen)) { From fa96ce1a61b3112686d4e771181208252e51f79a Mon Sep 17 00:00:00 2001 From: Paul <36135447+PaulDWhite@users.noreply.github.com> Date: Sun, 21 Jun 2026 22:57:36 -0400 Subject: [PATCH 09/15] Serialize OTA & NVS access; batch NVS writes Add FreeRTOS mutexes and locking to serialize OTA state mutations and NVS/settings writes to prevent races/corruption. ota_service.cpp: introduce otaStateMutex and OtaStateLock, guard command/data callbacks, checkOtaTimeout and abortOta, initialize mutex in initOtaBleService, and add explanatory comments to avoid esp_ota_abort/use-after-abort races. device_settings.cpp: add s_prefsMutex and prefsEnsureMutex, create mutex during refreshDeviceData(), and rewrite writeDeviceData() to use the low-level NVS API (nvs_open / nvs_set_* / nvs_commit) so all settings are committed in a single transaction; hold the mutex during the operation to prevent concurrent writers. sdkconfig and sdkconfig.defaults: disable TASK_WDT idle-task checks on both CPUs and add comments explaining the rationale (avoid unexpected panic reboots on a flying device); propagate corresponding config line changes. main.cpp: add note clarifying watchdog initialization behavior given the disabled idle checks. Also add necessary FreeRTOS/NVS includes where required. --- sdkconfig.OpenPPG-CESP32S3-CAN-SP140 | 8 +-- sdkconfig.defaults | 10 +++- src/sp140/ble/ota_service.cpp | 54 ++++++++++++++++++- src/sp140/device_settings.cpp | 77 ++++++++++++++++++++++------ src/sp140/main.cpp | 7 ++- 5 files changed, 132 insertions(+), 24 deletions(-) diff --git a/sdkconfig.OpenPPG-CESP32S3-CAN-SP140 b/sdkconfig.OpenPPG-CESP32S3-CAN-SP140 index 07fc5eef..6900a701 100644 --- a/sdkconfig.OpenPPG-CESP32S3-CAN-SP140 +++ b/sdkconfig.OpenPPG-CESP32S3-CAN-SP140 @@ -907,8 +907,8 @@ CONFIG_ESP_INT_WDT_CHECK_CPU1=y CONFIG_ESP_TASK_WDT=y CONFIG_ESP_TASK_WDT_PANIC=y CONFIG_ESP_TASK_WDT_TIMEOUT_S=5 -CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=y -CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=y +# CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0 is not set +# CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1 is not set # CONFIG_ESP_PANIC_HANDLER_IRAM is not set # CONFIG_ESP_DEBUG_STUBS_ENABLE is not set CONFIG_ESP_SYSTEM_CHECK_INT_LEVEL_4=y @@ -1962,8 +1962,8 @@ CONFIG_INT_WDT_CHECK_CPU1=y CONFIG_TASK_WDT=y CONFIG_TASK_WDT_PANIC=y CONFIG_TASK_WDT_TIMEOUT_S=5 -CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU0=y -CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU1=y +# CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU0 is not set +# CONFIG_TASK_WDT_CHECK_IDLE_TASK_CPU1 is not set # CONFIG_ESP32_DEBUG_STUBS_ENABLE is not set CONFIG_TIMER_TASK_STACK_SIZE=3584 CONFIG_SW_COEXIST_ENABLE=y diff --git a/sdkconfig.defaults b/sdkconfig.defaults index 54389478..ffaa7705 100644 --- a/sdkconfig.defaults +++ b/sdkconfig.defaults @@ -21,4 +21,12 @@ CONFIG_ESP_TASK_WDT_PANIC=y # panic/WDT reset. diagnostics.cpp already has the reader; this populates it so a # field crash backtrace can be pulled over USB (espcoredump.py) during testing. CONFIG_ESP_COREDUMP_ENABLE_TO_FLASH=y -CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y \ No newline at end of file +CONFIG_ESP_COREDUMP_DATA_FORMAT_ELF=y +# Watchdog scope: only the throttle control loop + its watchdog task explicitly +# subscribe to the Task WDT (see main.cpp). Do NOT also watch the idle tasks — +# with idle-task checking on, ANY non-safety task (UI/BMS/BLE/a cache-disabled +# NVS commit) that monopolizes a core for >5 s would panic-reboot a *flying* +# device, which the design explicitly forbids. A genuine throttle-loop stall +# still reboots via its own esp_task_wdt_add subscription. +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0=n +CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU1=n \ No newline at end of file diff --git a/src/sp140/ble/ota_service.cpp b/src/sp140/ble/ota_service.cpp index 664535d8..a92550e7 100644 --- a/src/sp140/ble/ota_service.cpp +++ b/src/sp140/ble/ota_service.cpp @@ -3,6 +3,8 @@ #include #include "esp_ota_ops.h" #include "esp_partition.h" +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "sp140/ble/ble_core.h" #include "sp140/ble/ble_ids.h" #include "sp140/device_state.h" @@ -13,6 +15,33 @@ extern volatile DeviceState currentState; namespace { +// Serializes every OTA state mutation. The command/data write callbacks run on +// the NimBLE host task while checkOtaTimeout() runs on bleNotifyTask. Without +// this, a timeout-driven abort on bleNotifyTask could call esp_ota_abort() and +// resetOtaState() while a data callback is mid esp_ota_write() on the host task +// — a use-after-abort on updateHandle/sectorBuffer that corrupts flash/heap. +// abortOtaImpl() assumes the lock is already held; the public entry points +// (callbacks, checkOtaTimeout, abortOta) acquire it. +SemaphoreHandle_t otaStateMutex = nullptr; + +class OtaStateLock { + public: + explicit OtaStateLock(TickType_t timeout) { + if (otaStateMutex != nullptr) { + held_ = (xSemaphoreTake(otaStateMutex, timeout) == pdTRUE); + } + } + ~OtaStateLock() { + if (held_ && otaStateMutex != nullptr) { + xSemaphoreGive(otaStateMutex); + } + } + bool held() const { return held_; } + + private: + bool held_ = false; +}; + // Protocol Constants const uint16_t CMD_START = 0x0001; const uint16_t CMD_END = 0x0002; @@ -122,6 +151,15 @@ void abortOtaImpl(); void handleCmdStart(const uint8_t* data) { USBSerial.println("OTA: CMD_START"); + // A duplicate CMD_START (app restarted the transfer) would orphan the live + // esp_ota handle, because resetOtaState() below only zeroes updateHandle + // without esp_ota_abort(). Abort the previous session cleanly first. Runs + // under the OtaStateLock already held by OtaCommandCallback::onWrite. + if (otaInProgress) { + USBSerial.println("OTA: CMD_START during active update - aborting previous session"); + abortOtaImpl(); + } + if (currentState == ARMED || currentState == ARMED_CRUISING) { USBSerial.println("OTA Blocked: Device ARMED"); sendCommandResponse(CMD_START, 0x0001); @@ -164,6 +202,7 @@ void handleCmdStart(const uint8_t* data) { class OtaCommandCallback : public NimBLECharacteristicCallbacks { void onWrite(NimBLECharacteristic* pChar, NimBLEConnInfo& connInfo) override { + OtaStateLock lock(portMAX_DELAY); // serialize vs checkOtaTimeout (other task) std::string value = pChar->getValue(); // Command Packet: ID(2) + Payload(16) + CRC(2) = 20 bytes if (value.length() < 20) { @@ -222,6 +261,7 @@ class OtaCommandCallback : public NimBLECharacteristicCallbacks { class OtaDataCallback : public NimBLECharacteristicCallbacks { void onWrite(NimBLECharacteristic* pChar, NimBLEConnInfo& connInfo) override { + OtaStateLock lock(portMAX_DELAY); // serialize vs checkOtaTimeout (other task) if (!otaInProgress) return; std::string valStr = pChar->getValue(); @@ -323,9 +363,17 @@ static OtaDataCallback dataCallback; } // namespace -void abortOta() { abortOtaImpl(); } +void abortOta() { + // Called from the BLE disconnect callback (host task); lock vs checkOtaTimeout. + OtaStateLock lock(portMAX_DELAY); + abortOtaImpl(); +} void checkOtaTimeout() { + // Take the lock without waiting: if a host-task callback is mid-write the + // device is clearly not idle, so skip this tick rather than block or race. + OtaStateLock lock(0); + if (!lock.held()) return; if (otaInProgress && (millis() - lastOtaActivityMs > OTA_TIMEOUT_MS)) { USBSerial.println("OTA: Idle timeout, aborting."); abortOtaImpl(); @@ -337,6 +385,10 @@ bool isOtaInProgress() { } void initOtaBleService(NimBLEServer* pServer) { + if (otaStateMutex == nullptr) { + otaStateMutex = xSemaphoreCreateMutex(); + } + NimBLEService* pService = pServer->createService(OTA_SERVICE_UUID); // Firmware data (Write No Response + Indicate for ACKs) diff --git a/src/sp140/device_settings.cpp b/src/sp140/device_settings.cpp index e7004242..f3b0df8b 100644 --- a/src/sp140/device_settings.cpp +++ b/src/sp140/device_settings.cpp @@ -7,6 +7,9 @@ #include "sp140/globals.h" #include "../../inc/sp140/esp32s3-config.h" #include // Add ESP32 Preferences library +#include // Raw NVS API for the batched (single-commit) write +#include "freertos/FreeRTOS.h" +#include "freertos/semphr.h" #include "../../inc/sp140/throttle.h" #include "../../inc/sp140/diagnostics.h" @@ -75,6 +78,20 @@ const char* KEY_TIMEZONE_OFFSET = "tz_offset"; // Create a Preferences instance Preferences preferences; +// Serializes all NVS settings access. writeDeviceData() is reachable +// concurrently from the BLE host task (config-service onWrite callbacks), the +// button task (disarmSystem) and the serial task (parse_serial_command_line); +// the single shared NVS namespace was previously written with no lock, so two +// writers could interleave and silently lose/corrupt a settings write. Created +// once in refreshDeviceData() while still single-threaded in setup(). +static SemaphoreHandle_t s_prefsMutex = nullptr; + +static void prefsEnsureMutex() { + if (s_prefsMutex == nullptr) { + s_prefsMutex = xSemaphoreCreateMutex(); + } +} + namespace { constexpr size_t kWebSerialCommandBufferSize = 256; @@ -86,6 +103,10 @@ bool gWebSerialCommandOverflow = false; // Read saved data from Preferences void refreshDeviceData() { + // Create the NVS mutex now, while still single-threaded in setup(), so every + // later (multi-task) writeDeviceData() call takes an already-existing lock. + prefsEnsureMutex(); + // Try to initialize preferences, with corruption recovery if (!preferences.begin(PREFS_NAMESPACE, false)) { USBSerial.println(F("Failed to initialize Preferences - may be corrupted")); @@ -147,32 +168,54 @@ void refreshDeviceData() { USBSerial.println(F("Device data loaded from Preferences")); } -// Write to Preferences +// Write to NVS. Uses the raw NVS API (not the Preferences wrapper) so all 11 +// keys share ONE nvs_commit() instead of committing per key — a single flash +// transaction that, together with s_prefsMutex, removes any window in which a +// concurrent writer could observe or produce a torn/partial save. The key names +// and NVS value types here MATCH exactly what the Preferences getters in +// refreshDeviceData() expect (u8/u16/i32, and a 4-byte blob for the float), so +// data written by older firmware remains fully readable. void writeDeviceData() { - if (!preferences.begin(PREFS_NAMESPACE, false)) { - USBSerial.println(F("Failed to initialize Preferences for writing")); + prefsEnsureMutex(); + if (s_prefsMutex != nullptr) { + xSemaphoreTake(s_prefsMutex, portMAX_DELAY); + } + + nvs_handle_t handle = 0; + esp_err_t err = nvs_open(PREFS_NAMESPACE, NVS_READWRITE, &handle); + if (err != ESP_OK) { + USBSerial.println(F("Failed to open NVS for writing")); + if (s_prefsMutex != nullptr) { + xSemaphoreGive(s_prefsMutex); + } return; } - // Save all values to preferences with error checking bool success = true; - success &= (preferences.putUChar(KEY_VERSION_MAJOR, deviceData.version_major) > 0); - success &= (preferences.putUChar(KEY_VERSION_MINOR, deviceData.version_minor) > 0); - success &= (preferences.putUChar(KEY_SCREEN_ROTATION, deviceData.screen_rotation) > 0); - success &= (preferences.putFloat(KEY_SEA_PRESSURE, deviceData.sea_pressure) > 0); - success &= (preferences.putBool(KEY_METRIC_TEMP, deviceData.metric_temp) > 0); - success &= (preferences.putBool(KEY_METRIC_ALT, deviceData.metric_alt) > 0); - success &= (preferences.putUChar(KEY_PERFORMANCE_MODE, deviceData.performance_mode) > 0); - success &= (preferences.putUChar(KEY_THEME, deviceData.theme) > 0); - success &= (preferences.putUShort(KEY_ARMED_TIME, deviceData.armed_time) > 0); - success &= (preferences.putUChar(KEY_REVISION, deviceData.revision) > 0); - success &= (preferences.putInt(KEY_TIMEZONE_OFFSET, deviceData.timezone_offset) > 0); + success &= (nvs_set_u8(handle, KEY_VERSION_MAJOR, deviceData.version_major) == ESP_OK); + success &= (nvs_set_u8(handle, KEY_VERSION_MINOR, deviceData.version_minor) == ESP_OK); + success &= (nvs_set_u8(handle, KEY_SCREEN_ROTATION, deviceData.screen_rotation) == ESP_OK); + success &= (nvs_set_blob(handle, KEY_SEA_PRESSURE, &deviceData.sea_pressure, + sizeof(deviceData.sea_pressure)) == ESP_OK); + success &= (nvs_set_u8(handle, KEY_METRIC_TEMP, deviceData.metric_temp ? 1 : 0) == ESP_OK); + success &= (nvs_set_u8(handle, KEY_METRIC_ALT, deviceData.metric_alt ? 1 : 0) == ESP_OK); + success &= (nvs_set_u8(handle, KEY_PERFORMANCE_MODE, deviceData.performance_mode) == ESP_OK); + success &= (nvs_set_u8(handle, KEY_THEME, deviceData.theme) == ESP_OK); + success &= (nvs_set_u16(handle, KEY_ARMED_TIME, deviceData.armed_time) == ESP_OK); + success &= (nvs_set_u8(handle, KEY_REVISION, deviceData.revision) == ESP_OK); + success &= (nvs_set_i32(handle, KEY_TIMEZONE_OFFSET, deviceData.timezone_offset) == ESP_OK); + + // One commit for the whole settings blob. + success &= (nvs_commit(handle) == ESP_OK); + nvs_close(handle); + + if (s_prefsMutex != nullptr) { + xSemaphoreGive(s_prefsMutex); + } if (success) { - preferences.end(); USBSerial.println(F("Device data saved to Preferences")); } else { - preferences.end(); USBSerial.println(F("Warning: Some preferences may not have been saved correctly")); } } diff --git a/src/sp140/main.cpp b/src/sp140/main.cpp index b4f16640..ee43dc87 100644 --- a/src/sp140/main.cpp +++ b/src/sp140/main.cpp @@ -569,7 +569,12 @@ void setupAnalogRead() { void setupWatchdog() { #ifndef OPENPPG_DEBUG - // Initialize Task Watchdog (reboot on timeout) + // Initialize Task Watchdog (reboot on timeout). The idle-task checks on both + // cores are deliberately DISABLED in sdkconfig + // (CONFIG_ESP_TASK_WDT_CHECK_IDLE_TASK_CPU0/CPU1=n) so the watchdog scope is + // exactly the tasks that explicitly subscribe (throttleTask + watchdogTask). + // Otherwise any non-safety task (UI/BMS/BLE) monopolizing a core for >5 s + // would panic-reboot a flying device — the opposite of the intent. ESP_ERROR_CHECK(esp_task_wdt_init(WDT_TIMEOUT_SECONDS, true)); #endif // OPENPPG_DEBUG } From 90fdc34b65a226c182302224dbd5480e7de8a41b Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Wed, 24 Jun 2026 18:49:53 -0400 Subject: [PATCH 10/15] Bump version to 8.1 8.0 is tagged for release; unified-everything-app now targets 8.1. Co-Authored-By: Claude Opus 4.8 --- inc/version.h | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/inc/version.h b/inc/version.h index b7a8dfbe..125c70ec 100644 --- a/inc/version.h +++ b/inc/version.h @@ -2,7 +2,7 @@ #pragma once #define VERSION_MAJOR 8 -#define VERSION_MINOR 0 +#define VERSION_MINOR 1 // Monotonic build counter, independent of MAJOR/MINOR. Injected at build time // by extra_script.py (git commit count) so it climbs on every release. The From 2aedd7398a1c186e3da14baf0c02169c23900da7 Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Sat, 11 Jul 2026 21:30:28 -0400 Subject: [PATCH 11/15] Fix baseline CI checks --- inc/sp140/esc_config_relay.h | 2 +- src/sp140/esc_config_relay.cpp | 16 +++++++++++----- src/sp140/esc_flasher_relay.cpp | 2 +- .../reference/splash_dark.bmp | Bin 61494 -> 61494 bytes .../reference/splash_light.bmp | Bin 61494 -> 61494 bytes 5 files changed, 13 insertions(+), 7 deletions(-) diff --git a/inc/sp140/esc_config_relay.h b/inc/sp140/esc_config_relay.h index aac959cf..666d6c1a 100644 --- a/inc/sp140/esc_config_relay.h +++ b/inc/sp140/esc_config_relay.h @@ -40,7 +40,7 @@ enum class EscRelayPhase : uint8_t { VERIFY, READING, // read-all: iterating GetConfig over every param BATCH_WRITE, // batch: iterating SetConfig over every queued param - BATCH_VERIFY, // batch: iterating GetConfig to confirm each queued param persisted + BATCH_VERIFY, // batch: iterating GetConfig to confirm each queued param persisted DONE_OK, DONE_FAIL, }; diff --git a/src/sp140/esc_config_relay.cpp b/src/sp140/esc_config_relay.cpp index d1a664a0..432a836e 100644 --- a/src/sp140/esc_config_relay.cpp +++ b/src/sp140/esc_config_relay.cpp @@ -348,7 +348,9 @@ static void finishFail(EscRelayStatusCode code, uint8_t detail) { static bool dataMatches(const uint8_t* got, uint8_t gotLen, const uint8_t* exp, uint8_t expLen) { if (gotLen < expLen) return false; - for (uint8_t i = 0; i < expLen; i++) if (got[i] != exp[i]) return false; + for (uint8_t i = 0; i < expLen; i++) { + if (got[i] != exp[i]) return false; + } return true; } @@ -666,8 +668,11 @@ void escConfigRelayServiceTick() { } s_lastSendMs = now; } else if (timedOut) { - if (s_retries++ < RELAY_MAX_RETRIES) { s_gotUnlock = false; sendPasswordUnlock(); s_lastSendMs = now; } - else { finishFail(EscRelayStatusCode::TIMEOUT, 0); } + if (s_retries++ < RELAY_MAX_RETRIES) { + s_gotUnlock = false; sendPasswordUnlock(); s_lastSendMs = now; + } else { + finishFail(EscRelayStatusCode::TIMEOUT, 0); + } } break; @@ -758,8 +763,9 @@ void escConfigRelayServiceTick() { uint16_t nid; const uint8_t* nd; uint8_t nlen; if (batchCurrentTuple(&nid, &nd, &nlen)) { sendGetConfig(nid); s_lastSendMs = now; } } else if (timedOut) { - if (s_retries++ < RELAY_MAX_RETRIES) { s_gotGet = false; sendGetConfig(id); s_lastSendMs = now; } - else { + if (s_retries++ < RELAY_MAX_RETRIES) { + s_gotGet = false; sendGetConfig(id); s_lastSendMs = now; + } else { s_batchMismatch++; // gave up reading this one batchAdvanceCursor(len); s_retries = 0; s_gotGet = false; uint16_t nid; const uint8_t* nd; uint8_t nlen; diff --git a/src/sp140/esc_flasher_relay.cpp b/src/sp140/esc_flasher_relay.cpp index 3ca514d0..22df9e27 100644 --- a/src/sp140/esc_flasher_relay.cpp +++ b/src/sp140/esc_flasher_relay.cpp @@ -21,7 +21,7 @@ static const uint16_t FW_CHUNK_SIZE = 256; // SendFwData payload (matches static const unsigned long FW_TIMEOUT_MS = 150; // per-request response wait static const uint8_t FW_MAX_RETRIES = 10; static const unsigned long REBOOT_WAIT_MS = 700; // ESC reboot before bootloader answers -static const unsigned long RX_TIMEOUT_MS = 15000; // abort if the app stalls mid-transfer +static const unsigned long RX_TIMEOUT_MS = 15000; // abort if the app stalls mid-transfer static const uint32_t MAX_IMAGE_SIZE = 256u * 1024u; // sanity cap static const uint32_t HEADER_SIZE = 32; // firmware data starts at byte 32 diff --git a/test/test_screenshots/reference/splash_dark.bmp b/test/test_screenshots/reference/splash_dark.bmp index 001d6d8a092958e9d01180c2916597ef96b7c2b4..495549f7f94b88e8b6deb0c139ad66f45d0a671d 100644 GIT binary patch delta 250 zcmdn?fO*>k<_&vtCmL{0-jnOZ926QfxqprKMuPyx$#e3N_`Zyj6;`rMt}nDtnf zTv1d3g(j4WPCij%jIbn?aq`AGEfoI526fJ))TH9l;w4L$KzxwFIJu-rh3(zDcmMzY XpDegcZlgi1HIS delta 212 zcmdn?fO*>k<_&vtCm+pqnjEAfH#vWc+(v^W#>q?bohA#}%Wc%EWt^;N0vz=XEdu>Y`SH diff --git a/test/test_screenshots/reference/splash_light.bmp b/test/test_screenshots/reference/splash_light.bmp index 85ad8df0396b4dfbae208a2d503d7c8b740160f8..645e462577a3b04809136ad924a37ef17cd798a2 100644 GIT binary patch delta 250 zcmdn?fO*>k<_&vtCkAj%-jnOZykzN;$^C1*HwFYSPM(vG#P?;Ktgw=8a($r<+yDRn z;mUx*k;P7vWmj@ep0vheV}KXqWd7}JlWj_ECQn$(2G#--7b|y~7=SADtpZiZopJKz zDpwR$K%ohxqLWY57$Yo6Wt_aRP78%Uu|b`4%G4>%t<6E9K@cBgFitLMQeorZ;b353 Xm@K$VZeu{Lk<_&vtCm+pqnjEAfH#vWc+{S<;#>q?bohA#}%Wd?kWt^;N0vz=XEduk1<$6 From 2724af14b96e53ec84520bc852b1e54444cb4dbc Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Sat, 11 Jul 2026 11:41:48 -0400 Subject: [PATCH 12/15] Trim unused LVGL features --- inc/sp140/lvgl/lv_conf.h | 38 ++++++++++++++++++++++++++++++-------- 1 file changed, 30 insertions(+), 8 deletions(-) diff --git a/inc/sp140/lvgl/lv_conf.h b/inc/sp140/lvgl/lv_conf.h index 8baef2fb..0d36c138 100644 --- a/inc/sp140/lvgl/lv_conf.h +++ b/inc/sp140/lvgl/lv_conf.h @@ -136,16 +136,18 @@ #define LV_USE_ANIMIMG 0 -#define LV_USE_ARC 1 +#define LV_USE_ARC 0 #define LV_USE_BAR 1 -#define LV_USE_BUTTON 1 +#define LV_USE_BUTTON 0 -#define LV_USE_BUTTONMATRIX 1 /* Required by other components */ +#define LV_USE_BUTTONMATRIX 0 #define LV_USE_CANVAS 0 /* Don't need canvas */ +#define LV_USE_CHART 0 + #define LV_USE_CALENDAR 0 /* Explicitly disable calendar widget */ #define LV_USE_CHECKBOX 0 /* Don't need checkbox */ @@ -154,6 +156,8 @@ #define LV_USE_IMAGE 1 /*Requires: lv_label*/ +#define LV_USE_IMAGEBUTTON 0 + #define LV_USE_LABEL 1 #if LV_USE_LABEL #define LV_LABEL_TEXT_SELECTION 1 /*Enable selecting text of the label*/ @@ -162,21 +166,39 @@ #define LV_USE_LINE 1 +#define LV_USE_LIST 0 + +#define LV_USE_MENU 0 + +#define LV_USE_MSGBOX 0 + #define LV_USE_ROLLER 0 /* Don't need roller */ +#define LV_USE_SCALE 0 + #define LV_USE_SLIDER 0 /* Don't need slider */ -#define LV_USE_SPAN 1 +#define LV_USE_SPAN 0 #if LV_USE_SPAN /*A line text can contain maximum num of span descriptor */ #define LV_SPAN_SNIPPET_STACK_SIZE 64 #endif +#define LV_USE_SPINBOX 0 + #define LV_USE_SWITCH 0 /* Don't need switch */ -#define LV_USE_TEXTAREA 1 /* Required by other components */ +#define LV_USE_TABLE 0 + +#define LV_USE_TABVIEW 0 + +#define LV_USE_TEXTAREA 0 + +#define LV_USE_TILEVIEW 0 + +#define LV_USE_WIN 0 -#define LV_USE_SPINNER 1 +#define LV_USE_SPINNER 0 /* keyboard widget (set to 0 since we don't use it) */ #define LV_USE_KEYBOARD 0 @@ -207,10 +229,10 @@ *==================*/ /*A layout similar to Flexbox in CSS.*/ -#define LV_USE_FLEX 1 +#define LV_USE_FLEX 0 /*A layout similar to Grid in CSS.*/ -#define LV_USE_GRID 1 +#define LV_USE_GRID 0 /*==================== * 3RD PARTS LIBRARIES From 90bdd0d85ce84c02ce7eee59dd4131caaaf9a8ef Mon Sep 17 00:00:00 2001 From: Zach Whitehead Date: Sun, 12 Jul 2026 09:44:00 -0400 Subject: [PATCH 13/15] Improve LVGL dark theme and splash tests Extract the splash screen into a shared production builder and reuse it from the screenshot tests so the real UI is covered without tying golden images to version bumps. This also tunes dark-mode colors for battery, BLE, armed, and climb/descent indicators to preserve contrast, adds screenshot coverage for max climb and descent states, and fixes core dump task name copying to guarantee null termination. --- inc/sp140/lvgl/lvgl_main_screen.h | 10 ++ inc/sp140/lvgl/lvgl_splash.h | 12 +++ inc/sp140/lvgl/lvgl_updates.h | 2 +- src/sp140/diagnostics.cpp | 3 +- src/sp140/lvgl/lvgl_core.cpp | 60 +---------- src/sp140/lvgl/lvgl_main_screen.cpp | 12 ++- src/sp140/lvgl/lvgl_splash.cpp | 58 ++++++++++ src/sp140/lvgl/lvgl_updates.cpp | 39 ++++--- test/test_screenshots/CMakeLists.txt | 1 + test/test_screenshots/build_and_run.sh | 9 +- .../reference/main_armed_cruising_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_armed_flying_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_ble_pairing_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_charging_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_critical_alerts_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_esc_disconnected_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_full_battery_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_high_altitude_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_high_power_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_idle_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_low_battery_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_max_climb_dark.bmp | Bin 0 -> 61494 bytes .../reference/main_max_climb_light.bmp | Bin 0 -> 61494 bytes .../reference/main_max_descent_dark.bmp | Bin 0 -> 61494 bytes .../reference/main_max_descent_light.bmp | Bin 0 -> 61494 bytes .../reference/main_temp_critical_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_temp_warnings_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_warning_alerts_dark.bmp | Bin 61494 -> 61494 bytes test/test_screenshots/test_screenshots.cpp | 101 ++++++++++++------ 29 files changed, 188 insertions(+), 119 deletions(-) create mode 100644 inc/sp140/lvgl/lvgl_splash.h create mode 100644 src/sp140/lvgl/lvgl_splash.cpp create mode 100644 test/test_screenshots/reference/main_max_climb_dark.bmp create mode 100644 test/test_screenshots/reference/main_max_climb_light.bmp create mode 100644 test/test_screenshots/reference/main_max_descent_dark.bmp create mode 100644 test/test_screenshots/reference/main_max_descent_light.bmp diff --git a/inc/sp140/lvgl/lvgl_main_screen.h b/inc/sp140/lvgl/lvgl_main_screen.h index 30bb766f..ac8ee00f 100644 --- a/inc/sp140/lvgl/lvgl_main_screen.h +++ b/inc/sp140/lvgl/lvgl_main_screen.h @@ -21,6 +21,16 @@ enum ScreenPage { #define LVGL_PURPLE lv_color_make(128, 0, 128) #define LVGL_GRAY lv_color_make(128, 128, 128) +// Dark-theme accents are intentionally subdued so white foreground text stays +// readable whether it falls over a filled indicator or the black background. +#define LVGL_DARK_GREEN lv_color_make(0, 112, 48) +#define LVGL_DARK_YELLOW lv_color_make(128, 96, 0) +#define LVGL_DARK_RED lv_color_make(176, 0, 32) +#define LVGL_DARK_CYAN lv_color_make(0, 96, 104) +#define LVGL_DARK_BLE_BLUE lv_color_make(64, 160, 255) +#define LVGL_DARK_DESCENT_BLUE lv_color_make(74, 144, 226) +#define LVGL_DARK_DESCENT_PURPLE lv_color_make(192, 128, 255) + // LVGL styles for temperature warnings and critical states extern lv_style_t style_warning; diff --git a/inc/sp140/lvgl/lvgl_splash.h b/inc/sp140/lvgl/lvgl_splash.h new file mode 100644 index 00000000..4c623f91 --- /dev/null +++ b/inc/sp140/lvgl/lvgl_splash.h @@ -0,0 +1,12 @@ +#ifndef INC_SP140_LVGL_LVGL_SPLASH_H_ +#define INC_SP140_LVGL_LVGL_SPLASH_H_ + +#include + +#include "sp140/structs.h" + +// Builds and loads the production splash screen. The caller owns the returned +// screen and may delete it after transitioning to the main screen. +lv_obj_t* createLvglSplashScreen(const STR_DEVICE_DATA_140_V1& deviceData); + +#endif // INC_SP140_LVGL_LVGL_SPLASH_H_ diff --git a/inc/sp140/lvgl/lvgl_updates.h b/inc/sp140/lvgl/lvgl_updates.h index 5c68b85f..82548d7c 100644 --- a/inc/sp140/lvgl/lvgl_updates.h +++ b/inc/sp140/lvgl/lvgl_updates.h @@ -43,7 +43,7 @@ void updateLvglMainScreen( ); // Helper update functions -void updateClimbRateIndicator(float climbRate); +void updateClimbRateIndicator(float climbRate, bool darkMode); // Test function void updateLvglMainScreenWithTestData(const STR_DEVICE_DATA_140_V1& deviceData); diff --git a/src/sp140/diagnostics.cpp b/src/sp140/diagnostics.cpp index 4e909362..4b06bf11 100644 --- a/src/sp140/diagnostics.cpp +++ b/src/sp140/diagnostics.cpp @@ -433,7 +433,8 @@ CoreDumpSummary diagReadCoreDumpSummary(DiagResetReason reason) { esp_core_dump_summary_t core_dump = {}; if (esp_core_dump_get_summary(&core_dump) == ESP_OK) { summary.valid = 1; - strncpy(summary.task, core_dump.exc_task, sizeof(summary.task) - 1); + memcpy(summary.task, core_dump.exc_task, sizeof(summary.task) - 1); + summary.task[sizeof(summary.task) - 1] = '\0'; summary.pc = core_dump.exc_pc; summary.exc_cause = core_dump.ex_info.exc_cause; summary.exc_vaddr = core_dump.ex_info.exc_vaddr; diff --git a/src/sp140/lvgl/lvgl_core.cpp b/src/sp140/lvgl/lvgl_core.cpp index e115cba3..183a7149 100644 --- a/src/sp140/lvgl/lvgl_core.cpp +++ b/src/sp140/lvgl/lvgl_core.cpp @@ -1,6 +1,6 @@ // Copyright 2020 #include "../../../inc/sp140/lvgl/lvgl_core.h" -#include "../../../inc/version.h" +#include "sp140/lvgl/lvgl_splash.h" #include "../../../inc/sp140/esp32s3-config.h" // Global variables for core LVGL functionality @@ -132,63 +132,7 @@ void updateLvgl() { void displayLvglSplash(const STR_DEVICE_DATA_140_V1& deviceData, int duration) { USBSerial.println("Displaying LVGL splash screen"); - - // Create a new screen for the splash - lv_obj_t* splash_screen = lv_obj_create(NULL); - lv_screen_load(splash_screen); - - // Disable scrollbars - lv_obj_remove_flag(splash_screen, LV_OBJ_FLAG_SCROLLABLE); - - // Set background color based on theme - lv_obj_set_style_bg_color(splash_screen, - deviceData.theme == 1 ? lv_color_black() : lv_color_white(), - LV_PART_MAIN); - - // OpenPPG title - lv_obj_t* title_label = lv_label_create(splash_screen); - lv_label_set_text(title_label, "OpenPPG"); - // Use a larger font for the title - lv_obj_set_style_text_font(title_label, &lv_font_montserrat_28, 0); - lv_obj_set_style_text_color(title_label, - deviceData.theme == 1 ? lv_color_white() : lv_color_black(), - 0); - lv_obj_align(title_label, LV_ALIGN_TOP_MID, 0, 15); - - // Create animation for title - lv_anim_t title_anim; - lv_anim_init(&title_anim); - lv_anim_set_var(&title_anim, title_label); - lv_anim_set_values(&title_anim, 0, 255); - lv_anim_set_time(&title_anim, 500); - lv_anim_set_exec_cb(&title_anim, [](void* var, int32_t value) { - lv_obj_set_style_opa((lv_obj_t*)var, value, 0); // NOLINT(readability/casting) - }); - lv_anim_start(&title_anim); - - // Version label - lv_obj_t* version_label = lv_label_create(splash_screen); - char version_str[10]; - snprintf(version_str, sizeof(version_str), "v%d.%d", VERSION_MAJOR, VERSION_MINOR); - lv_label_set_text(version_label, version_str); - lv_obj_set_style_text_font(version_label, &lv_font_montserrat_16, 0); - lv_obj_set_style_text_color(version_label, - deviceData.theme == 1 ? lv_color_white() : lv_color_black(), - 0); - lv_obj_align(version_label, LV_ALIGN_CENTER, 0, 0); - - // Time used label - lv_obj_t* time_label = lv_label_create(splash_screen); - char time_str[10]; - const int hours = deviceData.armed_time / 60; - const int minutes = deviceData.armed_time % 60; - snprintf(time_str, sizeof(time_str), "%02d:%02d", hours, minutes); - lv_label_set_text(time_label, time_str); - lv_obj_set_style_text_font(time_label, &lv_font_montserrat_16, 0); - lv_obj_set_style_text_color(time_label, - deviceData.theme == 1 ? lv_color_white() : lv_color_black(), - 0); - lv_obj_align(time_label, LV_ALIGN_BOTTOM_MID, 0, -20); + createLvglSplashScreen(deviceData); // Process LVGL for the duration uint32_t start_time = millis(); diff --git a/src/sp140/lvgl/lvgl_main_screen.cpp b/src/sp140/lvgl/lvgl_main_screen.cpp index e0d2dca6..bdd9f4f4 100644 --- a/src/sp140/lvgl/lvgl_main_screen.cpp +++ b/src/sp140/lvgl/lvgl_main_screen.cpp @@ -138,7 +138,8 @@ void setupMainScreen(bool darkMode) { battery_label = lv_label_create(main_screen); lv_obj_align(battery_label, LV_ALIGN_TOP_MID, 0, 3); // Move up for better vertical centering in battery bar lv_obj_set_style_text_font(battery_label, &lv_font_montserrat_28, 0); // Large font for prominent percentage display - lv_obj_set_style_text_color(battery_label, LVGL_BLACK, 0); + lv_obj_set_style_text_color(battery_label, + darkMode ? LVGL_WHITE : LVGL_BLACK, 0); // Center-align battery percentage since it's in the middle lv_obj_set_style_text_align(battery_label, LV_TEXT_ALIGN_CENTER, 0); @@ -534,7 +535,8 @@ void setupMainScreen(bool darkMode) { ble_pairing_icon = lv_label_create(main_screen); lv_label_set_text(ble_pairing_icon, LV_SYMBOL_BLUETOOTH); lv_obj_set_style_text_font(ble_pairing_icon, &lv_font_montserrat_14, 0); - lv_obj_set_style_text_color(ble_pairing_icon, LVGL_BLUE, 0); + lv_obj_set_style_text_color( + ble_pairing_icon, darkMode ? LVGL_DARK_BLE_BLUE : LVGL_BLUE, 0); lv_obj_set_pos(ble_pairing_icon, 103, 72); lv_obj_add_flag(ble_pairing_icon, LV_OBJ_FLAG_HIDDEN); // Hide initially @@ -556,9 +558,11 @@ void setupMainScreen(bool darkMode) { lv_line_set_points(climb_rate_divider_lines[i], line_points[i], 2); - // Make the center line (line 6) special: 3 pixels wide and black + // Make the center line (line 6) thicker and theme-visible. if (i == 6) { - lv_obj_set_style_line_color(climb_rate_divider_lines[i], LVGL_BLACK, LV_PART_MAIN); + lv_obj_set_style_line_color( + climb_rate_divider_lines[i], darkMode ? LVGL_WHITE : LVGL_BLACK, + LV_PART_MAIN); lv_obj_set_style_line_width(climb_rate_divider_lines[i], 3, LV_PART_MAIN); } else { lv_obj_set_style_line_color(climb_rate_divider_lines[i], LVGL_GRAY, LV_PART_MAIN); diff --git a/src/sp140/lvgl/lvgl_splash.cpp b/src/sp140/lvgl/lvgl_splash.cpp new file mode 100644 index 00000000..34d8cb72 --- /dev/null +++ b/src/sp140/lvgl/lvgl_splash.cpp @@ -0,0 +1,58 @@ +#include "sp140/lvgl/lvgl_splash.h" + +#include + +#include "version.h" + +lv_obj_t* createLvglSplashScreen(const STR_DEVICE_DATA_140_V1& deviceData) { + lv_obj_t* splash_screen = lv_obj_create(NULL); + lv_screen_load(splash_screen); + lv_obj_remove_flag(splash_screen, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color( + splash_screen, + deviceData.theme == 1 ? lv_color_black() : lv_color_white(), + LV_PART_MAIN); + + lv_obj_t* title_label = lv_label_create(splash_screen); + lv_label_set_text(title_label, "OpenPPG"); + lv_obj_set_style_text_font(title_label, &lv_font_montserrat_28, 0); + lv_obj_set_style_text_color( + title_label, + deviceData.theme == 1 ? lv_color_white() : lv_color_black(), 0); + lv_obj_align(title_label, LV_ALIGN_TOP_MID, 0, 15); + + lv_anim_t title_anim; + lv_anim_init(&title_anim); + lv_anim_set_var(&title_anim, title_label); + lv_anim_set_values(&title_anim, 0, 255); + lv_anim_set_time(&title_anim, 500); + lv_anim_set_exec_cb(&title_anim, [](void* var, int32_t value) { + lv_obj_set_style_opa((lv_obj_t*)var, value, 0); // NOLINT(readability/casting) + }); + lv_anim_start(&title_anim); + + lv_obj_t* version_label = lv_label_create(splash_screen); + char version_str[10]; + snprintf(version_str, sizeof(version_str), "v%d.%d", VERSION_MAJOR, + VERSION_MINOR); + lv_label_set_text(version_label, version_str); + lv_obj_set_style_text_font(version_label, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color( + version_label, + deviceData.theme == 1 ? lv_color_white() : lv_color_black(), 0); + lv_obj_align(version_label, LV_ALIGN_CENTER, 0, 0); + + lv_obj_t* time_label = lv_label_create(splash_screen); + char time_str[10]; + const int hours = deviceData.armed_time / 60; + const int minutes = deviceData.armed_time % 60; + snprintf(time_str, sizeof(time_str), "%02d:%02d", hours, minutes); + lv_label_set_text(time_label, time_str); + lv_obj_set_style_text_font(time_label, &lv_font_montserrat_16, 0); + lv_obj_set_style_text_color( + time_label, + deviceData.theme == 1 ? lv_color_white() : lv_color_black(), 0); + lv_obj_align(time_label, LV_ALIGN_BOTTOM_MID, 0, -20); + + return splash_screen; +} diff --git a/src/sp140/lvgl/lvgl_updates.cpp b/src/sp140/lvgl/lvgl_updates.cpp index 43f71953..77647cd3 100644 --- a/src/sp140/lvgl/lvgl_updates.cpp +++ b/src/sp140/lvgl/lvgl_updates.cpp @@ -400,7 +400,7 @@ void stopBLEPairingIconFlash() { } // Update the climb rate indicator -void updateClimbRateIndicator(float climbRate) { +void updateClimbRateIndicator(float climbRate, bool darkMode) { // Vario display scale: 6 segments per direction cover a +/-3 m/s climb/descent // envelope. The raw value is NOT clamped here - sectionsToFill is capped at 6 // below, so rates beyond +/-3 m/s simply pin the gauge to full deflection. @@ -421,10 +421,10 @@ void updateClimbRateIndicator(float climbRate) { lv_color_t negative_colors[6] = { lv_color_make(173, 216, 230), // Light blue lv_color_make(173, 216, 230), // Light blue (again) - lv_color_make(70, 130, 180), // Darker blue - lv_color_make(70, 130, 180), // Darker blue (again) - lv_color_make(75, 0, 130), // Dark purple - lv_color_make(75, 0, 130) // Dark purple (again) + darkMode ? LVGL_DARK_DESCENT_BLUE : lv_color_make(70, 130, 180), + darkMode ? LVGL_DARK_DESCENT_BLUE : lv_color_make(70, 130, 180), + darkMode ? LVGL_DARK_DESCENT_PURPLE : lv_color_make(75, 0, 130), + darkMode ? LVGL_DARK_DESCENT_PURPLE : lv_color_make(75, 0, 130) }; // Compute the desired state for all 12 sections, then diff-apply so only @@ -525,11 +525,11 @@ void updateLvglMainScreen( lv_bar_set_value(battery_bar, (int)batteryPercent, LV_ANIM_OFF); // Set color based on percentage - lv_color_t batteryColor = LVGL_RED; + lv_color_t batteryColor = darkMode ? LVGL_DARK_RED : LVGL_RED; if (batteryPercent > bmsSOCThresholds.warnLow) { - batteryColor = LVGL_GREEN; + batteryColor = darkMode ? LVGL_DARK_GREEN : LVGL_GREEN; } else if (batteryPercent >= bmsSOCThresholds.critLow) { - batteryColor = LVGL_YELLOW; + batteryColor = darkMode ? LVGL_DARK_YELLOW : LVGL_YELLOW; } setBgColor(battery_bar, batteryColor, LV_PART_INDICATOR); @@ -538,7 +538,7 @@ void updateLvglMainScreen( char buffer[10]; snprintf(buffer, sizeof(buffer), "%d%%", (int)batteryPercent); setLabelText(battery_label, buffer); - setLabelTextColor(battery_label, LVGL_BLACK); + setLabelTextColor(battery_label, darkMode ? LVGL_WHITE : LVGL_BLACK); } else if (escConnected) { // clear the battery bar, we handle voltage later lv_bar_set_value(battery_bar, 0, LV_ANIM_OFF); @@ -557,15 +557,16 @@ void updateLvglMainScreen( snprintf(buffer, sizeof(buffer), "%2.2fv", lowestCellV); setLabelText(voltage_left_label, buffer); - // Always use black text for better readability - setLabelTextColor(voltage_left_label, LVGL_BLACK); + setLabelTextColor(voltage_left_label, + darkMode ? LVGL_WHITE : LVGL_BLACK); if (lastVoltLeftMode != 0) { // Restore default position when BMS is connected lv_obj_align(voltage_left_label, LV_ALIGN_TOP_LEFT, 3, 12); lastVoltLeftMode = 0; } } else if (escConnected) { - setLabelTextColor(voltage_left_label, LVGL_BLACK); + setLabelTextColor(voltage_left_label, + darkMode ? LVGL_WHITE : LVGL_BLACK); setLabelText(voltage_left_label, "NO\nBMS"); if (lastVoltLeftMode != 1) { // Move up by 10 pixels when showing NO BMS @@ -590,7 +591,8 @@ void updateLvglMainScreen( char batt_buffer[10]; snprintf(batt_buffer, sizeof(batt_buffer), "%d%%", (int)batteryPercent); setLabelText(battery_label, batt_buffer); - setLabelTextColor(battery_label, LVGL_BLACK); + setLabelTextColor(battery_label, + darkMode ? LVGL_WHITE : LVGL_BLACK); } } else if (escConnected) { @@ -599,7 +601,8 @@ void updateLvglMainScreen( char buffer[10]; snprintf(buffer, sizeof(buffer), "%2.1fv", totalVolts); setLabelText(battery_label, buffer); - setLabelTextColor(battery_label, LVGL_BLACK); + setLabelTextColor(battery_label, + darkMode ? LVGL_WHITE : LVGL_BLACK); } else { setLabelText(voltage_right_label, ""); } @@ -926,8 +929,10 @@ void updateLvglMainScreen( // Update armed indicator if (armed) { - // Set background to CYAN when armed, regardless of cruise state - setBgColor(arm_indicator, LVGL_CYAN, LV_PART_MAIN); + // Keep the armed tile bright in light mode and glare/contrast safe in dark. + setBgColor(arm_indicator, + darkMode ? LVGL_DARK_CYAN : LVGL_CYAN, + LV_PART_MAIN); lv_obj_remove_flag(arm_indicator, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_add_flag(arm_indicator, LV_OBJ_FLAG_HIDDEN); @@ -1011,7 +1016,7 @@ void updateLvglMainScreen( climbRate = altitudeChange / timeChange; // m/s } } - updateClimbRateIndicator(climbRate); + updateClimbRateIndicator(climbRate, darkMode); lastAltitude = altitude; lastAltitudeTime = currentTime; } diff --git a/test/test_screenshots/CMakeLists.txt b/test/test_screenshots/CMakeLists.txt index a489df47..37e5cba5 100644 --- a/test/test_screenshots/CMakeLists.txt +++ b/test/test_screenshots/CMakeLists.txt @@ -59,6 +59,7 @@ target_compile_options(lvgl PRIVATE -w) # Project UI source files (the actual code under test) set(UI_SOURCES + ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_splash.cpp ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_main_screen.cpp ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_updates.cpp ${PROJECT_ROOT}/src/sp140/lvgl/lvgl_alerts.cpp diff --git a/test/test_screenshots/build_and_run.sh b/test/test_screenshots/build_and_run.sh index 849f30b9..7655bcea 100755 --- a/test/test_screenshots/build_and_run.sh +++ b/test/test_screenshots/build_and_run.sh @@ -85,6 +85,11 @@ fi mkdir -p "$BUILD_DIR" +# --local uses RelWithDebInfo: optimized binary (5-10x faster renderer) with +# debug symbols. CI keeps Debug for faithful failure diagnostics. +BUILD_TYPE="Debug" +[ "$LOCAL_BUILD" = 1 ] && BUILD_TYPE="RelWithDebInfo" + # If the cached generator or build type no longer matches, clear the build dir once. if [ -f "$BUILD_DIR/CMakeCache.txt" ]; then cached_gen=$(grep '^CMAKE_GENERATOR:INTERNAL=' "$BUILD_DIR/CMakeCache.txt" 2>/dev/null | cut -d= -f2) @@ -102,10 +107,6 @@ cd "$BUILD_DIR" echo "" echo "--- Configuring CMake ---" -# --local uses RelWithDebInfo: optimized binary (5-10x faster renderer) with debug symbols. -# CI keeps Debug for faithful failure diagnostics. -BUILD_TYPE="Debug" -[ "$LOCAL_BUILD" = 1 ] && BUILD_TYPE="RelWithDebInfo" # shellcheck disable=SC2086 cmake "$SCRIPT_DIR" \ -DCMAKE_BUILD_TYPE="$BUILD_TYPE" \ diff --git a/test/test_screenshots/reference/main_armed_cruising_dark.bmp b/test/test_screenshots/reference/main_armed_cruising_dark.bmp index 80f7eb0fecacde32a3ae3abe1d9ac30b22f3fd92..4b3e5be6ec78b6573b2808ec32100a9902870aa2 100644 GIT binary patch literal 61494 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 literal 61494 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 diff --git a/test/test_screenshots/reference/main_armed_flying_dark.bmp b/test/test_screenshots/reference/main_armed_flying_dark.bmp index 79398c284340c81dee8b29da7eabfe2cd281b463..4075255ed61fd7aff5ff20ebdebd71cda64f37ca 100644 GIT binary patch literal 61494 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 literal 61494 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 Ai2wiq diff --git a/test/test_screenshots/reference/main_charging_dark.bmp b/test/test_screenshots/reference/main_charging_dark.bmp index 5e2674fefddeab34ab311972566ed70c36086687..83b8466d825e5dc83c37b034b4cf66a9d7f3a671 100644 GIT binary patch literal 61494 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 j|H%*@cXWB*i>~W@vKE0fQ1m6!IKKye33sbh? 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 diff --git a/test/test_screenshots/reference/main_critical_alerts_dark.bmp b/test/test_screenshots/reference/main_critical_alerts_dark.bmp index 2935d689731a27c3a52bf758d88ece451dda10d9..8f0d8dee48591825da80f0d8bd91aad092cd6893 100644 GIT binary patch literal 61494 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 P1-b>g1-b<`Yk~g*FJI-C literal 61494 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 E0hXsMSO5S3 diff --git a/test/test_screenshots/reference/main_esc_disconnected_dark.bmp b/test/test_screenshots/reference/main_esc_disconnected_dark.bmp index 09e64a04d61f8df20e5c8185b7f8572205c1678f..d36c2794a84f872581f82bcc6ce09f0ad4ff85d0 100644 GIT binary patch literal 61494 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 literal 61494 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 L9f6KOV+8&WvmE)o diff --git a/test/test_screenshots/reference/main_high_altitude_dark.bmp b/test/test_screenshots/reference/main_high_altitude_dark.bmp index bcd5fd3e3f9bf860f02e7cbdd44d82c358288ad3..b459fae04faa540e6acd0a82e43b29c543c369de 100644 GIT binary patch literal 61494 zcmeI5F{>q65y!`D0zbe6egFqdOco4Bj0BSbKY$AcZniuOL-~0Rf_kZ|4%7^d;Q{dw+1Jq2$Sc#OU-o1HB4@7*yluQI2 zvTq3A=ih%0zT~Nl-0?jnSTp7_zF-QnXdr(a2OlwbEioR z>HIKDr%uGAQx;Z7v8P}#tI*+rJ5GS(BSdwSnM#B>92(bYd$O1aEJrz0s6v*#474gN zM^f4*Ejg(*zBqy0R4;02GF=j;$-*%b3uP)|tc6RS#3VLiXLo;n?gSUHNsE=*D!we1 zB{b$`Ayt^{6NP$J1AZtY1T!pbB_fm*?kx!f(vZ%k@dZ;40U)NR;wvaiCnH~aqaRZ{T4^TWELsJMedq} zuSazfxJ8q4*Q9ocERi1Y9lhd=M~7I(RPiN*N@#2hT~&-EfI{^S#i70=A`_vs@<=$T z81UQ3U26E;qf3(doP$cx;u>8ZpVe z8^ssq&d5gv889XN!q&FjIpTHZYcgiArSI7`yoLCRmRMJBl%Q4_c{c1DUt z^V;-m<@RA4VwXX-2^L#kLLa2u)o^26OCw3+$!fGd#~m~3H|;Gc9iJ=!;TVY8h%Q!* z0jx-&hz4jlbXjsX1kr#Y`Vi%v5WBv4MbQ}H8^b>K4$<})!<`5g_Kry}9(LAPz?L@> z+`9Fu;}H9W)Os;mc|{YRW;yVRwOZd z#**R>;fsILg^OI48H?r;K4+6X&nR%gLf~*O!)e-*HsL}fgUfq_tgR~b9+F~7RoOgT zdCY~<)tb>^fWwmwa?}vMr5hs$pv!>X_C3jB30apF$AiC zVQUi+OAxAdx-fIlUGjc_LRaDB9S>Kq2F7!{GsIAbJ z186A;Q_~?<_KtEkfiDTAHMaA@8=^96bnU9H1kb2s6u^c<93EAPmbONixpbFfM>(6o z*Cnx0Hd(1X-|$s*h{Gl_duRvJyqcM&5WWOk;=AbtH>aPNiZ4E7Iu%UfN)EUGWh!@V zdHu?9RHLpsfZO@YswY)5n>`gKm*@s4{Sb`qJ`SgTot6{?Ac7Kq8tWzISro8R1LYrt zxfl!X_+OVabmGephGPTD%c+e*XCZY~4jrq!cEi+JπrA%{1rQCDR~4W^x*tkg^s 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 wU;O*q(@U=X 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 diff --git a/test/test_screenshots/reference/main_high_power_dark.bmp b/test/test_screenshots/reference/main_high_power_dark.bmp index f29eddb5e8554032a4f7a46c3dcabd43f5a5a999..29e9376dee0e1e8e41f358779ccf0367128150aa 100644 GIT binary patch literal 61494 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 literal 61494 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 diff --git a/test/test_screenshots/reference/main_idle_dark.bmp b/test/test_screenshots/reference/main_idle_dark.bmp index 7c28ff812397bd9bc1badff183ad60facce17e62..5cc9784dad21d71878fa8fdb74e6583406e5aa8c 100644 GIT binary patch literal 61494 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 Ul?*FO9>{6tHKlQ*ZWkW-KP`BBy#N3J literal 61494 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 Gdf@+aOG_32 diff --git a/test/test_screenshots/reference/main_max_climb_dark.bmp b/test/test_screenshots/reference/main_max_climb_dark.bmp new file mode 100644 index 0000000000000000000000000000000000000000..7443dd95184c3b4f3cddccfeffe97b212379a2af GIT binary patch literal 61494 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 literal 0 HcmV?d00001 diff --git a/test/test_screenshots/reference/main_max_climb_light.bmp b/test/test_screenshots/reference/main_max_climb_light.bmp new file mode 100644 index 0000000000000000000000000000000000000000..9c79e797a8225baae7989c58ea1418a7bfa4b0f4 GIT binary patch literal 61494 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 W<7PLsHV&)Z8%P}&0S9p7#Qy;xj3~hX literal 0 HcmV?d00001 diff --git a/test/test_screenshots/reference/main_max_descent_dark.bmp b/test/test_screenshots/reference/main_max_descent_dark.bmp new file mode 100644 index 0000000000000000000000000000000000000000..0d3546d305a4f0429430420a340e4bc8770516f9 GIT binary patch literal 61494 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 SD0-mFZ%fe^FRCf+f&T&Lm-gWR literal 61494 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 edoOz+_t^8<MSbYz2ieS&Wv4 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 literal 61494 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 Y{V|OrYJ6if?oV!f?~iF5(T(%{KPkreBLDyZ diff --git a/test/test_screenshots/reference/main_warning_alerts_dark.bmp b/test/test_screenshots/reference/main_warning_alerts_dark.bmp index cb3c4f6abf0fa22e899aeec8c0232bbe7fdd6f07..a85c1dd16cadfe7106c9a11a83396df6701ce3a2 100644 GIT binary patch literal 61494 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 Date: Sun, 12 Jul 2026 10:59:25 -0400 Subject: [PATCH 14/15] Optimize critical border flashing redraws Refactors the critical alert border from a single full-screen bordered object to four thin edge strips, reducing LVGL invalidation/flush work during flash animation. Adds `setCriticalBorderOpacity`/`getCriticalBorderOpacity` helpers, clears the non-owning `critical_border` pointer before rebuilding the main screen, and updates flash logic and screenshot tests to use the new opacity API without forced full-screen invalidation or immediate refresh calls. --- inc/sp140/lvgl/lvgl_main_screen.h | 2 + src/sp140/lvgl/lvgl_main_screen.cpp | 63 +++++++++++++++++----- src/sp140/lvgl/lvgl_updates.cpp | 28 +++------- test/test_screenshots/test_screenshots.cpp | 4 +- 4 files changed, 62 insertions(+), 35 deletions(-) diff --git a/inc/sp140/lvgl/lvgl_main_screen.h b/inc/sp140/lvgl/lvgl_main_screen.h index ac8ee00f..f15799cf 100644 --- a/inc/sp140/lvgl/lvgl_main_screen.h +++ b/inc/sp140/lvgl/lvgl_main_screen.h @@ -71,6 +71,8 @@ extern lv_obj_t* climb_rate_fill_sections[12]; // Critical border (used by flash animations) extern lv_obj_t* critical_border; +void setCriticalBorderOpacity(lv_opa_t opacity); +lv_opa_t getCriticalBorderOpacity(); // Helper functions void setAltitudeVisibility(bool visible); diff --git a/src/sp140/lvgl/lvgl_main_screen.cpp b/src/sp140/lvgl/lvgl_main_screen.cpp index bdd9f4f4..f73bcefe 100644 --- a/src/sp140/lvgl/lvgl_main_screen.cpp +++ b/src/sp140/lvgl/lvgl_main_screen.cpp @@ -53,6 +53,26 @@ lv_obj_t* climb_rate_fill_sections[12] = {NULL}; // Critical border (used by flash animations) lv_obj_t* critical_border = NULL; +// The critical border is a transparent parent containing four narrow red +// strips. Changing a strip only invalidates its edge instead of the complete +// 160x128 display. +void setCriticalBorderOpacity(lv_opa_t opacity) { + if (critical_border == NULL) return; + const uint32_t childCount = lv_obj_get_child_count(critical_border); + for (uint32_t i = 0; i < childCount; ++i) { + lv_obj_set_style_bg_opa(lv_obj_get_child(critical_border, i), opacity, + LV_PART_MAIN); + } +} + +lv_opa_t getCriticalBorderOpacity() { + if (critical_border == NULL || lv_obj_get_child_count(critical_border) == 0) { + return LV_OPA_0; + } + return lv_obj_get_style_bg_opa(lv_obj_get_child(critical_border, 0), + LV_PART_MAIN); +} + // Helper function to hide/show all altitude character labels void setAltitudeVisibility(bool visible) { for (int i = 0; i < 7; i++) { @@ -104,6 +124,9 @@ void init_temp_styles(bool darkMode) { // Setup the main screen layout once void setupMainScreen(bool darkMode) { if (main_screen != NULL) { + // critical_border is owned by main_screen and becomes invalid when its + // parent is deleted. Clear the non-owning handle before rebuilding. + critical_border = NULL; lv_obj_delete(main_screen); } @@ -590,19 +613,35 @@ void setupMainScreen(bool darkMode) { lv_obj_move_background(climb_rate_fill_sections[i]); } - // Create critical alert border (initially hidden) - moved from updates file - if (critical_border == NULL) { - critical_border = lv_obj_create(main_screen); - lv_obj_set_size(critical_border, SCREEN_WIDTH, SCREEN_HEIGHT); - lv_obj_set_pos(critical_border, 0, 0); - lv_obj_set_style_border_width(critical_border, 4, LV_PART_MAIN); - lv_obj_set_style_border_color(critical_border, LVGL_RED, LV_PART_MAIN); - lv_obj_set_style_bg_opa(critical_border, LV_OPA_0, LV_PART_MAIN); // Transparent background - lv_obj_set_style_radius(critical_border, 0, LV_PART_MAIN); // Sharp corners - lv_obj_set_style_border_opa(critical_border, LV_OPA_0, LV_PART_MAIN); // Initially invisible border - // Move border to front so it's visible over all other elements - lv_obj_move_foreground(critical_border); + // Create the critical alert border as four narrow edge strips. A full-screen + // bordered object causes LVGL to invalidate and flush the full display on + // every flash, even though only the outer four pixels change. + critical_border = lv_obj_create(main_screen); + lv_obj_set_size(critical_border, SCREEN_WIDTH, SCREEN_HEIGHT); + lv_obj_set_pos(critical_border, 0, 0); + lv_obj_remove_flag(critical_border, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_opa(critical_border, LV_OPA_0, LV_PART_MAIN); + lv_obj_set_style_border_width(critical_border, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(critical_border, 0, LV_PART_MAIN); + + static const int16_t edgeGeometry[4][4] = { + {0, 0, SCREEN_WIDTH, 4}, + {0, SCREEN_HEIGHT - 4, SCREEN_WIDTH, 4}, + {0, 4, 4, SCREEN_HEIGHT - 8}, + {SCREEN_WIDTH - 4, 4, 4, SCREEN_HEIGHT - 8}, + }; + for (const auto& edge : edgeGeometry) { + lv_obj_t* strip = lv_obj_create(critical_border); + lv_obj_set_pos(strip, edge[0], edge[1]); + lv_obj_set_size(strip, edge[2], edge[3]); + lv_obj_remove_flag(strip, LV_OBJ_FLAG_SCROLLABLE); + lv_obj_set_style_bg_color(strip, LVGL_RED, LV_PART_MAIN); + lv_obj_set_style_bg_opa(strip, LV_OPA_0, LV_PART_MAIN); + lv_obj_set_style_border_width(strip, 0, LV_PART_MAIN); + lv_obj_set_style_radius(strip, 0, LV_PART_MAIN); + lv_obj_set_style_pad_all(strip, 0, LV_PART_MAIN); } + lv_obj_move_foreground(critical_border); // Setup alert counter UI elements (circles, labels, and alert text display) setupAlertCounterUI(darkMode); diff --git a/src/sp140/lvgl/lvgl_updates.cpp b/src/sp140/lvgl/lvgl_updates.cpp index 77647cd3..9dea6f29 100644 --- a/src/sp140/lvgl/lvgl_updates.cpp +++ b/src/sp140/lvgl/lvgl_updates.cpp @@ -245,25 +245,19 @@ static void critical_border_flash_timer_cb(lv_timer_t* timer) { // This callback runs within the LVGL task handler, so no mutex needed here. if (critical_border != NULL) { // Toggle opacity: 300ms on (opaque), 700ms off (transparent) - uint8_t current_opa = lv_obj_get_style_border_opa(critical_border, LV_PART_MAIN); + const lv_opa_t current_opa = getCriticalBorderOpacity(); if (current_opa == LV_OPA_100) { - lv_obj_set_style_border_opa(critical_border, LV_OPA_0, LV_PART_MAIN); + setCriticalBorderOpacity(LV_OPA_0); lv_timer_set_period(timer, 700); // Off duration - // Invalidate entire screen when hiding to ensure clean removal of border pixels - lv_obj_invalidate(lv_screen_active()); } else { - lv_obj_set_style_border_opa(critical_border, LV_OPA_100, LV_PART_MAIN); + setCriticalBorderOpacity(LV_OPA_100); lv_timer_set_period(timer, 300); // On duration // Trigger vibration pulse in sync with border "on" if (ENABLE_VIBE) { pulseVibration(300, 200); // 300ms pulse, intensity 200 } - // Invalidate the border area when showing - lv_obj_invalidate(critical_border); } - // Force immediate refresh to minimize tearing - lv_refr_now(lv_display_get_default()); } } @@ -271,7 +265,7 @@ void startCriticalBorderFlash() { if (xSemaphoreTake(lvglMutex, pdMS_TO_TICKS(50)) == pdTRUE) { if (critical_border != NULL && !isFlashingCriticalBorder) { isFlashingCriticalBorder = true; - lv_obj_set_style_border_opa(critical_border, LV_OPA_100, LV_PART_MAIN); // Start visible + setCriticalBorderOpacity(LV_OPA_100); critical_border_flash_timer = lv_timer_create(critical_border_flash_timer_cb, 300, NULL); } xSemaphoreGive(lvglMutex); @@ -285,9 +279,7 @@ void stopCriticalBorderFlash() { critical_border_flash_timer = NULL; } if (critical_border != NULL) { - lv_obj_set_style_border_opa(critical_border, LV_OPA_0, LV_PART_MAIN); - // Invalidate entire screen to ensure clean removal - lv_obj_invalidate(lv_screen_active()); + setCriticalBorderOpacity(LV_OPA_0); } isFlashingCriticalBorder = false; xSemaphoreGive(lvglMutex); @@ -302,11 +294,8 @@ bool isCriticalBorderFlashing() { void startCriticalBorderFlashDirect() { if (critical_border != NULL && !isFlashingCriticalBorder) { isFlashingCriticalBorder = true; - lv_obj_set_style_border_opa(critical_border, LV_OPA_100, LV_PART_MAIN); // Start visible - lv_obj_invalidate(critical_border); // Ensure clean initial draw + setCriticalBorderOpacity(LV_OPA_100); critical_border_flash_timer = lv_timer_create(critical_border_flash_timer_cb, 300, NULL); - // Force immediate refresh for clean start - lv_refr_now(lv_display_get_default()); } } @@ -316,10 +305,7 @@ void stopCriticalBorderFlashDirect() { critical_border_flash_timer = NULL; } if (critical_border != NULL) { - lv_obj_set_style_border_opa(critical_border, LV_OPA_0, LV_PART_MAIN); - lv_obj_invalidate(lv_screen_active()); // Ensure clean removal of border - // Force immediate refresh for clean stop - lv_refr_now(lv_display_get_default()); + setCriticalBorderOpacity(LV_OPA_0); } isFlashingCriticalBorder = false; } diff --git a/test/test_screenshots/test_screenshots.cpp b/test/test_screenshots/test_screenshots.cpp index 3e250f6c..05e95e26 100644 --- a/test/test_screenshots/test_screenshots.cpp +++ b/test/test_screenshots/test_screenshots.cpp @@ -565,7 +565,7 @@ TEST_F(ScreenshotTest, MainScreen_CriticalAlerts_Light) { lv_showAlertTextWithLevel(SensorID::ESC_MOS_Temp, AlertLevel::WARN_HIGH, false); if (critical_border != NULL) { - lv_obj_set_style_border_opa(critical_border, LV_OPA_100, LV_PART_MAIN); + setCriticalBorderOpacity(LV_OPA_100); } emulator_render_frame(); @@ -592,7 +592,7 @@ TEST_F(ScreenshotTest, MainScreen_CriticalAlerts_Dark) { lv_showAlertTextWithLevel(SensorID::ESC_MOS_Temp, AlertLevel::WARN_HIGH, false); if (critical_border != NULL) { - lv_obj_set_style_border_opa(critical_border, LV_OPA_100, LV_PART_MAIN); + setCriticalBorderOpacity(LV_OPA_100); } emulator_render_frame(); From a73d5f4748bb5318c228226e66088f6d6b3075ce Mon Sep 17 00:00:00 2001 From: Paul <36135447+PaulDWhite@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:54:06 -0400 Subject: [PATCH 15/15] Improve BMS coherence and UI boot handling Gate BMS as CONNECTED until a coherent snapshot arrives and add BMS_LINK_TIMEOUT_MS. Add bmsSnapshotCoherent() and bmsTempFrameSeen() helpers and latch first-good-state to avoid spurious pre-data alerts. Defer temp-probe sanitization until a real temp frame and log probe connection transitions. Add a 2s BMS alert grace window so boot/booting-BMS frames don't trigger alarms. Tune high-cell voltage thresholds to avoid zero-reading criticals. LVGL: initialize labels to blank/placeholders and fix charging-icon alignment. Add tests for new BMS behaviors and a Windows mkdir fix; update reference screenshots. --- inc/sp140/bms.h | 41 ++++++++ inc/sp140/monitor_config.h | 12 ++- src/sp140/bms.cpp | 29 +++++- src/sp140/bms_monitors.cpp | 14 ++- src/sp140/lvgl/lvgl_main_screen.cpp | 10 ++ src/sp140/lvgl/lvgl_updates.cpp | 15 +++ src/sp140/main.cpp | 19 +++- src/sp140/simple_monitor.cpp | 37 ++++++- .../reference/main_charging_dark.bmp | Bin 61494 -> 61494 bytes .../reference/main_charging_light.bmp | Bin 61494 -> 61494 bytes test/test_screenshots/test_screenshots.cpp | 4 + .../test_simplemonitor/test_simplemonitor.cpp | 94 ++++++++++++++++++ 12 files changed, 262 insertions(+), 13 deletions(-) diff --git a/inc/sp140/bms.h b/inc/sp140/bms.h index 63605ff0..fa2a5c5c 100644 --- a/inc/sp140/bms.h +++ b/inc/sp140/bms.h @@ -18,6 +18,47 @@ constexpr uint8_t BMS_CELL_PROBE_COUNT = 4; constexpr uint8_t BMS_MAX_IGNORED_DISCONNECTED_PROBES = 2; +// How long the BMS link may go silent before it counts as disconnected. Must +// stay well clear of the bmsTask poll period (100 ms): with the library's 100 ms +// default there is zero timing margin, so a single skipped CAN drain — e.g. the +// SPI-mutex bail in updateBMSData() — publishes a spurious disconnect, which +// force-clears every BMS alert. +constexpr unsigned long BMS_LINK_TIMEOUT_MS = 500; + +// A just-connected BMS spreads its state across several CAN frames (basic +// info 1: pack voltage/SOC/current, basic info 2: cell voltages, 0x18B4: +// temperatures). Treating the link as CONNECTED on the first frame let +// monitors and the UI see zero-initialized fields — a 0.000 V "highest cell" +// fired a critical displayed as cell-voltage-high at every boot. Only report +// CONNECTED once both basic frames have populated the snapshot. +// +// Deliberately tests HIGHEST cell voltage, not lowest: both are written by the +// same frame (0x18FE28F4), so either proves that frame arrived — but a shorted +// cell or broken sense lead drives the LOWEST cell to 0.000 V on a healthy, +// still-transmitting pack. Keying on lowest would read that catastrophic fault +// as "no data" and drop the whole BMS to NOT_CONNECTED, suppressing the very +// low-cell and voltage-differential alerts that exist to catch it. No +// single-cell fault can pull the highest cell of a live pack below this floor. +// Callers must latch the result (see bmsTask) so this stays a boot-ordering +// gate and never becomes a runtime state. +inline bool bmsSnapshotCoherent(const STR_BMS_TELEMETRY_140& t) { + return t.battery_voltage > 5.0f && // basic info 1 received + t.highest_cell_voltage > 0.5f; // basic info 2 received +} + +// True once at least one reading from the BMS temperature frame has been +// parsed. Before that frame arrives every probe reads NaN — indistinguishable +// from "all probes disconnected" — and the disconnect sentinel policy below +// must not run, or it fires -40 °C criticals for T3/T4 at every boot. +inline bool bmsTempFrameSeen(float mosTemp, float balanceTemp, + const float cellTemps[BMS_CELL_PROBE_COUNT]) { + if (!isnan(mosTemp) || !isnan(balanceTemp)) return true; + for (uint8_t i = 0; i < BMS_CELL_PROBE_COUNT; i++) { + if (!isnan(cellTemps[i])) return true; + } + return false; +} + inline void sanitizeCellProbeTemps( const float temps[BMS_CELL_PROBE_COUNT], float out[BMS_CELL_PROBE_COUNT]) { diff --git a/inc/sp140/monitor_config.h b/inc/sp140/monitor_config.h index d80b37dd..9fa7d8b8 100644 --- a/inc/sp140/monitor_config.h +++ b/inc/sp140/monitor_config.h @@ -14,12 +14,22 @@ static const Thresholds motorTempThresholds = {.warnLow = -20, .warnHigh = 105, // -- BMS Thresholds -- static const Thresholds bmsTempThresholds = {.warnLow = -10, .warnHigh = 50, .critLow = -15, .critHigh = 60, .hysteresis = 1.0f}; static const Thresholds bmsCellTempThresholds = {.warnLow = -10, .warnHigh = 50, .critLow = -15, .critHigh = 56, .hysteresis = 1.0f}; -static const Thresholds bmsHighCellVoltageThresholds = {.warnLow = 0.0, .warnHigh = 4.19, .critLow = 0.0, .critHigh = 4.20}; +// High-cell monitor is high-side only: SensorMonitor fires at v <= critLow, so +// the old critLow of 0.0 turned a zero-initialized (pre-data) reading into a +// critical that displayed as "BC-CV-H". Low readings belong to the low monitor. +static const Thresholds bmsHighCellVoltageThresholds = {.warnLow = -1.0, .warnHigh = 4.19, .critLow = -1.0, .critHigh = 4.20}; static const Thresholds bmsLowCellVoltageThresholds = {.warnLow = 3.2, .warnHigh = 4.5, .critLow = 3.0, .critHigh = 4.8}; static const Thresholds bmsSOCThresholds = {.warnLow = 15.0, .warnHigh = 101.0, .critLow = 5.0, .critHigh = 110.0}; static const Thresholds bmsTotalVoltageThresholds = {.warnLow = 79.2, .warnHigh = 100.4, .critLow = 69.6, .critHigh = 100.8}; static const Thresholds bmsVoltageDifferentialThresholds = {.warnLow = -1.0, .warnHigh = 0.2, .critLow = -2.0, .critHigh = 0.4}; +// Hold BMS monitors off for this long after the FIRST NOT_CONNECTED -> +// CONNECTED transition: a BMS that is itself still booting can emit +// sentinel/garbage readings in its first frames, and alarming on those caused +// spurious criticals at power-on and on hot-plug. Applied once per power-on — +// see checkAllSensorsWithData for why re-arming it per reconnect is unsafe. +static const uint32_t BMS_ALERT_GRACE_MS = 2000; + // -- Altimeter Thresholds -- static const Thresholds baroTempThresholds = {.warnLow = 0, .warnHigh = 50, .critLow = -10, .critHigh = 80, .hysteresis = 1.0f}; diff --git a/src/sp140/bms.cpp b/src/sp140/bms.cpp index ce552c34..922f99af 100644 --- a/src/sp140/bms.cpp +++ b/src/sp140/bms.cpp @@ -128,16 +128,37 @@ void updateBMSData() { bms_can->getTemperature(4), bms_can->getTemperature(5) }; + // Latched: once a temperature frame has ever been parsed, keep applying the + // disconnect policy. Re-testing the live readings every cycle would mean a + // later all-NaN frame (BMS temperature subsystem failed, or the BMS rebooting + // and broadcasting zeros) silently disables the too-many-probes-disconnected + // critical — total loss of the temperature path must not be silent. + static bool tempFrameSeen = false; + if (!tempFrameSeen && + bmsTempFrameSeen(bmsTelemetryData.mos_temperature, + bmsTelemetryData.balance_temperature, cellTemps)) { + tempFrameSeen = true; + } + float sanitizedCellTemps[BMS_CELL_PROBE_COUNT]; - sanitizeCellProbeTemps(cellTemps, sanitizedCellTemps); + if (tempFrameSeen) { + sanitizeCellProbeTemps(cellTemps, sanitizedCellTemps); + // Emit transition logs to help field-debug intermittent probe wiring + // issues. Only once real temp data exists — otherwise every boot would + // log a spurious "reconnected" burst when the first frame arrives. + logBmsCellProbeConnectionTransitions(sanitizedCellTemps); + } else { + // No temperature frame parsed yet: pass NaN through so temp monitors + // stay silent instead of firing the -40 disconnect sentinel. + for (uint8_t i = 0; i < BMS_CELL_PROBE_COUNT; i++) { + sanitizedCellTemps[i] = cellTemps[i]; + } + } bmsTelemetryData.t1_temperature = sanitizedCellTemps[0]; bmsTelemetryData.t2_temperature = sanitizedCellTemps[1]; bmsTelemetryData.t3_temperature = sanitizedCellTemps[2]; bmsTelemetryData.t4_temperature = sanitizedCellTemps[3]; - // Emit transition logs to help field-debug intermittent probe wiring issues. - logBmsCellProbeConnectionTransitions(sanitizedCellTemps); - // Library already excludes disconnected probes from high/low temps bmsTelemetryData.highest_temperature = bms_can->getHighestTemperature(); bmsTelemetryData.lowest_temperature = bms_can->getLowestTemperature(); diff --git a/src/sp140/bms_monitors.cpp b/src/sp140/bms_monitors.cpp index 023e5648..48f37b9d 100644 --- a/src/sp140/bms_monitors.cpp +++ b/src/sp140/bms_monitors.cpp @@ -15,11 +15,15 @@ extern bool bmsCanInitialized; // A freshly-connected BMS reports zero-initialized fields (soc=0, all voltages // 0, discharge-MOS=false) until its first data frames are parsed — which looks // exactly like a dead, critically-low pack and fired spurious CRIT_LOW / MOS-off -// alarms on every connect. Treat readings as "no data" until the pack-voltage -// frame proves real telemetry has arrived. A real pack (even deeply discharged) -// sits far above this 5 V floor, so this never masks a genuine low condition. -// SensorMonitor skips NaN; the discharge-MOS monitor reads its non-alerting -// state (true) until data is valid. +// alarms on every connect. bmsTask now holds bmsState at NOT_CONNECTED until +// the snapshot is coherent (see bmsSnapshotCoherent), so this guard is +// defense-in-depth against future changes to that gating. SensorMonitor skips +// NaN; the discharge-MOS monitor reads its non-alerting state (true) until data +// is valid. +// +// Deliberately checks pack voltage only. A per-cell floor here would NaN out +// every voltage reader on a pack with one collapsed cell — exactly the fault +// the low-cell and voltage-differential monitors exist to report. static constexpr float BMS_MIN_VALID_PACK_V = 5.0f; static inline bool bmsDataValid() { return monitoringBmsData.battery_voltage > BMS_MIN_VALID_PACK_V; diff --git a/src/sp140/lvgl/lvgl_main_screen.cpp b/src/sp140/lvgl/lvgl_main_screen.cpp index f73bcefe..feff8589 100644 --- a/src/sp140/lvgl/lvgl_main_screen.cpp +++ b/src/sp140/lvgl/lvgl_main_screen.cpp @@ -165,6 +165,9 @@ void setupMainScreen(bool darkMode) { darkMode ? LVGL_WHITE : LVGL_BLACK, 0); // Center-align battery percentage since it's in the middle lv_obj_set_style_text_align(battery_label, LV_TEXT_ALIGN_CENTER, 0); + // Blank until the first update writes real content — a label with no text + // set renders LVGL's default "Text", which flashed on-screen at boot. + lv_label_set_text(battery_label, ""); // Left voltage label voltage_left_label = lv_label_create(main_screen); @@ -172,6 +175,7 @@ void setupMainScreen(bool darkMode) { lv_obj_set_style_text_font(voltage_left_label, &lv_font_montserrat_12, 0); // Much smaller font for voltage lv_obj_set_style_text_color(voltage_left_label, darkMode ? LVGL_WHITE : LVGL_BLACK, 0); + lv_label_set_text(voltage_left_label, ""); // Blank until first update // Right voltage label voltage_right_label = lv_label_create(main_screen); @@ -181,6 +185,7 @@ void setupMainScreen(bool darkMode) { darkMode ? LVGL_WHITE : LVGL_BLACK, 0); // Right-align right voltage so numbers grow from right to left lv_obj_set_style_text_align(voltage_right_label, LV_TEXT_ALIGN_RIGHT, 0); + lv_label_set_text(voltage_right_label, ""); // Blank until first update // Middle section - power display with individual character positions // Layout: [tens][ones][.][tenths] kW (4 positions for numbers + decimal) @@ -232,6 +237,7 @@ void setupMainScreen(bool darkMode) { darkMode ? LVGL_WHITE : LVGL_BLACK, 0); // Ensure text within the label is centered lv_obj_set_style_text_align(perf_mode_label, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(perf_mode_label, ""); // Blank until first update // Armed time label - adjust position now that there's no bluetooth icon armed_time_label = lv_label_create(main_screen); @@ -241,6 +247,7 @@ void setupMainScreen(bool darkMode) { darkMode ? LVGL_WHITE : LVGL_BLACK, 0); // Right-align time so numbers grow from right to left lv_obj_set_style_text_align(armed_time_label, LV_TEXT_ALIGN_RIGHT, 0); + lv_label_set_text(armed_time_label, ""); // Blank until first update // Bottom section - altitude and temperatures // Create individual character position labels for fixed positioning @@ -340,6 +347,7 @@ void setupMainScreen(bool darkMode) { lv_obj_set_style_pad_top(batt_temp_label, 1, 0); lv_obj_set_style_pad_bottom(batt_temp_label, 1, 0); lv_obj_set_style_text_align(batt_temp_label, LV_TEXT_ALIGN_RIGHT, 0); // Right align temperature values + lv_label_set_text(batt_temp_label, "-"); // "No data" placeholder until first update // Create letter label for B batt_letter_label = lv_label_create(main_screen); @@ -372,6 +380,7 @@ void setupMainScreen(bool darkMode) { lv_obj_set_style_pad_top(esc_temp_label, 1, 0); lv_obj_set_style_pad_bottom(esc_temp_label, 1, 0); lv_obj_set_style_text_align(esc_temp_label, LV_TEXT_ALIGN_RIGHT, 0); // Right align temperature values + lv_label_set_text(esc_temp_label, "-"); // "No data" placeholder until first update // Create letter label for E esc_letter_label = lv_label_create(main_screen); @@ -411,6 +420,7 @@ void setupMainScreen(bool darkMode) { lv_obj_set_style_pad_top(motor_temp_label, 1, 0); lv_obj_set_style_pad_bottom(motor_temp_label, 1, 0); lv_obj_set_style_text_align(motor_temp_label, LV_TEXT_ALIGN_RIGHT, 0); // Right align temperature values + lv_label_set_text(motor_temp_label, "-"); // "No data" placeholder until first update // Create letter label for M motor_letter_label = lv_label_create(main_screen); diff --git a/src/sp140/lvgl/lvgl_updates.cpp b/src/sp140/lvgl/lvgl_updates.cpp index 9dea6f29..55be4da4 100644 --- a/src/sp140/lvgl/lvgl_updates.cpp +++ b/src/sp140/lvgl/lvgl_updates.cpp @@ -57,12 +57,14 @@ static int8_t lastVoltLeftMode = -1; // 0 = BMS, 1 = "NO BMS", 2 = none static uint8_t lastBattTempLevel = 0xFF; // 0 = normal, 1 = warning, 2 = critical static uint8_t lastEscTempLevel = 0xFF; static uint8_t lastMotorTempLevel = 0xFF; +static int32_t lastChargingIconBattW = -1; // battery_label width the icon was aligned to void resetLvglUpdateCache() { lastVoltLeftMode = -1; lastBattTempLevel = 0xFF; lastEscTempLevel = 0xFF; lastMotorTempLevel = 0xFF; + lastChargingIconBattW = -1; } // Flash timer globals - definitions @@ -938,6 +940,19 @@ void updateLvglMainScreen( // Update Charging Icon Visibility - only when BMS is connected and reports charging if (charging_icon_img != NULL) { // Check object exists + // Re-align to the live battery label. lv_obj_align_to() stamps an absolute + // position once and keeps no binding to the base object, but battery_label + // is centre-aligned and re-centres itself every time its text changes - so + // a position stamped at setup (when the label is still empty) leaves the + // icon sitting on top of the percentage digits. Only re-align when the + // width actually changed; the call invalidates the icon. + if (battery_label != NULL) { + const int32_t battW = lv_obj_get_width(battery_label); + if (battW != lastChargingIconBattW) { + lv_obj_align_to(charging_icon_img, battery_label, LV_ALIGN_OUT_RIGHT_MID, 3, 0); + lastChargingIconBattW = battW; + } + } bool showChargingIcon = (bmsTelemetry.bmsState == TelemetryState::CONNECTED) && bmsTelemetry.is_charging; if (showChargingIcon) { lv_obj_remove_flag(charging_icon_img, LV_OBJ_FLAG_HIDDEN); diff --git a/src/sp140/main.cpp b/src/sp140/main.cpp index ee43dc87..4bf98b8e 100644 --- a/src/sp140/main.cpp +++ b/src/sp140/main.cpp @@ -493,8 +493,23 @@ void bmsTask(void *pvParameters) { for (;;) { if (bmsCanInitialized) { updateBMSData(); - if (bms_can->isConnected()) { - bmsTelemetryData.bmsState = TelemetryState::CONNECTED; + // Coherent-snapshot gate: report CONNECTED only after the pack-voltage + // and cell-voltage frames have both arrived, so monitors and the UI + // never see the half-populated data a fresh link starts with. + // + // Latched on purpose. This orders the first few frames at link-up and + // has no legitimate work to do afterwards — re-evaluating it every cycle + // would let a live-but-faulted pack (one collapsed cell) read as "no + // data" and silently drop the BMS out of the alert system entirely. + // Genuine link loss is still caught by isConnected() below. + static bool bmsSnapshotEverCoherent = false; + if (bms_can->isConnected(BMS_LINK_TIMEOUT_MS)) { + if (!bmsSnapshotEverCoherent && bmsSnapshotCoherent(bmsTelemetryData)) { + bmsSnapshotEverCoherent = true; + } + bmsTelemetryData.bmsState = bmsSnapshotEverCoherent + ? TelemetryState::CONNECTED + : TelemetryState::NOT_CONNECTED; } else { bmsTelemetryData.bmsState = TelemetryState::NOT_CONNECTED; } diff --git a/src/sp140/simple_monitor.cpp b/src/sp140/simple_monitor.cpp index 6c7a2295..50752e4a 100644 --- a/src/sp140/simple_monitor.cpp +++ b/src/sp140/simple_monitor.cpp @@ -282,6 +282,18 @@ const char* sensorIDToAbbreviationWithLevel(SensorID id, AlertLevel level) { } return "BC-T4"; // Default + // Cell voltage monitors: suffix reflects the fired direction, not the + // monitor's name. A low-side fire on the high monitor used to display + // "BC-CV-H" for a 0 V (no data) reading. + case SensorID::BMS_High_Cell_Voltage: + case SensorID::BMS_Low_Cell_Voltage: + if (level == AlertLevel::WARN_HIGH || level == AlertLevel::CRIT_HIGH) { + return "BC-CV-H"; // Cell voltage high + } else if (level == AlertLevel::WARN_LOW || level == AlertLevel::CRIT_LOW) { + return "BC-CV-L"; // Cell voltage low + } + return "BC-CV"; // Default + // For all other sensors, use the standard abbreviation default: return sensorIDToAbbreviation(id); @@ -354,14 +366,37 @@ void checkAllSensorsWithData(const STR_ESC_TELEMETRY_140& escData, } } + // Grace period: a BMS that is itself still booting can emit sentinel/garbage + // readings in its first frames. Hold BMS monitors off briefly after the FIRST + // connect so half-baked data can never fire alerts. + // + // One-shot by design. Re-arming on every reconnect edge would mean each + // transient link drop — a chattering connector, or the SPI-mutex bail in + // updateBMSData() — blanks all BMS monitoring for another 2 s, right after + // the disconnect edge above already force-cleared every BMS alert. A real + // in-flight critical would vanish from the screen and could not be re-raised; + // flaps recurring faster than the grace would suppress it indefinitely. The + // booting-BMS garbage this protects against only happens once per power-on. + static uint32_t bmsGraceStartMs = 0; + static bool bmsGraceStarted = false; + static bool bmsGraceExpired = false; if (!prevBmsConnected && bmsConnected) { USBSerial.println("[MONITOR] BMS reconnected - resetting BMS monitor states"); + if (!bmsGraceStarted) { + bmsGraceStartMs = millis(); + bmsGraceStarted = true; + } for (auto* monitor : monitors) { if (monitor && monitor->getCategory() == SensorCategory::BMS) { monitor->resetState(); } } } + if (bmsGraceStarted && !bmsGraceExpired && + (millis() - bmsGraceStartMs >= BMS_ALERT_GRACE_MS)) { + bmsGraceExpired = true; + } + const bool bmsMonitorsArmed = bmsConnected && bmsGraceExpired; // Update previous connection states prevEscConnected = escConnected; @@ -383,7 +418,7 @@ void checkAllSensorsWithData(const STR_ESC_TELEMETRY_140& escData, shouldRun = escConnected; break; case SensorCategory::BMS: - shouldRun = bmsConnected; + shouldRun = bmsMonitorsArmed; break; case SensorCategory::ALTIMETER: case SensorCategory::INTERNAL: diff --git a/test/test_screenshots/reference/main_charging_dark.bmp b/test/test_screenshots/reference/main_charging_dark.bmp index 83b8466d825e5dc83c37b034b4cf66a9d7f3a671..37b3097604f02a0ccaf9ca4a11cb42e7a84eaae9 100644 GIT binary patch delta 293 zcmdn?fO*>k<_+mPC$D{^IB7N8=DamK87JR6&o((=x7_CJ-Fpxm@4ZMI?fqa5=VpW3 zOpKE+oMxN+j*9RA4@u1aj~JAuT> zJGBa=#{lFAzcWDgzKdYC-nq44@y*;9Hh={;zrMH`B699BP~_fukYRG0S6|%*;()A? s+uVH}iIaU3Yz@e1a+|$xBXP9vB6*4XJ`!j3_C?H-H#`Cc!J2Mv004WMo&W#< delta 284 zcmdn?fO*>k<_+mPH!pwmjB&EpZpF=cyLU29o_(2fvhQxW&3=24IC}fR9G=M^4oGk2 zJBY-2cL>bkpWJ&@V)MBpNSt-YfSl^9>YMvcKpAoP#ARpo}zWZQDLO6MA_A^di^$6&91fzo+0Kxf_Hvj+t diff --git a/test/test_screenshots/reference/main_charging_light.bmp b/test/test_screenshots/reference/main_charging_light.bmp index 3a878544956911a5d9a17d6ed6f6972a906473a3..9bee7237551078727b914b6cfb2f90ae5ddf2eeb 100644 GIT binary patch delta 345 zcmdn?fO*>k<_#G;C$D>?Flja0=Dan#7$@I9&o+7c5%Ec@IXCC+M&f88-KpMP?|1OCYZt2s9Joj?=FJGC0*B7KnS z{LTQ``!9modgs=G#W!k<_#G;H?Mf~jB&E}ZiUTxyLT~8o^zRVvfpmm&3=24IC}fR9G=N~x1~1o z9Yo^1I|SzNPwu-azWLk{B+j~HKu*n7watAepp3jzt3l=)ZT3F14#ZHL?0-pUv(C8< z5K+Dha8bd{?=EfxiRy1YcNxg2xvIZ;-PJ815s*!?oBOUKaq@0%2g!h32XyysB#z!) au!A5z;=7N;$y;-Paq{X%Ku;qW9ozt 0.000 V on a healthy, still-transmitting +// pack) read as "no data", dropping the whole BMS to NOT_CONNECTED and +// silencing the low-cell and voltage-differential alerts that exist to catch +// exactly that fault. +TEST(SimpleMonitor, BmsSnapshotCoherentSurvivesCollapsedCell) { + STR_BMS_TELEMETRY_140 t = {}; + t.battery_voltage = 88.0f; + t.highest_cell_voltage = 3.95f; + + t.lowest_cell_voltage = 0.0f; // cell shorted / sense lead open + EXPECT_TRUE(bmsSnapshotCoherent(t)); + + t.lowest_cell_voltage = 0.30f; // collapsing cell + EXPECT_TRUE(bmsSnapshotCoherent(t)); +} + +// The low-cell monitor must still fire for a collapsed cell once the snapshot +// is considered coherent — this is the alert the gate must never suppress. +TEST(SimpleMonitor, BMSLowCellVoltageAlertsOnCollapsedCell) { + FakeLogger logger; + float lowestCell = 3.9f; + + SensorMonitor lowCellMon( + SensorID::BMS_Low_Cell_Voltage, + SensorCategory::BMS, + bmsLowCellVoltageThresholds, + [&]() { return lowestCell; }, + &logger); + + lowCellMon.check(); // healthy + EXPECT_TRUE(logger.entries.empty()); + + lowestCell = 0.30f; // collapsed cell + lowCellMon.check(); + ASSERT_EQ(logger.entries.size(), 1u); + EXPECT_EQ(logger.entries.back().lvl, AlertLevel::CRIT_LOW); +} + +// Before the first temperature frame, every probe reads NaN — that must be +// treated as "no data yet", not "all probes disconnected". +TEST(SimpleMonitor, BmsTempFrameSeenDetection) { + const float allNaN[BMS_CELL_PROBE_COUNT] = {NAN, NAN, NAN, NAN}; + EXPECT_FALSE(bmsTempFrameSeen(NAN, NAN, allNaN)); + + // Internal BMS sensors prove the frame arrived even with all probes out. + EXPECT_TRUE(bmsTempFrameSeen(32.0f, NAN, allNaN)); + EXPECT_TRUE(bmsTempFrameSeen(NAN, 30.0f, allNaN)); + + const float oneProbe[BMS_CELL_PROBE_COUNT] = {NAN, 27.0f, NAN, NAN}; + EXPECT_TRUE(bmsTempFrameSeen(NAN, NAN, oneProbe)); +} + TEST(SimpleMonitor, BMSCellProbeDisconnectTransitionTriggersAndClearsAlert) { FakeLogger logger; float sanitizedTemps[BMS_CELL_PROBE_COUNT] = {NAN, NAN, NAN, NAN};