#include #include #include #include #include #include #include "lvgl_v8_port.h" #include "dashboard_config.h" extern const lv_img_dsc_t gas_station; extern const lv_img_dsc_t thermometer_water; #include "dashboard_status_model.h" using namespace esp_panel::drivers; using namespace esp_panel::board; static DashboardStatus status_model; static lv_obj_t *battery_card_obj = nullptr; static lv_obj_t *battery_soc_arc = nullptr; static lv_obj_t *battery_charge_pulse_arc = nullptr; static lv_obj_t *battery_current_label = nullptr; static lv_obj_t *battery_estimate_label = nullptr; static lv_obj_t *battery_soc_label = nullptr; static lv_obj_t *temp_tile_objs[2] = {nullptr}; static lv_obj_t *temp_tile_value_labels[2] = {nullptr}; static lv_obj_t *temp_tile_name_labels[2] = {nullptr}; static bool last_temp_tile_alert[2] = {false, false}; 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_page = nullptr; 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; static lv_obj_t *vehicle_fuel_bar = nullptr; static lv_obj_t *vehicle_fuel_value_label = nullptr; static lv_obj_t *relay_buttons[6] = {nullptr}; static lv_obj_t *relay_button_labels[6] = {nullptr}; static lv_obj_t *diagnostics_overlay = nullptr; static lv_obj_t *diagnostics_label = nullptr; static unsigned long diagnostics_last_update_ms = 0; static unsigned long diagnostics_last_tap_ms = 0; static unsigned long system_card_last_tap_ms = 0; static int system_card_tap_count = 0; static String relay_ids[6]; static String relay_names[6]; static bool relay_states[6] = {false}; static int relay_count = 0; static const int RELAY_STRIP_X = 32; static const int RELAY_STRIP_Y = 376; static const int RELAY_STRIP_W = 960; static const int RELAY_BUTTON_GAP = 10; static unsigned long last_wifi_attempt_ms = 0; static unsigned long last_wifi_label_update_ms = 0; static unsigned long last_status_poll_ms = 0; static unsigned long last_metadata_poll_ms = 0; static bool api_request_in_progress = false; static unsigned long last_relay_command_ms = 0; static bool fast_status_refresh_requested = false; static bool metadata_refresh_requested = true; static bool was_wifi_connected = false; static bool overview_screen_created = false; static bool relay_command_pending[6] = {false}; static bool pending_relay_state[6] = {false}; static bool pending_relay_previous_state[6] = {false}; static String last_battery_current_text; static String last_battery_estimate_text; static String last_battery_soc_text; static bool last_battery_card_alert = false; static String last_temp_tile_value_text[2]; static String last_temp_tile_name_text[2]; static String last_system_status_text; static String last_outside_status_temp_text; static bool last_cargo_api_ok = false; static String on_off(bool value) { return value ? "ON" : "OFF"; } static String normalize_temp_group(String group, const String &name, bool weather) { group.toLowerCase(); group.trim(); if (weather || group == "weather" || group == "outside") { return "outside"; } if (group == "fridge" || group == "freezer" || group == "cooler") { return "fridge"; } if (group == "battery" || group == "bms") { return "battery"; } if (group == "cabin" || group == "inside" || group == "interior") { return "cabin"; } String lower_name = name; lower_name.toLowerCase(); if (lower_name.indexOf("fridge") >= 0 || lower_name.indexOf("freezer") >= 0 || lower_name.indexOf("cooler") >= 0) { return "fridge"; } if (lower_name.indexOf("outside") >= 0 || lower_name.indexOf("ambient") >= 0) { return "outside"; } if (lower_name.indexOf("battery") >= 0 || lower_name.indexOf("bms") >= 0) { return "battery"; } return group.length() ? group : "cabin"; } static String display_temp_group_name(String group) { group.trim(); group.toLowerCase(); if (group == "cabin") return "Cabin"; if (group == "fridge") return "Fridge"; if (group == "battery") return "Battery"; if (group == "other") return "Other"; if (group.length() == 0) { return "Temp"; } group.setCharAt(0, toupper(group.charAt(0))); return group; } static int temp_group_index(const String &group) { if (group == "cabin") return 0; if (group == "fridge") return 1; if (group == "battery") return 2; return 3; } static String temp_group_label(int index, const String &fallback) { if (index == 0) return fallback.length() ? fallback : "Cabin"; if (index == 1) return "Fridge"; if (index == 2) return "Outside"; if (index == 3) return "Other"; return "Temp"; } static String format_hours(float hours) { if (hours <= 0.0 || isnan(hours) || isinf(hours)) { return ""; } int total_minutes = (int)(hours * 60.0 + 0.5); int h = total_minutes / 60; int m = total_minutes % 60; String text; if (h > 0) { text += String(h); text += "h "; } text += String(m); text += "m"; return text; } static void update_outside_status_temp(const String &text) { if (outside_status_temp_label == nullptr) { return; } set_label_text_if_changed(outside_status_temp_label, last_outside_status_temp_text, text); } static void update_status_icons() { if (cargo_status_dot == nullptr) { return; } bool cargo_ok = status_model.api_ok; if (cargo_ok == last_cargo_api_ok) { return; } last_cargo_api_ok = cargo_ok; lvgl_port_lock(-1); lv_obj_set_style_bg_color( cargo_status_dot, cargo_ok ? lv_color_hex(0x32D583) : lv_color_hex(0xF97066), 0 ); lvgl_port_unlock(); } static int clamp_int(int value, int min_value, int max_value) { if (value < min_value) { return min_value; } if (value > max_value) { return max_value; } return value; } static void update_battery_current_label(const String &text, float current) { if (battery_current_label == nullptr || last_battery_current_text == text) { return; } last_battery_current_text = text; lv_color_t current_color = lv_color_hex(0xB8C0C8); if (current > 0.05) { current_color = lv_color_hex(0x32D583); } else if (current < -0.05) { current_color = lv_color_hex(0xF97066); } lvgl_port_lock(-1); lv_obj_clear_flag(battery_current_label, LV_OBJ_FLAG_HIDDEN); lv_label_set_text(battery_current_label, text.c_str()); lv_obj_set_style_text_color(battery_current_label, current_color, 0); lvgl_port_unlock(); } static void update_battery_soc_gauge() { if (battery_soc_arc == nullptr || battery_charge_pulse_arc == nullptr) { return; } int soc = clamp_int(status_model.soc, 0, 100); bool charging = status_model.current > 0.05; int end_angle = (soc * 270) / 100; lvgl_port_lock(-1); lv_arc_set_value(battery_soc_arc, soc); lv_arc_set_angles(battery_soc_arc, 0, end_angle); lv_color_t soc_color = lv_color_hex(0x32D583); if (soc < 30) { soc_color = lv_color_hex(0xF97066); } else if (soc < 70) { soc_color = lv_color_hex(0xFDB022); } lv_obj_set_style_arc_color( battery_soc_arc, soc_color, LV_PART_INDICATOR ); lv_arc_set_value(battery_charge_pulse_arc, soc); lv_arc_set_angles(battery_charge_pulse_arc, 0, end_angle); lv_obj_add_flag(battery_charge_pulse_arc, LV_OBJ_FLAG_HIDDEN); lvgl_port_unlock(); Serial.print("Battery SOC arc: "); Serial.print(soc); Serial.print("% -> "); Serial.print(end_angle); Serial.println(" degrees"); } static void update_battery_charge_animation() { // Intentionally disabled for now. // Repeated style changes on the RGB panel caused artifacts/freezes during WiFi/API updates. } static void set_label_text_if_changed(lv_obj_t *label, String &last_text, const String &text) { if (label == nullptr || last_text == text) { return; } last_text = text; lvgl_port_lock(-1); lv_label_set_text(label, text.c_str()); lvgl_port_unlock(); } static void request_fast_status_refresh() { fast_status_refresh_requested = false; } static String format_diag_uptime(unsigned long seconds) { unsigned long days = seconds / 86400; seconds %= 86400; unsigned long hours = seconds / 3600; seconds %= 3600; unsigned long minutes = seconds / 60; String out; if (days > 0) { out += String(days); out += "d "; } out += String(hours); out += "h "; out += String(minutes); out += "m"; return out; } static void update_diagnostics_overlay() { if (diagnostics_overlay == nullptr || diagnostics_label == nullptr) { return; } if (millis() - diagnostics_last_update_ms < 500) { return; } diagnostics_last_update_ms = millis(); String text; text += "DIAGNOSTICS\n\n"; text += "Dashboard ESP32-S3\n"; text += " Version: "; text += DASHBOARD_VERSION; text += "\n Uptime: "; text += format_diag_uptime(millis() / 1000); text += "\n Free heap: "; text += String(ESP.getFreeHeap()); text += "\n Min heap: "; text += String(ESP.getMinFreeHeap()); text += "\n WiFi: "; text += WiFi.status() == WL_CONNECTED ? "connected" : "offline"; text += "\n RSSI: "; text += WiFi.status() == WL_CONNECTED ? String(WiFi.RSSI()) : String("--"); text += " dBm\n IP: "; text += WiFi.status() == WL_CONNECTED ? WiFi.localIP().toString() : String("--"); text += "\n Last HTTP: "; text += String(status_model.http_code); text += "\n Cargo API: "; text += status_model.api_ok ? "OK" : "FAIL"; text += "\n\nCargo ESP\n"; text += " Version: "; text += status_model.firmware_version; text += "\n Uptime: "; text += format_diag_uptime(status_model.uptime_seconds); text += "\n Chip: "; text += status_model.cargo_chip_model; text += "\n CPU: "; text += String(status_model.cargo_cpu_mhz); text += " MHz"; text += "\n Free heap: "; text += String(status_model.cargo_free_heap); text += "\n Min heap: "; text += String(status_model.cargo_min_free_heap); text += "\n\nCargo Network\n"; text += " AP SSID: "; text += status_model.cargo_ap_ssid; text += "\n AP clients: "; text += String(status_model.cargo_ap_clients); text += "\n STA IP: "; text += status_model.cargo_sta_ip.length() ? status_model.cargo_sta_ip : String("--"); text += "\n STA RSSI: "; text += status_model.cargo_sta_rssi_dbm == 0 ? String("--") : String(status_model.cargo_sta_rssi_dbm); text += " dBm"; text += "\n\nPower / Sensors\n"; text += " BMS: "; text += status_model.bms_connected ? "connected" : "offline"; text += "\n SOC: "; text += status_model.soc >= 0 ? String(status_model.soc) + "%" : String("--"); text += "\n Current: "; text += String(status_model.current, 2); text += " A"; text += "\n Cell delta: "; text += String(status_model.cell_delta_mv); text += " mV"; text += "\n\nControls\n"; text += " Relays visible: "; text += String(relay_count); text += "\n API busy: "; text += api_request_in_progress ? "yes" : "no"; text += "\n\nDouble tap anywhere to return."; lvgl_port_lock(-1); lv_label_set_text(diagnostics_label, text.c_str()); lvgl_port_unlock(); } static void close_diagnostics_overlay() { if (diagnostics_overlay == nullptr) { return; } lvgl_port_lock(-1); lv_obj_del(diagnostics_overlay); diagnostics_overlay = nullptr; diagnostics_label = nullptr; lvgl_port_unlock(); } static void diagnostics_overlay_event_cb(lv_event_t *event) { if (lv_event_get_code(event) != LV_EVENT_CLICKED) { return; } unsigned long now = millis(); if (now - diagnostics_last_tap_ms < 650) { close_diagnostics_overlay(); diagnostics_last_tap_ms = 0; return; } diagnostics_last_tap_ms = now; } static void show_diagnostics_overlay() { if (diagnostics_overlay != nullptr) { return; } lvgl_port_lock(-1); diagnostics_overlay = lv_obj_create(lv_scr_act()); lv_obj_set_size(diagnostics_overlay, 1024, 600); lv_obj_align(diagnostics_overlay, LV_ALIGN_CENTER, 0, 0); lv_obj_set_style_bg_color(diagnostics_overlay, lv_color_hex(0x101418), 0); lv_obj_set_style_bg_opa(diagnostics_overlay, LV_OPA_COVER, 0); lv_obj_set_style_border_width(diagnostics_overlay, 0, 0); lv_obj_set_style_pad_all(diagnostics_overlay, 22, 0); lv_obj_add_flag(diagnostics_overlay, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(diagnostics_overlay, diagnostics_overlay_event_cb, LV_EVENT_CLICKED, nullptr); diagnostics_label = lv_label_create(diagnostics_overlay); lv_label_set_text(diagnostics_label, "DIAGNOSTICS\nLoading..."); lv_obj_set_style_text_color(diagnostics_label, lv_color_hex(0xDDE3EA), 0); lv_obj_set_style_text_font(diagnostics_label, &lv_font_montserrat_26, 0); lv_obj_set_width(diagnostics_label, 960); lv_label_set_long_mode(diagnostics_label, LV_LABEL_LONG_WRAP); lv_obj_align(diagnostics_label, LV_ALIGN_TOP_LEFT, 8, 8); lvgl_port_unlock(); diagnostics_last_update_ms = 0; update_diagnostics_overlay(); } static void system_card_event_cb(lv_event_t *event) { if (lv_event_get_code(event) != LV_EVENT_CLICKED) { return; } unsigned long now = millis(); if (now - system_card_last_tap_ms > 3000) { system_card_tap_count = 0; } system_card_last_tap_ms = now; system_card_tap_count++; if (system_card_tap_count >= 5) { system_card_tap_count = 0; show_diagnostics_overlay(); } } static void create_overland_overview_screen(); static void create_battery_detail_screen(); static void show_dashboard_screen(DashboardScreen screen); static void reset_battery_detail_widget_cache() { last_battery_detail_soc_text = "__force_soc__"; last_battery_detail_status_text = "__force_status__"; last_battery_detail_voltage_text = "__force_voltage__"; last_battery_detail_current_text = "__force_current__"; last_battery_detail_power_text = "__force_power__"; last_battery_detail_remaining_text = "__force_remaining__"; last_battery_detail_capacity_text = "__force_capacity__"; last_battery_detail_temp_text = "__force_temp__"; last_battery_detail_delta_text = "__force_delta__"; last_battery_detail_estimate_text = "__force_estimate__"; } 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 bool dashboard_page_switch_queued = false; static DashboardScreen dashboard_page_switch_target = SCREEN_OVERVIEW; static void queue_dashboard_page_switch(DashboardScreen target) { if (dashboard_page_switch_queued) return; if (target == current_dashboard_screen) return; dashboard_page_switch_target = target; dashboard_page_switch_queued = true; } static void process_dashboard_page_switch() { if (!dashboard_page_switch_queued) return; DashboardScreen target = dashboard_page_switch_target; dashboard_page_switch_queued = false; lvgl_port_lock(-1); show_dashboard_screen(target); lvgl_port_unlock(); } 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); // Only Battery Detail uses swipe navigation. // Left-to-right swipe acts like a smartphone back gesture. if (current_dashboard_screen == SCREEN_BATTERY_DETAIL && dir == LV_DIR_RIGHT) { queue_dashboard_page_switch(SCREEN_OVERVIEW); } } static String battery_detail_signed(float value, int decimals, const char *suffix) { String text; if (value > 0.0) text += "+"; text += String(value, decimals); text += suffix; return text; } static void battery_detail_value_label(lv_obj_t *parent, const char *title, lv_obj_t **value, int x, int y) { lv_obj_t *card = create_card(parent, "", 292, 112, LV_ALIGN_TOP_LEFT, x, y); lv_obj_t *title_label = lv_label_create(card); 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_26, 0); lv_obj_align(title_label, LV_ALIGN_TOP_LEFT, 16, 10); *value = lv_label_create(card); lv_label_set_text(*value, "--"); lv_obj_set_style_text_color(*value, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_font(*value, &lv_font_montserrat_30, 0); lv_obj_set_width(*value, 260); lv_obj_set_style_text_align(*value, LV_TEXT_ALIGN_LEFT, 0); lv_obj_align(*value, LV_ALIGN_TOP_LEFT, 16, 54); } static void update_battery_detail_widgets() { if (battery_detail_soc_label == nullptr) return; const float amps = status_model.current; String soc = status_model.soc >= 0 ? String(status_model.soc) + "%" : "--%"; String battery_state; if (amps <= -0.05f) { battery_state = "DISCHARGING"; } else if (amps >= 0.05f) { battery_state = "CHARGING"; } else { battery_state = "IDLE"; } String voltage = String(status_model.voltage, 2) + " V"; String current = battery_detail_signed(amps, 2, " A"); String power = battery_detail_signed(status_model.voltage * amps, 0, " W"); String remaining = String(status_model.remaining_ah, 1) + " Ah"; String capacity = String(status_model.capacity_ah, 1) + " Ah"; String temp = String(status_model.battery_temp_f, 1) + "°F"; String delta = status_model.cell_delta_mv >= 0 ? String(status_model.cell_delta_mv) + " mV" : "-- mV"; String estimate; if (amps <= -0.05f && status_model.remaining_ah > 0.0f) { estimate = "ETR " + format_hours(status_model.remaining_ah / fabs(amps)); } else if (amps >= 0.05f && status_model.capacity_ah > 0.0f) { float ah_to_full = max(0.0f, status_model.capacity_ah - status_model.remaining_ah); estimate = "Full " + format_hours(ah_to_full / amps); } else { estimate = "Idle"; } set_label_text_if_changed(battery_detail_soc_label, last_battery_detail_soc_text, soc); set_label_text_if_changed(battery_detail_status_label, last_battery_detail_status_text, battery_state); set_label_text_if_changed(battery_detail_voltage_label, last_battery_detail_voltage_text, voltage); set_label_text_if_changed(battery_detail_current_label, last_battery_detail_current_text, current); set_label_text_if_changed(battery_detail_power_label, last_battery_detail_power_text, power); set_label_text_if_changed(battery_detail_remaining_label, last_battery_detail_remaining_text, remaining); set_label_text_if_changed(battery_detail_capacity_label, last_battery_detail_capacity_text, capacity); set_label_text_if_changed(battery_detail_temp_label, last_battery_detail_temp_text, temp); set_label_text_if_changed(battery_detail_delta_label, last_battery_detail_delta_text, delta); set_label_text_if_changed(battery_detail_estimate_label, last_battery_detail_estimate_text, estimate); } static void clear_battery_detail_refs() { battery_detail_soc_label = nullptr; battery_detail_status_label = nullptr; battery_detail_voltage_label = nullptr; battery_detail_current_label = nullptr; battery_detail_power_label = nullptr; battery_detail_remaining_label = nullptr; battery_detail_capacity_label = nullptr; battery_detail_temp_label = nullptr; battery_detail_delta_label = nullptr; battery_detail_estimate_label = nullptr; } static void close_battery_detail_screen() { if (battery_detail_page != nullptr) { lv_obj_add_flag(battery_detail_page, LV_OBJ_FLAG_HIDDEN); } // Keep the existing overview widgets exactly as-is. // The next successful API poll will refresh them naturally. current_dashboard_screen = SCREEN_OVERVIEW; } static void create_battery_detail_screen() { if (battery_detail_page != nullptr) { lv_obj_clear_flag(battery_detail_page, LV_OBJ_FLAG_HIDDEN); lv_obj_move_foreground(battery_detail_page); current_dashboard_screen = SCREEN_BATTERY_DETAIL; reset_battery_detail_widget_cache(); update_battery_detail_widgets(); return; } lv_obj_t *screen = lv_scr_act(); battery_detail_page = lv_obj_create(screen); lv_obj_set_size(battery_detail_page, lv_pct(100), lv_pct(100)); lv_obj_align(battery_detail_page, LV_ALIGN_TOP_LEFT, 0, 0); lv_obj_set_style_bg_color(battery_detail_page, lv_color_hex(0x101418), 0); lv_obj_set_style_border_width(battery_detail_page, 0, 0); lv_obj_set_style_pad_all(battery_detail_page, 0, 0); lv_obj_clear_flag(battery_detail_page, LV_OBJ_FLAG_SCROLLABLE); lv_obj_add_event_cb(battery_detail_page, dashboard_root_event_cb, LV_EVENT_GESTURE, nullptr); current_dashboard_screen = SCREEN_BATTERY_DETAIL; // Match the main dashboard status bar layout. lv_obj_t *system_card = create_card(battery_detail_page, "", 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 *detail_status_dot = lv_obj_create(system_card); lv_obj_set_size(detail_status_dot, 18, 18); lv_obj_set_style_radius(detail_status_dot, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(detail_status_dot, last_cargo_api_ok ? lv_color_hex(0x32D583) : lv_color_hex(0xF97066), 0); lv_obj_set_style_border_width(detail_status_dot, 0, 0); lv_obj_align(detail_status_dot, LV_ALIGN_LEFT_MID, 24, 0); lv_obj_t *outside_label = lv_label_create(system_card); lv_label_set_text(outside_label, last_outside_status_temp_text.length() ? last_outside_status_temp_text.c_str() : "OUT --"); lv_obj_set_style_text_color(outside_label, lv_color_hex(0xDDE3EA), 0); lv_obj_set_style_text_font(outside_label, &lv_font_montserrat_30, 0); lv_obj_set_width(outside_label, 220); lv_obj_set_style_text_align(outside_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(outside_label, LV_ALIGN_CENTER, 0, 0); lv_obj_t *page_label = lv_label_create(system_card); lv_label_set_text(page_label, "BATTERY"); lv_obj_set_style_text_color(page_label, lv_color_hex(0xB8C0C8), 0); lv_obj_set_style_text_font(page_label, &lv_font_montserrat_26, 0); lv_obj_set_width(page_label, 160); lv_obj_set_style_text_align(page_label, LV_TEXT_ALIGN_RIGHT, 0); lv_obj_align(page_label, LV_ALIGN_RIGHT_MID, -10, 0); // Left summary card. lv_obj_t *soc_card = create_card(battery_detail_page, "", 300, 390, LV_ALIGN_TOP_LEFT, 32, 68); battery_detail_status_label = lv_label_create(soc_card); lv_label_set_text(battery_detail_status_label, "IDLE"); lv_obj_set_style_text_color(battery_detail_status_label, lv_color_hex(0xB8C0C8), 0); lv_obj_set_style_text_font(battery_detail_status_label, &lv_font_montserrat_26, 0); lv_obj_set_width(battery_detail_status_label, 260); lv_obj_set_style_text_align(battery_detail_status_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(battery_detail_status_label, LV_ALIGN_TOP_MID, 0, 34); battery_detail_soc_label = lv_label_create(soc_card); lv_label_set_text(battery_detail_soc_label, "--%"); lv_obj_set_style_text_color(battery_detail_soc_label, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_font(battery_detail_soc_label, &lv_font_montserrat_48, 0); lv_obj_set_width(battery_detail_soc_label, 260); lv_obj_set_style_text_align(battery_detail_soc_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(battery_detail_soc_label, LV_ALIGN_TOP_MID, 0, 150); battery_detail_estimate_label = lv_label_create(soc_card); lv_label_set_text(battery_detail_estimate_label, "--"); lv_obj_set_style_text_color(battery_detail_estimate_label, lv_color_hex(0xDDE3EA), 0); lv_obj_set_style_text_font(battery_detail_estimate_label, &lv_font_montserrat_30, 0); lv_obj_set_width(battery_detail_estimate_label, 260); lv_obj_set_style_text_align(battery_detail_estimate_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(battery_detail_estimate_label, LV_ALIGN_TOP_MID, 0, 258); lv_obj_t *back_hint_card = create_card(battery_detail_page, "", 300, 96, LV_ALIGN_TOP_LEFT, 32, 472); lv_obj_t *back_hint = lv_label_create(back_hint_card); lv_label_set_text(back_hint, "Swipe right\nto go back"); lv_obj_set_style_text_color(back_hint, lv_color_hex(0xB8C0C8), 0); lv_obj_set_style_text_font(back_hint, &lv_font_montserrat_26, 0); lv_obj_set_width(back_hint, 260); lv_obj_set_style_text_align(back_hint, LV_TEXT_ALIGN_CENTER, 0); lv_obj_center(back_hint); // Right-side compact metrics. Fits fully within 1024x600. battery_detail_value_label(battery_detail_page, "Voltage", &battery_detail_voltage_label, 356, 68); battery_detail_value_label(battery_detail_page, "Current", &battery_detail_current_label, 670, 68); battery_detail_value_label(battery_detail_page, "Power", &battery_detail_power_label, 356, 192); battery_detail_value_label(battery_detail_page, "Remaining", &battery_detail_remaining_label, 670, 192); battery_detail_value_label(battery_detail_page, "Capacity", &battery_detail_capacity_label, 356, 316); battery_detail_value_label(battery_detail_page, "Battery Temp", &battery_detail_temp_label, 670, 316); battery_detail_value_label(battery_detail_page, "Cell Delta", &battery_detail_delta_label, 356, 440); reset_battery_detail_widget_cache(); update_battery_detail_widgets(); lv_obj_move_foreground(battery_detail_page); } static void show_dashboard_screen(DashboardScreen screen) { if (screen == current_dashboard_screen) return; if (screen == SCREEN_BATTERY_DETAIL) { create_battery_detail_screen(); } else { close_battery_detail_screen(); } } static unsigned long battery_card_last_tap_ms = 0; static void battery_card_event_cb(lv_event_t *event) { if (lv_event_get_code(event) != LV_EVENT_CLICKED) { return; } unsigned long now = millis(); if (now - battery_card_last_tap_ms < 350) { battery_card_last_tap_ms = 0; Serial.println("NAV: Battery card double click"); queue_dashboard_page_switch(SCREEN_BATTERY_DETAIL); return; } battery_card_last_tap_ms = now; } static void add_battery_card_tap_target(lv_obj_t *obj) { if (obj == nullptr) { return; } lv_obj_add_flag(obj, LV_OBJ_FLAG_CLICKABLE); lv_obj_add_event_cb(obj, battery_card_event_cb, LV_EVENT_CLICKED, nullptr); } static void layout_relay_buttons() { int visible_count = relay_count; if (visible_count < 1) { visible_count = 1; } if (visible_count > 6) { visible_count = 6; } const int card_width = 960; const int horizontal_pad = 18; const int button_gap = 12; const int button_y = 10; const int button_height = 178; int available_width = card_width - (horizontal_pad * 2); int button_width = (available_width - ((visible_count - 1) * button_gap)) / visible_count; int total_width = (button_width * visible_count) + (button_gap * (visible_count - 1)); int start_x = (card_width - total_width) / 2; for (int i = 0; i < 6; i++) { if (relay_buttons[i] == nullptr) { continue; } if (i < relay_count) { lv_obj_set_size(relay_buttons[i], button_width, button_height); lv_obj_align( relay_buttons[i], LV_ALIGN_TOP_LEFT, start_x + (i * (button_width + button_gap)), button_y ); } } } static void update_relay_buttons() { lvgl_port_lock(-1); layout_relay_buttons(); for (int i = 0; i < 6; i++) { if (relay_buttons[i] == nullptr || relay_button_labels[i] == nullptr) { continue; } if (i < relay_count) { lv_obj_clear_flag(relay_buttons[i], LV_OBJ_FLAG_HIDDEN); String label = relay_names[i].length() > 0 ? relay_names[i] : relay_ids[i]; label += "\n"; label += on_off(relay_states[i]); lv_label_set_text(relay_button_labels[i], label.c_str()); if (relay_count >= 5) { lv_obj_set_style_text_font(relay_button_labels[i], &lv_font_montserrat_30, 0); } else { lv_obj_set_style_text_font(relay_button_labels[i], &lv_font_montserrat_40, 0); } if (relay_states[i]) { lv_obj_set_style_bg_color(relay_buttons[i], lv_color_hex(0x32D583), 0); lv_obj_set_style_bg_grad_color(relay_buttons[i], lv_color_hex(0x027A48), 0); lv_obj_set_style_bg_grad_dir(relay_buttons[i], LV_GRAD_DIR_VER, 0); lv_obj_set_style_border_color(relay_buttons[i], lv_color_hex(0x6CE9A6), 0); lv_obj_set_style_shadow_color(relay_buttons[i], lv_color_hex(0x32D583), 0); } else { lv_obj_set_style_bg_color(relay_buttons[i], lv_color_hex(0x3B4754), 0); lv_obj_set_style_bg_grad_color(relay_buttons[i], lv_color_hex(0x1F2933), 0); lv_obj_set_style_bg_grad_dir(relay_buttons[i], LV_GRAD_DIR_VER, 0); lv_obj_set_style_border_color(relay_buttons[i], lv_color_hex(0x556270), 0); lv_obj_set_style_shadow_color(relay_buttons[i], lv_color_hex(0x000000), 0); } lv_obj_set_style_radius(relay_buttons[i], 16, 0); lv_obj_set_style_border_width(relay_buttons[i], 2, 0); lv_obj_set_style_shadow_width(relay_buttons[i], 14, 0); lv_obj_set_style_shadow_opa(relay_buttons[i], LV_OPA_40, 0); lv_obj_set_style_shadow_ofs_y(relay_buttons[i], 6, 0); lv_obj_set_style_bg_color(relay_buttons[i], lv_color_hex(0x17202A), LV_STATE_PRESSED); lv_obj_set_style_bg_grad_color(relay_buttons[i], lv_color_hex(0x111820), LV_STATE_PRESSED); lv_obj_set_style_shadow_width(relay_buttons[i], 4, LV_STATE_PRESSED); lv_obj_set_style_shadow_ofs_y(relay_buttons[i], 2, LV_STATE_PRESSED); lv_obj_set_style_text_color(relay_button_labels[i], lv_color_hex(0xFFFFFF), 0); } else { lv_obj_add_flag(relay_buttons[i], LV_OBJ_FLAG_HIDDEN); } } lvgl_port_unlock(); } static bool post_relay_state(const String &relay_id, bool state) { if (api_request_in_progress || WiFi.status() != WL_CONNECTED) { return false; } api_request_in_progress = true; DynamicJsonDocument request(256); request["id"] = relay_id; request["state"] = state; String payload; serializeJson(request, payload); Serial.print("POST "); Serial.println(CARGO_API_RELAY_SET_URL); Serial.println(payload); HTTPClient http; http.setTimeout(1200); http.begin(CARGO_API_RELAY_SET_URL); http.setReuse(false); http.addHeader("Connection", "close"); http.addHeader("Content-Type", "application/json"); unsigned long start_ms = millis(); int code = http.POST(payload); String body = http.getString(); Serial.print("Relay HTTP code: "); Serial.print(code); Serial.print(" total="); Serial.print(millis() - start_ms); Serial.println("ms"); Serial.println(body); bool ok = code >= 200 && code < 300; if (ok && body.length() > 0) { DynamicJsonDocument response(512); DeserializationError error = deserializeJson(response, body); if (!error && response["state"].is()) { bool applied_state = response["state"].as(); const char *applied_id = response["id"] | ""; for (int i = 0; i < relay_count; i++) { if (relay_ids[i] == String(applied_id)) { relay_states[i] = applied_state; break; } } } } http.end(); api_request_in_progress = false; return ok; } static void relay_button_event_cb(lv_event_t *event) { if (lv_event_get_code(event) != LV_EVENT_CLICKED) { return; } intptr_t index_value = reinterpret_cast(lv_event_get_user_data(event)); int index = static_cast(index_value); if (index < 0 || index >= relay_count) { return; } bool previous_state = relay_states[index]; bool next_state = !previous_state; relay_states[index] = next_state; update_relay_buttons(); if (!relay_command_pending[index]) { pending_relay_previous_state[index] = previous_state; } // Latest-state queue: rapid taps overwrite this relay's desired target. pending_relay_state[index] = next_state; relay_command_pending[index] = true; String text = "Queued "; text += relay_names[index]; text += " "; text += on_off(next_state); set_label_text_if_changed(system_status_label, last_system_status_text, text); } static void process_pending_relay_command() { if (api_request_in_progress) { return; } int index = -1; for (int i = 0; i < relay_count; i++) { if (relay_command_pending[i]) { index = i; break; } } if (index < 0) { return; } String relay_id = relay_ids[index]; bool next_state = pending_relay_state[index]; bool previous_state = pending_relay_previous_state[index]; // Clear before posting. If the user taps again after this command returns, // the new desired state will be queued as a fresh command. relay_command_pending[index] = false; bool ok = post_relay_state(relay_id, next_state); if (ok) { set_label_text_if_changed(system_status_label, last_system_status_text, "Relay command sent"); } else { // Only revert if no newer desired state was queued for this relay. if (!relay_command_pending[index] && index >= 0 && index < relay_count) { relay_states[index] = previous_state; update_relay_buttons(); } set_label_text_if_changed(system_status_label, last_system_status_text, "Relay command failed"); } } static void connect_wifi_if_needed() { if (WiFi.status() == WL_CONNECTED) { return; } if (millis() - last_wifi_attempt_ms < 10000) { return; } last_wifi_attempt_ms = millis(); Serial.print("Connecting to Cargo ESP AP: "); Serial.println(CARGO_WIFI_SSID); WiFi.mode(WIFI_STA); WiFi.begin(CARGO_WIFI_SSID, CARGO_WIFI_PASSWORD); } static void update_system_status_label() { bool wifi_connected = WiFi.status() == WL_CONNECTED; if (wifi_connected && !was_wifi_connected) { was_wifi_connected = true; fast_status_refresh_requested = false; metadata_refresh_requested = true; last_status_poll_ms = 0; last_metadata_poll_ms = 0; if (!overview_screen_created) { lvgl_port_lock(-1); create_overland_overview_screen(); lvgl_port_unlock(); overview_screen_created = true; } } else if (!wifi_connected) { was_wifi_connected = false; } if (millis() - last_wifi_label_update_ms < 5000) { return; } last_wifi_label_update_ms = millis(); update_status_icons(); } static lv_obj_t *create_coolant_icon(lv_obj_t *parent) { lv_obj_t *icon = lv_obj_create(parent); lv_obj_set_size(icon, 34, 46); lv_obj_set_style_bg_opa(icon, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(icon, 0, 0); lv_obj_set_style_pad_all(icon, 0, 0); lv_obj_clear_flag(icon, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t *stem = lv_obj_create(icon); lv_obj_set_size(stem, 10, 30); lv_obj_align(stem, LV_ALIGN_TOP_MID, 0, 2); lv_obj_set_style_radius(stem, 6, 0); lv_obj_set_style_bg_color(stem, lv_color_hex(0x84CAFF), 0); lv_obj_set_style_border_width(stem, 0, 0); lv_obj_t *bulb = lv_obj_create(icon); lv_obj_set_size(bulb, 22, 22); lv_obj_align(bulb, LV_ALIGN_BOTTOM_MID, 0, -1); lv_obj_set_style_radius(bulb, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(bulb, lv_color_hex(0x84CAFF), 0); lv_obj_set_style_border_width(bulb, 0, 0); return icon; } static lv_obj_t *create_fuel_icon(lv_obj_t *parent) { lv_obj_t *icon = lv_obj_create(parent); lv_obj_set_size(icon, 34, 34); lv_obj_set_style_bg_opa(icon, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(icon, 0, 0); lv_obj_set_style_pad_all(icon, 0, 0); lv_obj_clear_flag(icon, LV_OBJ_FLAG_SCROLLABLE); lv_obj_t *body = lv_obj_create(icon); lv_obj_set_size(body, 18, 26); lv_obj_align(body, LV_ALIGN_LEFT_MID, 2, 0); lv_obj_set_style_radius(body, 3, 0); lv_obj_set_style_bg_color(body, lv_color_hex(0xDDE3EA), 0); lv_obj_set_style_border_width(body, 0, 0); lv_obj_t *window = lv_obj_create(body); lv_obj_set_size(window, 10, 7); lv_obj_align(window, LV_ALIGN_TOP_MID, 0, 4); lv_obj_set_style_radius(window, 1, 0); lv_obj_set_style_bg_color(window, lv_color_hex(0x17202A), 0); lv_obj_set_style_border_width(window, 0, 0); lv_obj_t *hose = lv_obj_create(icon); lv_obj_set_size(hose, 12, 4); lv_obj_align(hose, LV_ALIGN_TOP_RIGHT, -2, 8); lv_obj_set_style_radius(hose, 2, 0); lv_obj_set_style_bg_color(hose, lv_color_hex(0xDDE3EA), 0); lv_obj_set_style_border_width(hose, 0, 0); lv_obj_t *nozzle = lv_obj_create(icon); lv_obj_set_size(nozzle, 4, 14); lv_obj_align(nozzle, LV_ALIGN_RIGHT_MID, -2, 2); lv_obj_set_style_radius(nozzle, 2, 0); lv_obj_set_style_bg_color(nozzle, lv_color_hex(0xDDE3EA), 0); lv_obj_set_style_border_width(nozzle, 0, 0); return icon; } static void fuel_bar_draw_value_cb(lv_event_t *e) { lv_obj_draw_part_dsc_t *dsc = lv_event_get_draw_part_dsc(e); if (dsc == nullptr || dsc->part != LV_PART_INDICATOR) { return; } lv_obj_t *bar = lv_event_get_target(e); lv_draw_label_dsc_t label_dsc; lv_draw_label_dsc_init(&label_dsc); label_dsc.font = &lv_font_montserrat_26; char buf[10]; lv_snprintf(buf, sizeof(buf), "%d%%", (int)lv_bar_get_value(bar)); lv_point_t txt_size; lv_txt_get_size( &txt_size, buf, label_dsc.font, label_dsc.letter_space, label_dsc.line_space, LV_COORD_MAX, label_dsc.flag ); lv_area_t txt_area; if (lv_area_get_width(dsc->draw_area) > txt_size.x + 20) { txt_area.x2 = dsc->draw_area->x2 - 6; txt_area.x1 = txt_area.x2 - txt_size.x + 1; label_dsc.color = lv_color_hex(0xFFFFFF); } else { txt_area.x1 = dsc->draw_area->x2 + 8; txt_area.x2 = txt_area.x1 + txt_size.x - 1; label_dsc.color = lv_color_hex(0xFFFFFF); } txt_area.y1 = dsc->draw_area->y1 + (lv_area_get_height(dsc->draw_area) - txt_size.y) / 2; txt_area.y2 = txt_area.y1 + txt_size.y - 1; lv_draw_label(dsc->draw_ctx, &label_dsc, &txt_area, buf, NULL); } static void reset_overview_widget_cache() { last_battery_current_text = "__force_current__"; last_battery_estimate_text = "__force_estimate__"; last_battery_soc_text = "__force_soc__"; last_system_status_text = "__force_system__"; last_outside_status_temp_text = "__force_outside__"; last_battery_card_alert = !last_battery_card_alert; last_cargo_api_ok = !last_cargo_api_ok; for (int i = 0; i < 2; i++) { last_temp_tile_value_text[i] = "__force_temp_value__"; last_temp_tile_name_text[i] = "__force_temp_name__"; last_temp_tile_alert[i] = !last_temp_tile_alert[i]; } } static void update_temp_tile_alert_style(int index, bool alert) { if (index < 0 || index >= 2 || temp_tile_objs[index] == nullptr) { return; } if (last_temp_tile_alert[index] == alert) { return; } last_temp_tile_alert[index] = alert; lvgl_port_lock(-1); if (alert) { lv_obj_set_style_bg_color(temp_tile_objs[index], lv_color_hex(0x7A271A), 0); lv_obj_set_style_border_color(temp_tile_objs[index], lv_color_hex(0xF97066), 0); lv_obj_set_style_border_width(temp_tile_objs[index], 3, 0); } else { lv_obj_set_style_bg_color(temp_tile_objs[index], lv_color_hex(0x17202A), 0); lv_obj_set_style_border_color(temp_tile_objs[index], lv_color_hex(0x2F3A46), 0); lv_obj_set_style_border_width(temp_tile_objs[index], 2, 0); } lvgl_port_unlock(); } static void update_battery_card_alert_style(bool alert) { if (battery_card_obj == nullptr) { return; } if (last_battery_card_alert == alert) { return; } last_battery_card_alert = alert; lvgl_port_lock(-1); if (alert) { lv_obj_set_style_bg_color(battery_card_obj, lv_color_hex(0x7A271A), 0); lv_obj_set_style_border_color(battery_card_obj, lv_color_hex(0xF97066), 0); lv_obj_set_style_border_width(battery_card_obj, 3, 0); } else { lv_obj_set_style_bg_color(battery_card_obj, lv_color_hex(0x1F2933), 0); lv_obj_set_style_border_color(battery_card_obj, lv_color_hex(0x3A4652), 0); lv_obj_set_style_border_width(battery_card_obj, 2, 0); } lvgl_port_unlock(); } static void update_overview_widgets() { String current_text = "Idle"; String estimate_text = ""; if (status_model.current > 0.05 || status_model.current < -0.05) { current_text = ""; current_text += status_model.current > 0 ? "+" : ""; current_text += String(status_model.current, 2); current_text += " A"; if (status_model.current > 0.05 && status_model.capacity_ah > 0.0) { float remaining_to_full_ah = status_model.capacity_ah - status_model.remaining_ah; estimate_text = "Full in " + format_hours(remaining_to_full_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)); } } String soc_text; soc_text += status_model.soc >= 0 ? String(status_model.soc) : "--"; soc_text += "%"; update_battery_soc_gauge(); set_label_text_if_changed(battery_soc_label, last_battery_soc_text, soc_text); update_battery_current_label(current_text, status_model.current); if (battery_estimate_label != nullptr) { set_label_text_if_changed(battery_estimate_label, last_battery_estimate_text, estimate_text); lvgl_port_lock(-1); if (estimate_text.length() == 0) { lv_obj_add_flag(battery_estimate_label, LV_OBJ_FLAG_HIDDEN); } else { lv_obj_clear_flag(battery_estimate_label, LV_OBJ_FLAG_HIDDEN); } lvgl_port_unlock(); } } static void parse_status_json(const String &body) { DynamicJsonDocument doc(16000); DeserializationError error = deserializeJson(doc, body); if (error) { status_model.api_ok = false; set_label_text_if_changed(system_status_label, last_system_status_text, String("JSON parse failed: ") + error.c_str()); return; } status_model.api_ok = true; bool battery_alarm_active = false; if (doc["alarms"].is()) { JsonObject alarms = doc["alarms"]; battery_alarm_active = (alarms["low_soc"] | false) || (alarms["critical_soc"] | false) || (alarms["low_voltage"] | false) || (alarms["high_battery_temp"] | false) || (alarms["cell_imbalance"] | false) || (alarms["bms_disconnected"] | false); } update_battery_card_alert_style(battery_alarm_active); if (doc["battery"].is()) { JsonObject battery = doc["battery"]; status_model.bms_connected = battery["connected"] | status_model.bms_connected; status_model.soc = battery["soc"] | status_model.soc; status_model.voltage = battery["voltage"] | status_model.voltage; status_model.current = battery["current"] | status_model.current; status_model.battery_temp_f = battery["temperature_f"] | status_model.battery_temp_f; status_model.remaining_ah = battery["remaining_ah"] | status_model.remaining_ah; status_model.capacity_ah = battery["capacity_ah"] | status_model.capacity_ah; status_model.cell_delta_mv = battery["cell_delta_mv"] | status_model.cell_delta_mv; } if (doc["config"].is()) { JsonObject config = doc["config"]; status_model.hardware_profile = String((const char *)(config["hardware_profile"] | status_model.hardware_profile.c_str())); status_model.output_count = config["output_count"] | status_model.output_count; } if (doc["system"].is()) { JsonObject system = doc["system"]; status_model.firmware_version = String((const char *)(system["firmware_version"] | status_model.firmware_version.c_str())); status_model.uptime_seconds = system["uptime_seconds"] | status_model.uptime_seconds; status_model.cargo_free_heap = system["free_heap"] | status_model.cargo_free_heap; status_model.cargo_min_free_heap = system["min_free_heap"] | status_model.cargo_min_free_heap; status_model.cargo_chip_model = String((const char *)(system["chip_model"] | status_model.cargo_chip_model.c_str())); status_model.cargo_cpu_mhz = system["cpu_mhz"] | status_model.cargo_cpu_mhz; } if (doc["network"].is()) { JsonObject network = doc["network"]; status_model.cargo_ap_ssid = String((const char *)(network["ap_ssid"] | status_model.cargo_ap_ssid.c_str())); status_model.cargo_sta_ip = String((const char *)(network["sta_ip"] | status_model.cargo_sta_ip.c_str())); status_model.cargo_ap_clients = network["ap_clients"] | status_model.cargo_ap_clients; status_model.cargo_sta_rssi_dbm = network["sta_rssi_dbm"] | status_model.cargo_sta_rssi_dbm; } if (doc["temps"].is()) { String outside_status = "OUT --"; struct TempDisplayItem { String group; int priority; int rounded_f; bool high_alert; }; TempDisplayItem items[4]; int item_count = 0; for (JsonObject temp : doc["temps"].as()) { bool enabled = temp["enabled"] | false; bool online = temp["online"] | false; bool weather = temp["weather"] | false; if (!enabled || !online || temp["temperature_f"].isNull()) { continue; } float temp_f = (float)(temp["temperature_f"] | 0.0); bool high_alert = (temp["high_alert"] | false) || (temp["alert"] | false) || (temp["alarm"] | false) || (temp["high_alarm"] | false) || (temp["temp_high"] | false); if (!temp["high_threshold_f"].isNull()) { high_alert = high_alert || temp_f >= (float)(temp["high_threshold_f"] | 9999.0); } if (!temp["alarm_threshold_f"].isNull()) { high_alert = high_alert || temp_f >= (float)(temp["alarm_threshold_f"] | 9999.0); } if (weather) { outside_status = "OUT "; outside_status += String((int)round(temp_f)); outside_status += "°"; continue; } const char *raw_name = temp["name"] | ""; const char *raw_group = temp["group"] | ""; String name = raw_name && raw_name[0] ? String(raw_name) : String("cabin"); String group = normalize_temp_group(String(raw_group), name, false); if (group == "outside") { outside_status = "OUT "; outside_status += String((int)round(temp_f)); outside_status += "°"; continue; } if (item_count < 4) { items[item_count] = { group, temp["priority"] | 99, (int)round(temp_f), high_alert }; item_count++; } } for (int a = 0; a < item_count - 1; a++) { for (int b = a + 1; b < item_count; b++) { if ( items[b].priority < items[a].priority || (items[b].priority == items[a].priority && items[b].group < items[a].group) ) { TempDisplayItem tmp = items[a]; items[a] = items[b]; items[b] = tmp; } } } String group_names[2] = {"--", "--"}; String group_values[2] = {"--", "--"}; bool group_alerts[2] = {false, false}; int group_count = 0; for (int i = 0; i < item_count; i++) { int slot = -1; String display_group = display_temp_group_name(items[i].group); for (int g = 0; g < group_count; g++) { if (group_names[g] == display_group) { slot = g; break; } } if (slot < 0) { if (group_count >= 2) { continue; } slot = group_count; group_names[slot] = display_group; group_values[slot] = ""; group_count++; } String value = String(items[i].rounded_f); value += "°"; if (group_values[slot].length() > 0) { group_values[slot] += "/"; } group_values[slot] += value; if (items[i].high_alert) { group_alerts[slot] = true; } } update_outside_status_temp(outside_status); for (int i = 0; i < 2; i++) { set_label_text_if_changed(temp_tile_value_labels[i], last_temp_tile_value_text[i], group_values[i]); set_label_text_if_changed(temp_tile_name_labels[i], last_temp_tile_name_text[i], group_names[i]); update_temp_tile_alert_style(i, group_alerts[i]); } } if (doc["relays"].is()) { relay_count = 0; for (JsonObject relay : doc["relays"].as()) { const char *id = relay["id"] | ""; const char *name = relay["name"] | relay["id"] | "Output"; bool enabled = relay["enabled"] | false; bool available = relay["available"] | true; bool state = relay["state"] | false; if (relay_count < 6 && enabled && available) { relay_ids[relay_count] = String(id); relay_names[relay_count] = String(name); relay_states[relay_count] = state; relay_count++; } } update_relay_buttons(); } update_overview_widgets(); update_system_status_label(); update_battery_detail_widgets(); } static void invalidate_overview_widget_cache() { last_battery_current_text = "__force__"; last_battery_estimate_text = "__force__"; last_battery_soc_text = "__force__"; last_system_status_text = "__force__"; last_outside_status_temp_text = "__force__"; for (int i = 0; i < 2; i++) { last_temp_tile_value_text[i] = "__force__"; last_temp_tile_name_text[i] = "__force__"; last_temp_tile_alert[i] = false; } last_battery_card_alert = false; last_cargo_api_ok = !last_cargo_api_ok; } static void refresh_dashboard_widgets() { update_overview_widgets(); update_status_icons(); update_relay_buttons(); if (battery_detail_page != nullptr) { update_battery_detail_widgets(); } } static bool fetch_status_fields(const char *url, const char *label) { if (api_request_in_progress || WiFi.status() != WL_CONNECTED) { return false; } api_request_in_progress = true; unsigned long start_ms = millis(); HTTPClient http; http.setTimeout(300); http.begin(url); http.setReuse(false); http.addHeader("Connection", "close"); int code = http.GET(); status_model.http_code = code; if (code == 200) { String body = http.getString(); parse_status_json(body); Serial.print("TIMING "); Serial.print(label); Serial.print(" total="); Serial.print(millis() - start_ms); Serial.print("ms bytes="); Serial.println(body.length()); http.end(); api_request_in_progress = false; return true; } status_model.api_ok = false; set_label_text_if_changed(system_status_label, last_system_status_text, String("API error: HTTP ") + code); http.end(); api_request_in_progress = false; return false; } static void poll_status_api() { if (WiFi.status() != WL_CONNECTED) { return; } if (fast_status_refresh_requested || millis() - last_status_poll_ms >= 1500) { fast_status_refresh_requested = false; last_status_poll_ms = millis(); fetch_status_fields(CARGO_API_FAST_STATUS_URL, "fast status"); } if (metadata_refresh_requested || millis() - last_metadata_poll_ms >= 30000) { metadata_refresh_requested = false; last_metadata_poll_ms = millis(); fetch_status_fields(CARGO_API_SLOW_STATUS_URL, "slow status"); } } static lv_obj_t *create_card(lv_obj_t *parent, const char *title, int width, int height, lv_align_t align, int x, int y) { lv_obj_t *card = lv_obj_create(parent); lv_obj_set_size(card, width, height); lv_obj_align(card, align, x, y); lv_obj_set_style_radius(card, 18, 0); lv_obj_set_style_bg_color(card, lv_color_hex(0x1F2933), 0); lv_obj_set_style_border_color(card, lv_color_hex(0x3A4652), 0); lv_obj_set_style_border_width(card, 2, 0); lv_obj_set_style_pad_all(card, 0, 0); lv_obj_set_scrollbar_mode(card, LV_SCROLLBAR_MODE_OFF); lv_obj_t *title_label = lv_label_create(card); lv_label_set_text(title_label, title); lv_obj_set_style_text_color(title_label, lv_color_hex(0xB8C0C8), 0); lv_obj_align(title_label, LV_ALIGN_TOP_LEFT, 0, 0); if (title == nullptr || title[0] == '\0') { lv_obj_add_flag(title_label, LV_OBJ_FLAG_HIDDEN); lv_obj_set_size(title_label, 0, 0); } return card; } static void show_boot_screen() { lv_obj_t *screen = lv_scr_act(); lv_obj_clean(screen); lv_obj_set_style_bg_color(screen, lv_color_hex(0x101418), 0); lv_obj_t *title = lv_label_create(screen); lv_label_set_text(title, "Overland Controller"); lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_font(title, &lv_font_montserrat_48, 0); lv_obj_align(title, LV_ALIGN_CENTER, 0, -70); lv_obj_t *subtitle = lv_label_create(screen); lv_label_set_text(subtitle, "ESP32-S3 Dashboard"); lv_obj_set_style_text_color(subtitle, lv_color_hex(0xB8C0C8), 0); lv_obj_set_style_text_font(subtitle, &lv_font_montserrat_30, 0); lv_obj_set_width(subtitle, 800); lv_obj_set_style_text_align(subtitle, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(subtitle, LV_ALIGN_CENTER, 0, -18); lv_obj_t *status = lv_label_create(screen); String boot_status = "Connecting to Cargo ESP WiFi...\n"; boot_status += DASHBOARD_VERSION; lv_label_set_text(status, boot_status.c_str()); lv_obj_set_style_text_color(status, lv_color_hex(0xDDE3EA), 0); lv_obj_set_style_text_font(status, &lv_font_montserrat_30, 0); lv_obj_set_width(status, 850); lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(status, LV_ALIGN_CENTER, 0, 48); lv_obj_t *api = lv_label_create(screen); lv_label_set_text(api, "/api/v1"); lv_obj_set_style_text_color(api, lv_color_hex(0x7D8996), 0); lv_obj_set_style_text_font(api, &lv_font_montserrat_26, 0); lv_obj_set_width(api, 700); lv_obj_set_style_text_align(api, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(api, LV_ALIGN_CENTER, 0, 118); } static void create_overland_overview_screen() { battery_detail_page = nullptr; clear_battery_detail_refs(); lv_obj_t *screen = lv_scr_act(); lv_obj_clean(screen); reset_overview_widget_cache(); current_dashboard_screen = SCREEN_OVERVIEW; 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 *battery_card = create_card(screen, "", 380, 310, LV_ALIGN_TOP_LEFT, 32, 68); battery_card_obj = battery_card; add_battery_card_tap_target(battery_card); lv_obj_t *temp_card = create_card(screen, "", 260, 310, LV_ALIGN_TOP_LEFT, 426, 68); lv_obj_t *vehicle_card = create_card(screen, "", 292, 310, LV_ALIGN_TOP_RIGHT, -32, 68); lv_obj_t *relay_card = create_card(screen, "", 960, 205, LV_ALIGN_BOTTOM_MID, 0, -8); battery_charge_pulse_arc = lv_arc_create(battery_card); lv_obj_set_size(battery_charge_pulse_arc, 286, 286); lv_obj_align(battery_charge_pulse_arc, LV_ALIGN_TOP_MID, 0, 42); lv_arc_set_range(battery_charge_pulse_arc, 0, 100); lv_arc_set_value(battery_charge_pulse_arc, 0); lv_arc_set_rotation(battery_charge_pulse_arc, 135); lv_arc_set_bg_angles(battery_charge_pulse_arc, 0, 270); lv_arc_set_angles(battery_charge_pulse_arc, 0, 0); lv_obj_remove_style(battery_charge_pulse_arc, NULL, LV_PART_KNOB); lv_obj_clear_flag(battery_charge_pulse_arc, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_arc_opa(battery_charge_pulse_arc, LV_OPA_TRANSP, LV_PART_MAIN); lv_obj_set_style_arc_color(battery_charge_pulse_arc, lv_color_hex(0x32D583), LV_PART_INDICATOR); lv_obj_set_style_arc_width(battery_charge_pulse_arc, 24, LV_PART_INDICATOR); lv_obj_set_style_arc_opa(battery_charge_pulse_arc, LV_OPA_TRANSP, LV_PART_INDICATOR); lv_obj_add_flag(battery_charge_pulse_arc, LV_OBJ_FLAG_HIDDEN); battery_soc_arc = lv_arc_create(battery_card); lv_obj_set_size(battery_soc_arc, 262, 262); lv_obj_align(battery_soc_arc, LV_ALIGN_TOP_MID, 0, 49); lv_arc_set_range(battery_soc_arc, 0, 100); lv_arc_set_value(battery_soc_arc, 0); lv_arc_set_rotation(battery_soc_arc, 135); lv_arc_set_bg_angles(battery_soc_arc, 0, 270); lv_arc_set_angles(battery_soc_arc, 0, 0); lv_obj_remove_style(battery_soc_arc, NULL, LV_PART_KNOB); lv_obj_clear_flag(battery_soc_arc, LV_OBJ_FLAG_CLICKABLE); lv_obj_set_style_arc_color(battery_soc_arc, lv_color_hex(0x3A4652), LV_PART_MAIN); lv_obj_set_style_arc_width(battery_soc_arc, 14, LV_PART_MAIN); lv_obj_set_style_arc_color(battery_soc_arc, lv_color_hex(0x84CAFF), LV_PART_INDICATOR); lv_obj_set_style_arc_width(battery_soc_arc, 14, LV_PART_INDICATOR); battery_estimate_label = lv_label_create(battery_card); lv_label_set_text(battery_estimate_label, ""); lv_obj_set_style_text_color(battery_estimate_label, lv_color_hex(0xDDE3EA), 0); lv_obj_set_style_text_font(battery_estimate_label, &lv_font_montserrat_30, 0); lv_obj_set_width(battery_estimate_label, 340); lv_obj_set_style_text_align(battery_estimate_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(battery_estimate_label, LV_ALIGN_TOP_MID, 0, 188); lv_obj_add_flag(battery_estimate_label, LV_OBJ_FLAG_HIDDEN); battery_soc_label = lv_label_create(battery_card); lv_label_set_text(battery_soc_label, "--%"); lv_obj_set_style_text_color(battery_soc_label, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_font(battery_soc_label, &lv_font_montserrat_48, 0); lv_obj_set_width(battery_soc_label, 280); lv_obj_set_style_text_align(battery_soc_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(battery_soc_label, LV_ALIGN_TOP_MID, 0, 90); battery_current_label = lv_label_create(battery_card); lv_label_set_text(battery_current_label, ""); lv_obj_set_style_text_font(battery_current_label, &lv_font_montserrat_26, 0); lv_obj_set_width(battery_current_label, 260); lv_obj_set_style_text_align(battery_current_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(battery_current_label, LV_ALIGN_TOP_MID, 0, 161); lv_obj_add_flag(battery_current_label, LV_OBJ_FLAG_HIDDEN); add_battery_card_tap_target(battery_charge_pulse_arc); add_battery_card_tap_target(battery_soc_arc); add_battery_card_tap_target(battery_estimate_label); add_battery_card_tap_target(battery_soc_label); add_battery_card_tap_target(battery_current_label); const int temp_tile_w = 236; const int temp_tile_h = 132; const int temp_tile_y[2] = {18, 160}; for (int i = 0; i < 2; i++) { lv_obj_t *tile = lv_obj_create(temp_card); temp_tile_objs[i] = tile; lv_obj_set_size(tile, temp_tile_w, temp_tile_h); lv_obj_align(tile, LV_ALIGN_TOP_MID, 0, temp_tile_y[i]); 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_set_scrollbar_mode(tile, LV_SCROLLBAR_MODE_OFF); temp_tile_value_labels[i] = lv_label_create(tile); lv_label_set_text(temp_tile_value_labels[i], "--"); lv_obj_set_style_text_color(temp_tile_value_labels[i], lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_font(temp_tile_value_labels[i], &lv_font_montserrat_40, 0); lv_obj_set_width(temp_tile_value_labels[i], temp_tile_w - 10); lv_obj_set_style_text_align(temp_tile_value_labels[i], LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(temp_tile_value_labels[i], LV_ALIGN_TOP_MID, 0, 22); temp_tile_name_labels[i] = lv_label_create(tile); lv_label_set_text(temp_tile_name_labels[i], "--"); lv_obj_set_style_text_color(temp_tile_name_labels[i], lv_color_hex(0xB8C0C8), 0); lv_obj_set_style_text_font(temp_tile_name_labels[i], &lv_font_montserrat_26, 0); lv_obj_set_width(temp_tile_name_labels[i], temp_tile_w - 10); lv_obj_set_style_text_align(temp_tile_name_labels[i], LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(temp_tile_name_labels[i], LV_ALIGN_TOP_MID, 0, 86); } lv_obj_t *coolant_icon = lv_img_create(vehicle_card); lv_img_set_src(coolant_icon, &thermometer_water); lv_obj_set_style_img_recolor(coolant_icon, lv_color_white(), 0); lv_obj_set_style_img_recolor_opa(coolant_icon, LV_OPA_COVER, 0); lv_img_set_zoom(coolant_icon, 100); lv_obj_align(coolant_icon, LV_ALIGN_TOP_MID, 0, 110); #if LV_USE_METER vehicle_coolant_meter = lv_meter_create(vehicle_card); lv_obj_set_size(vehicle_coolant_meter, 252, 252); lv_obj_align(vehicle_coolant_meter, LV_ALIGN_TOP_MID, 0, 0); lv_obj_set_style_bg_opa(vehicle_coolant_meter, LV_OPA_TRANSP, 0); lv_obj_set_style_border_width(vehicle_coolant_meter, 0, 0); lv_obj_clear_flag(vehicle_coolant_meter, LV_OBJ_FLAG_SCROLLABLE); lv_obj_set_style_text_opa(vehicle_coolant_meter, LV_OPA_TRANSP, LV_PART_TICKS); lv_meter_scale_t *coolant_scale = lv_meter_add_scale(vehicle_coolant_meter); lv_meter_set_scale_range(vehicle_coolant_meter, coolant_scale, 100, 260, 270, 135); lv_meter_set_scale_ticks(vehicle_coolant_meter, coolant_scale, 33, 2, 10, lv_color_hex(0x7D8996)); lv_meter_set_scale_major_ticks(vehicle_coolant_meter, coolant_scale, 4, 4, 16, lv_color_hex(0xDDE3EA), 10); lv_meter_indicator_t *cool_arc = lv_meter_add_arc(vehicle_coolant_meter, coolant_scale, 5, lv_color_hex(0x84CAFF), 0); lv_meter_set_indicator_start_value(vehicle_coolant_meter, cool_arc, 100); lv_meter_set_indicator_end_value(vehicle_coolant_meter, cool_arc, 150); lv_meter_indicator_t *hot_arc = lv_meter_add_arc(vehicle_coolant_meter, coolant_scale, 5, lv_color_hex(0xF97066), 0); lv_meter_set_indicator_start_value(vehicle_coolant_meter, hot_arc, 220); lv_meter_set_indicator_end_value(vehicle_coolant_meter, hot_arc, 260); vehicle_coolant_needle = lv_meter_add_needle_line(vehicle_coolant_meter, coolant_scale, 4, lv_color_hex(0xFFFFFF), -14); lv_meter_set_indicator_value(vehicle_coolant_meter, vehicle_coolant_needle, 188); lv_obj_t *vehicle_coolant_center_cap = lv_obj_create(vehicle_coolant_meter); lv_obj_set_size(vehicle_coolant_center_cap, 54, 54); lv_obj_center(vehicle_coolant_center_cap); lv_obj_set_style_radius(vehicle_coolant_center_cap, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(vehicle_coolant_center_cap, lv_color_hex(0x1F2933), 0); lv_obj_set_style_border_width(vehicle_coolant_center_cap, 0, 0); lv_obj_clear_flag(vehicle_coolant_center_cap, LV_OBJ_FLAG_SCROLLABLE); #endif vehicle_coolant_value_label = lv_label_create(vehicle_card); lv_label_set_text(vehicle_coolant_value_label, "188°"); lv_obj_set_style_text_color(vehicle_coolant_value_label, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_font(vehicle_coolant_value_label, &lv_font_montserrat_40, 0); lv_obj_set_width(vehicle_coolant_value_label, 120); lv_obj_set_style_text_align(vehicle_coolant_value_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(vehicle_coolant_value_label, LV_ALIGN_TOP_MID, 0, 109); lv_obj_t *fuel_icon = lv_img_create(vehicle_card); lv_img_set_src(fuel_icon, &gas_station); lv_obj_set_style_img_recolor(fuel_icon, lv_color_white(), 0); lv_obj_set_style_img_recolor_opa(fuel_icon, LV_OPA_COVER, 0); lv_img_set_zoom(fuel_icon, 90); lv_obj_align(fuel_icon, LV_ALIGN_BOTTOM_LEFT, -40, 35); vehicle_fuel_bar = lv_bar_create(vehicle_card); lv_obj_add_event_cb(vehicle_fuel_bar, fuel_bar_draw_value_cb, LV_EVENT_DRAW_PART_END, NULL); lv_obj_set_size(vehicle_fuel_bar, 202, 22); lv_obj_align(vehicle_fuel_bar, LV_ALIGN_BOTTOM_LEFT, 69, -29); lv_bar_set_range(vehicle_fuel_bar, 0, 100); lv_bar_set_value(vehicle_fuel_bar, 72, LV_ANIM_OFF); lv_obj_set_style_radius(vehicle_fuel_bar, 3, 0); lv_obj_set_style_radius(vehicle_fuel_bar, 3, LV_PART_INDICATOR); lv_obj_set_style_bg_color(vehicle_fuel_bar, lv_color_hex(0x101820), 0); lv_obj_set_style_bg_color(vehicle_fuel_bar, lv_color_hex(0x32D583), LV_PART_INDICATOR); lv_obj_set_style_bg_grad_color(vehicle_fuel_bar, lv_color_hex(0x027A48), LV_PART_INDICATOR); lv_obj_set_style_bg_grad_dir(vehicle_fuel_bar, LV_GRAD_DIR_VER, LV_PART_INDICATOR); lv_obj_set_style_border_color(vehicle_fuel_bar, lv_color_hex(0xDDE3EA), 0); lv_obj_set_style_border_width(vehicle_fuel_bar, 2, 0); vehicle_fuel_value_label = lv_label_create(vehicle_card); lv_label_set_text(vehicle_fuel_value_label, ""); lv_obj_add_flag(vehicle_fuel_value_label, LV_OBJ_FLAG_HIDDEN); for (int i = 0; i < 6; i++) { relay_buttons[i] = lv_btn_create(relay_card); lv_obj_set_size(relay_buttons[i], 200, 178); lv_obj_align(relay_buttons[i], LV_ALIGN_TOP_LEFT, 18 + (i * 212), 10); lv_obj_add_event_cb( relay_buttons[i], relay_button_event_cb, LV_EVENT_CLICKED, reinterpret_cast(static_cast(i)) ); // dashboard-relay-initial-3d-style lv_obj_set_style_radius(relay_buttons[i], 16, 0); lv_obj_set_style_border_width(relay_buttons[i], 2, 0); lv_obj_set_style_border_color(relay_buttons[i], lv_color_hex(0x556270), 0); lv_obj_set_style_bg_color(relay_buttons[i], lv_color_hex(0x3B4754), 0); lv_obj_set_style_bg_grad_color(relay_buttons[i], lv_color_hex(0x1F2933), 0); lv_obj_set_style_bg_grad_dir(relay_buttons[i], LV_GRAD_DIR_VER, 0); lv_obj_set_style_shadow_color(relay_buttons[i], lv_color_hex(0x000000), 0); lv_obj_set_style_shadow_width(relay_buttons[i], 14, 0); lv_obj_set_style_shadow_opa(relay_buttons[i], LV_OPA_40, 0); lv_obj_set_style_shadow_ofs_y(relay_buttons[i], 6, 0); relay_button_labels[i] = lv_label_create(relay_buttons[i]); lv_label_set_text(relay_button_labels[i], "Output\n--"); lv_obj_set_style_text_font(relay_button_labels[i], &lv_font_montserrat_40, 0); lv_obj_set_style_text_align(relay_button_labels[i], LV_TEXT_ALIGN_CENTER, 0); lv_obj_center(relay_button_labels[i]); lv_obj_add_flag(relay_buttons[i], LV_OBJ_FLAG_HIDDEN); } cargo_status_dot = lv_obj_create(system_card); lv_obj_set_size(cargo_status_dot, 18, 18); lv_obj_set_style_radius(cargo_status_dot, LV_RADIUS_CIRCLE, 0); lv_obj_set_style_bg_color(cargo_status_dot, lv_color_hex(0xF97066), 0); lv_obj_set_style_border_width(cargo_status_dot, 0, 0); lv_obj_align(cargo_status_dot, LV_ALIGN_LEFT_MID, 24, 0); system_status_label = lv_label_create(system_card); lv_label_set_text(system_status_label, ""); lv_obj_set_width(system_status_label, 1); lv_obj_add_flag(system_status_label, LV_OBJ_FLAG_HIDDEN); outside_status_temp_label = lv_label_create(system_card); lv_label_set_text(outside_status_temp_label, "OUT --"); lv_obj_set_style_text_color(outside_status_temp_label, lv_color_hex(0xDDE3EA), 0); lv_obj_set_style_text_font(outside_status_temp_label, &lv_font_montserrat_30, 0); lv_obj_set_width(outside_status_temp_label, 220); lv_obj_set_style_text_align(outside_status_temp_label, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(outside_status_temp_label, LV_ALIGN_CENTER, 0, 0); lv_obj_t *page_label = lv_label_create(system_card); lv_label_set_text(page_label, "MAIN"); lv_obj_set_style_text_color(page_label, lv_color_hex(0xB8C0C8), 0); lv_obj_set_style_text_font(page_label, &lv_font_montserrat_26, 0); lv_obj_set_width(page_label, 160); lv_obj_set_style_text_align(page_label, LV_TEXT_ALIGN_RIGHT, 0); lv_obj_align(page_label, LV_ALIGN_RIGHT_MID, -10, 0); } void setup() { Serial.begin(115200); delay(1000); Serial.println(); Serial.println("================================"); Serial.println("Overland Controller Dashboard"); Serial.println("================================"); Serial.print("Version: "); Serial.println(DASHBOARD_VERSION); Serial.println("Target: Waveshare ESP32-S3 Touch LCD 5B"); Serial.println("Display: 1024x600"); Serial.println("API Base: /api/v1"); Board *board = new Board(); board->init(); #if LVGL_PORT_AVOID_TEARING_MODE auto lcd = board->getLCD(); lcd->configFrameBufferNumber(LVGL_PORT_DISP_BUFFER_NUM); #if ESP_PANEL_DRIVERS_BUS_ENABLE_RGB && CONFIG_IDF_TARGET_ESP32S3 auto lcd_bus = lcd->getBus(); if (lcd_bus->getBasicAttributes().type == ESP_PANEL_BUS_TYPE_RGB) { static_cast(lcd_bus)->configRGB_BounceBufferSize(lcd->getFrameWidth() * 10); } #endif #endif assert(board->begin()); lvgl_port_init(board->getLCD(), board->getTouch()); lvgl_port_lock(-1); // TEMP DEBUG: bypass blocking Cargo ESP WiFi screen while troubleshooting pages. create_overland_overview_screen(); overview_screen_created = true; lvgl_port_unlock(); Serial.println("Dashboard boot screen ready"); } void loop() { connect_wifi_if_needed(); update_system_status_label(); process_pending_relay_command(); poll_status_api(); process_dashboard_page_switch(); delay(50); }