diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index 77b3c3b..371dd99 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## Unreleased + +### Added +- ESP32-S3 dashboard Battery Detail screen to the left of the main dashboard. +- Swipe navigation between Battery Detail and Main Dashboard pages. + ## 0.5.0 - 2026-06-08 ### Added diff --git a/docs/dashboard-esp32s3.md b/docs/dashboard-esp32s3.md index cea3475..cc1ab86 100644 --- a/docs/dashboard-esp32s3.md +++ b/docs/dashboard-esp32s3.md @@ -90,6 +90,9 @@ Then interactive: - Relay state summary ### Battery Detail +- Implemented as a left-side page from the main dashboard. +- Swipe right from the main dashboard to open it. +- Swipe left from Battery Detail to return to the main dashboard. - SOC - Voltage - Current diff --git a/firmware/esp32-s3-dashboard/dashboard_config.h b/firmware/esp32-s3-dashboard/dashboard_config.h index b7a8cca..9e1cc7f 100644 --- a/firmware/esp32-s3-dashboard/dashboard_config.h +++ b/firmware/esp32-s3-dashboard/dashboard_config.h @@ -1,6 +1,6 @@ #pragma once -static const char *DASHBOARD_VERSION = "0.1.77-remove-wifi-bars"; +static const char *DASHBOARD_VERSION = "0.1.78-battery-detail-screen"; // Update these if your Cargo ESP AP credentials are different. static const char *CARGO_WIFI_SSID = "OverlandController"; diff --git a/firmware/esp32-s3-dashboard/esp32-s3-dashboard.ino b/firmware/esp32-s3-dashboard/esp32-s3-dashboard.ino index 590c8eb..2945bc5 100644 --- a/firmware/esp32-s3-dashboard/esp32-s3-dashboard.ino +++ b/firmware/esp32-s3-dashboard/esp32-s3-dashboard.ino @@ -30,6 +30,33 @@ static lv_obj_t *system_status_label = nullptr; static lv_obj_t *cargo_status_dot = nullptr; static lv_obj_t *outside_status_temp_label = nullptr; +enum DashboardScreen { + SCREEN_BATTERY_DETAIL = 0, + SCREEN_OVERVIEW = 1, +}; + +static DashboardScreen current_dashboard_screen = SCREEN_OVERVIEW; +static lv_obj_t *battery_detail_soc_label = nullptr; +static lv_obj_t *battery_detail_status_label = nullptr; +static lv_obj_t *battery_detail_voltage_label = nullptr; +static lv_obj_t *battery_detail_current_label = nullptr; +static lv_obj_t *battery_detail_power_label = nullptr; +static lv_obj_t *battery_detail_remaining_label = nullptr; +static lv_obj_t *battery_detail_capacity_label = nullptr; +static lv_obj_t *battery_detail_temp_label = nullptr; +static lv_obj_t *battery_detail_delta_label = nullptr; +static lv_obj_t *battery_detail_estimate_label = nullptr; +static String last_battery_detail_soc_text; +static String last_battery_detail_status_text; +static String last_battery_detail_voltage_text; +static String last_battery_detail_current_text; +static String last_battery_detail_power_text; +static String last_battery_detail_remaining_text; +static String last_battery_detail_capacity_text; +static String last_battery_detail_temp_text; +static String last_battery_detail_delta_text; +static String last_battery_detail_estimate_text; + static lv_obj_t *vehicle_coolant_meter = nullptr; static lv_meter_indicator_t *vehicle_coolant_needle = nullptr; static lv_obj_t *vehicle_coolant_value_label = nullptr; @@ -510,11 +537,164 @@ static void system_card_event_cb(lv_event_t *event) static void create_overland_overview_screen(); +static void create_battery_detail_screen(); +static void show_dashboard_screen(DashboardScreen screen); +static void update_battery_detail_widgets(); static void show_boot_screen(); static void show_diagnostics_overlay(); static void close_diagnostics_overlay(); static void update_diagnostics_overlay(); + +static String signed_float_text(float value, int decimals, const char *suffix) +{ + String text; + if (value > 0.0) text += "+"; + text += String(value, decimals); + text += suffix; + return text; +} + +static void dashboard_root_event_cb(lv_event_t *event) +{ + if (lv_event_get_code(event) != LV_EVENT_GESTURE) return; + lv_indev_t *indev = lv_indev_get_act(); + if (indev == nullptr) return; + + lv_dir_t dir = lv_indev_get_gesture_dir(indev); + if (current_dashboard_screen == SCREEN_OVERVIEW && dir == LV_DIR_RIGHT) { + show_dashboard_screen(SCREEN_BATTERY_DETAIL); + } else if (current_dashboard_screen == SCREEN_BATTERY_DETAIL && dir == LV_DIR_LEFT) { + show_dashboard_screen(SCREEN_OVERVIEW); + } +} + +static void make_detail_label(lv_obj_t *parent, lv_obj_t **label, const char *text, const lv_font_t *font, lv_color_t color, int width, lv_align_t align, int x, int y, lv_text_align_t text_align = LV_TEXT_ALIGN_LEFT) +{ + *label = lv_label_create(parent); + lv_label_set_text(*label, text); + lv_obj_set_style_text_color(*label, color, 0); + lv_obj_set_style_text_font(*label, font, 0); + lv_obj_set_width(*label, width); + lv_obj_set_style_text_align(*label, text_align, 0); + lv_label_set_long_mode(*label, LV_LABEL_LONG_WRAP); + lv_obj_align(*label, align, x, y); +} + +static void update_battery_detail_widgets() +{ + if (battery_detail_soc_label == nullptr) return; + + String soc_text = status_model.soc >= 0 ? String(status_model.soc) + "%" : "--%"; + String status_text = status_model.bms_connected ? "BMS CONNECTED" : "BMS OFFLINE"; + String voltage_text = String(status_model.voltage, 2) + " V"; + String current_text = signed_float_text(status_model.current, 2, " A"); + String power_text = signed_float_text(status_model.voltage * status_model.current, 0, " W"); + String remaining_text = String(status_model.remaining_ah, 1) + " Ah"; + String capacity_text = String(status_model.capacity_ah, 1) + " Ah"; + String temp_text = String(status_model.battery_temp_f, 1) + "°F"; + String delta_text = status_model.cell_delta_mv >= 0 ? String(status_model.cell_delta_mv) + " mV" : "-- mV"; + + String estimate_text = "Idle"; + if (status_model.current > 0.05 && status_model.capacity_ah > 0.0) { + estimate_text = "Full in " + format_hours((status_model.capacity_ah - status_model.remaining_ah) / status_model.current); + } else if (status_model.current < -0.05 && status_model.remaining_ah > 0.0) { + estimate_text = "ETR " + format_hours(status_model.remaining_ah / abs(status_model.current)); + } + + set_label_text_if_changed(battery_detail_soc_label, last_battery_detail_soc_text, soc_text); + set_label_text_if_changed(battery_detail_status_label, last_battery_detail_status_text, status_text); + set_label_text_if_changed(battery_detail_voltage_label, last_battery_detail_voltage_text, voltage_text); + set_label_text_if_changed(battery_detail_current_label, last_battery_detail_current_text, current_text); + set_label_text_if_changed(battery_detail_power_label, last_battery_detail_power_text, power_text); + set_label_text_if_changed(battery_detail_remaining_label, last_battery_detail_remaining_text, remaining_text); + set_label_text_if_changed(battery_detail_capacity_label, last_battery_detail_capacity_text, capacity_text); + set_label_text_if_changed(battery_detail_temp_label, last_battery_detail_temp_text, temp_text); + set_label_text_if_changed(battery_detail_delta_label, last_battery_detail_delta_text, delta_text); + set_label_text_if_changed(battery_detail_estimate_label, last_battery_detail_estimate_text, estimate_text); +} + +static lv_obj_t *create_battery_metric_tile(lv_obj_t *parent, const char *title, lv_obj_t **value_label, int x, int y) +{ + lv_obj_t *tile = lv_obj_create(parent); + lv_obj_set_size(tile, 292, 132); + lv_obj_align(tile, LV_ALIGN_TOP_LEFT, x, y); + lv_obj_set_style_radius(tile, 18, 0); + lv_obj_set_style_bg_color(tile, lv_color_hex(0x17202A), 0); + lv_obj_set_style_border_color(tile, lv_color_hex(0x2F3A46), 0); + lv_obj_set_style_border_width(tile, 2, 0); + lv_obj_set_style_pad_all(tile, 0, 0); + lv_obj_clear_flag(tile, LV_OBJ_FLAG_SCROLLABLE); + + lv_obj_t *title_label = lv_label_create(tile); + lv_label_set_text(title_label, title); + lv_obj_set_style_text_color(title_label, lv_color_hex(0xB8C0C8), 0); + lv_obj_set_style_text_font(title_label, &lv_font_montserrat_22, 0); + lv_obj_set_width(title_label, 260); + lv_obj_align(title_label, LV_ALIGN_TOP_LEFT, 16, 14); + + make_detail_label(tile, value_label, "--", &lv_font_montserrat_30, lv_color_hex(0xFFFFFF), 260, LV_ALIGN_TOP_LEFT, 16, 58); + return tile; +} + +static void create_battery_detail_screen() +{ + lv_obj_t *screen = lv_scr_act(); + lv_obj_clean(screen); + current_dashboard_screen = SCREEN_BATTERY_DETAIL; + + lv_obj_set_style_bg_color(screen, lv_color_hex(0x101418), 0); + lv_obj_add_event_cb(screen, dashboard_root_event_cb, LV_EVENT_GESTURE, nullptr); + + lv_obj_t *system_card = create_card(screen, "", 960, 48, LV_ALIGN_TOP_MID, 0, 12); + lv_obj_add_flag(system_card, LV_OBJ_FLAG_CLICKABLE); + lv_obj_add_event_cb(system_card, system_card_event_cb, LV_EVENT_CLICKED, nullptr); + + lv_obj_t *title_label = nullptr; + make_detail_label(system_card, &title_label, "BATTERY DETAIL", &lv_font_montserrat_26, lv_color_hex(0xDDE3EA), 360, LV_ALIGN_LEFT_MID, 22, 0); + + lv_obj_t *page_dots = lv_label_create(system_card); + lv_label_set_text(page_dots, "● ○"); + lv_obj_set_style_text_color(page_dots, lv_color_hex(0xB8C0C8), 0); + lv_obj_set_width(page_dots, 120); + lv_obj_set_style_text_align(page_dots, LV_TEXT_ALIGN_RIGHT, 0); + lv_obj_align(page_dots, LV_ALIGN_RIGHT_MID, -10, 0); + + lv_obj_t *hero_card = create_card(screen, "", 300, 500, LV_ALIGN_TOP_LEFT, 32, 78); + make_detail_label(hero_card, &battery_detail_status_label, "BMS --", &lv_font_montserrat_22, lv_color_hex(0xB8C0C8), 260, LV_ALIGN_TOP_MID, 0, 30, LV_TEXT_ALIGN_CENTER); + make_detail_label(hero_card, &battery_detail_soc_label, "--%", &lv_font_montserrat_48, lv_color_hex(0xFFFFFF), 260, LV_ALIGN_TOP_MID, 0, 126, LV_TEXT_ALIGN_CENTER); + make_detail_label(hero_card, &battery_detail_estimate_label, "--", &lv_font_montserrat_30, lv_color_hex(0xDDE3EA), 260, LV_ALIGN_TOP_MID, 0, 236, LV_TEXT_ALIGN_CENTER); + + lv_obj_t *hint_label = nullptr; + make_detail_label(hero_card, &hint_label, "Swipe left for main dashboard", &lv_font_montserrat_20, lv_color_hex(0x7D8996), 260, LV_ALIGN_BOTTOM_MID, 0, -28, LV_TEXT_ALIGN_CENTER); + + create_battery_metric_tile(screen, "Voltage", &battery_detail_voltage_label, 356, 78); + create_battery_metric_tile(screen, "Current", &battery_detail_current_label, 670, 78); + create_battery_metric_tile(screen, "Power", &battery_detail_power_label, 356, 230); + create_battery_metric_tile(screen, "Remaining", &battery_detail_remaining_label, 670, 230); + create_battery_metric_tile(screen, "Capacity", &battery_detail_capacity_label, 356, 382); + create_battery_metric_tile(screen, "Battery Temp", &battery_detail_temp_label, 670, 382); + + lv_obj_t *delta_card = create_card(screen, "", 606, 70, LV_ALIGN_BOTTOM_MID, 177, -22); + lv_obj_t *delta_title = nullptr; + make_detail_label(delta_card, &delta_title, "Cell delta", &lv_font_montserrat_22, lv_color_hex(0xB8C0C8), 180, LV_ALIGN_LEFT_MID, 20, 0); + make_detail_label(delta_card, &battery_detail_delta_label, "-- mV", &lv_font_montserrat_30, lv_color_hex(0xFFFFFF), 220, LV_ALIGN_RIGHT_MID, -20, 0, LV_TEXT_ALIGN_RIGHT); + + update_battery_detail_widgets(); +} + +static void show_dashboard_screen(DashboardScreen screen) +{ + lvgl_port_lock(-1); + if (screen == SCREEN_BATTERY_DETAIL) { + create_battery_detail_screen(); + } else { + create_overland_overview_screen(); + } + lvgl_port_unlock(); +} + + static void layout_relay_buttons() { int visible_count = relay_count; @@ -1218,6 +1398,7 @@ static void parse_status_json(const String &body) update_overview_widgets(); update_system_status_label(); + update_battery_detail_widgets(); } static bool fetch_status_fields(const char *url, const char *label) @@ -1350,6 +1531,7 @@ static void create_overland_overview_screen() { lv_obj_t *screen = lv_scr_act(); lv_obj_clean(screen); + current_dashboard_screen = SCREEN_OVERVIEW; lv_obj_set_style_bg_color(screen, lv_color_hex(0x101418), 0); lv_obj_t *system_card = create_card(screen, "", 960, 48, LV_ALIGN_TOP_MID, 0, 12); @@ -1580,9 +1762,9 @@ cargo_status_dot = lv_obj_create(system_card); lv_obj_align(outside_status_temp_label, LV_ALIGN_CENTER, 0, 0); lv_obj_t *page_dots = lv_label_create(system_card); - lv_label_set_text(page_dots, "● ○ ○ ○"); + lv_label_set_text(page_dots, "○ ●"); lv_obj_set_style_text_color(page_dots, lv_color_hex(0xB8C0C8), 0); - lv_obj_set_width(page_dots, 140); + lv_obj_set_width(page_dots, 120); lv_obj_set_style_text_align(page_dots, LV_TEXT_ALIGN_RIGHT, 0); lv_obj_align(page_dots, LV_ALIGN_RIGHT_MID, -10, 0); }