#include #include #include #include #include #include #include "lvgl_v8_port.h" #include "dashboard_config.h" #include "dashboard_status_model.h" using namespace esp_panel::drivers; using namespace esp_panel::board; static DashboardStatus status_model; 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 *inside_temp_label = nullptr; static lv_obj_t *outside_temp_label = nullptr; static lv_obj_t *system_status_label = nullptr; static lv_obj_t *wifi_icon_label = nullptr; static lv_obj_t *wifi_bars_obj[4] = {nullptr}; static lv_obj_t *cargo_status_dot = nullptr; static lv_obj_t *relay_buttons[6] = {nullptr}; static lv_obj_t *relay_button_labels[6] = {nullptr}; 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 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 = false; static String pending_relay_id; static bool pending_relay_state = false; static int pending_relay_index = -1; static bool pending_relay_previous_state = false; static String last_battery_current_text; static String last_battery_estimate_text; static String last_battery_soc_text; static String last_inside_temp_text; static String last_outside_temp_text; static String last_system_status_text; static String on_off(bool value) { return value ? "ON" : "OFF"; } 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 int wifi_bar_count() { if (WiFi.status() != WL_CONNECTED) { return 0; } int rssi = WiFi.RSSI(); if (rssi > -60) { return 4; } if (rssi > -70) { return 3; } if (rssi > -80) { return 2; } return 1; } static void update_status_icons() { if (wifi_icon_label == nullptr || cargo_status_dot == nullptr) { return; } lvgl_port_lock(-1); int bars = wifi_bar_count(); for (int i = 0; i < 4; i++) { if (wifi_bars_obj[i] == nullptr) { continue; } lv_obj_set_style_bg_color( wifi_bars_obj[i], i < bars ? lv_color_hex(0xDDE3EA) : lv_color_hex(0x3A4652), 0 ); } lv_obj_set_style_bg_color( cargo_status_dot, status_model.api_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 = true; } static void create_overland_overview_screen(); static void show_boot_screen(); static void layout_relay_buttons() { int visible_count = relay_count; if (visible_count < 1) { visible_count = 1; } if (visible_count > 6) { visible_count = 6; } int available_width = RELAY_STRIP_W - 28; int button_width = (available_width - ((visible_count - 1) * RELAY_BUTTON_GAP)) / visible_count; if (button_width < 110) { button_width = 110; } if (button_width > 210) { button_width = 210; } int total_width = (button_width * visible_count) + (RELAY_BUTTON_GAP * (visible_count - 1)); int start_x = (available_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, 150); lv_obj_align(relay_buttons[i], LV_ALIGN_TOP_LEFT, start_x + (i * (button_width + RELAY_BUTTON_GAP)), 18); } } } 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()); } 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(5000); http.begin(CARGO_API_RELAY_SET_URL); http.addHeader("Content-Type", "application/json"); int code = http.POST(payload); String body = http.getString(); Serial.print("Relay HTTP code: "); Serial.println(code); Serial.println(body); http.end(); api_request_in_progress = false; return code >= 200 && code < 300; } 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 || relay_command_pending) { return; } bool previous_state = relay_states[index]; bool next_state = !previous_state; relay_states[index] = next_state; update_relay_buttons(); pending_relay_id = relay_ids[index]; pending_relay_state = next_state; pending_relay_index = index; pending_relay_previous_state = previous_state; relay_command_pending = 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 (!relay_command_pending || api_request_in_progress) { return; } String relay_id = pending_relay_id; bool next_state = pending_relay_state; int index = pending_relay_index; bool previous_state = pending_relay_previous_state; bool ok = post_relay_state(relay_id, next_state); relay_command_pending = false; pending_relay_id = ""; pending_relay_index = -1; if (ok) { set_label_text_if_changed(system_status_label, last_system_status_text, "Relay command sent"); request_fast_status_refresh(); } else { if (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 = true; 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 < 1000) { return; } last_wifi_label_update_ms = millis(); update_status_icons(); } 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 = "Runtime " + 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; 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; } 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())); } if (doc["temps"].is()) { String inside = "Inside\n--"; String outside = "Outside\n--"; 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; } String value = String((float)(temp["temperature_f"] | 0.0), 1); value += " F"; if (weather) { outside = "Outside\n" + value; } else { const char *name = temp["name"] | "Inside"; inside = String(name) + "\n" + value; } } set_label_text_if_changed(inside_temp_label, last_inside_temp_text, inside); set_label_text_if_changed(outside_temp_label, last_outside_temp_text, outside); } 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(); } 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(5000); http.begin(url); 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 >= 2000) { 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_40, 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_width(subtitle, 700); lv_obj_set_style_text_align(subtitle, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(subtitle, LV_ALIGN_CENTER, 0, -25); lv_obj_t *status = lv_label_create(screen); lv_label_set_text(status, "Connecting to Cargo ESP WiFi..."); lv_obj_set_style_text_color(status, lv_color_hex(0x7D8996), 0); lv_obj_set_width(status, 700); lv_obj_set_style_text_align(status, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(status, LV_ALIGN_CENTER, 0, 35); 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(0x3A4652), 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, 80); } static void create_overland_overview_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 *system_card = create_card(screen, "", 960, 48, LV_ALIGN_TOP_MID, 0, 12); lv_obj_t *battery_card = create_card(screen, "", 380, 310, LV_ALIGN_TOP_LEFT, 32, 76); lv_obj_t *temp_card = create_card(screen, "", 260, 310, LV_ALIGN_TOP_LEFT, 426, 76); lv_obj_t *vehicle_card = create_card(screen, "", 274, 310, LV_ALIGN_TOP_RIGHT, -32, 76); lv_obj_t *relay_card = create_card(screen, "", RELAY_STRIP_W, 126, LV_ALIGN_TOP_LEFT, RELAY_STRIP_X, 408); 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_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, 166); lv_obj_add_flag(battery_current_label, LV_OBJ_FLAG_HIDDEN); inside_temp_label = lv_label_create(temp_card); lv_label_set_text(inside_temp_label, "Inside\n--"); lv_obj_set_style_text_font( inside_temp_label, &lv_font_montserrat_30, 0 ); lv_obj_set_style_text_color(inside_temp_label, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_font(inside_temp_label, &lv_font_montserrat_40, 0); lv_obj_set_width(inside_temp_label, 260); lv_obj_align(inside_temp_label, LV_ALIGN_TOP_LEFT, 0, 42); outside_temp_label = lv_label_create(temp_card); lv_label_set_text(outside_temp_label, "Outside\n--"); lv_obj_set_style_text_font( outside_temp_label, &lv_font_montserrat_30, 0 ); lv_obj_set_style_text_color(outside_temp_label, lv_color_hex(0xFFFFFF), 0); lv_obj_set_style_text_font(outside_temp_label, &lv_font_montserrat_40, 0); lv_obj_set_width(outside_temp_label, 260); lv_obj_align(outside_temp_label, LV_ALIGN_TOP_LEFT, 0, 172); lv_obj_t *vehicle_label = lv_label_create(vehicle_card); lv_label_set_text(vehicle_label, "Vehicle\n\nOffline\n\nFuture:\nCoolant\nTrans Temp\n4WD\nTilt/Roll"); lv_obj_set_style_text_color(vehicle_label, lv_color_hex(0xDDE3EA), 0); lv_obj_set_width(vehicle_label, 220); lv_label_set_long_mode(vehicle_label, LV_LABEL_LONG_WRAP); lv_obj_align(vehicle_label, LV_ALIGN_CENTER, 0, 0); for (int i = 0; i < 6; i++) { relay_buttons[i] = lv_btn_create(relay_card); lv_obj_set_size(relay_buttons[i], 140, 150); lv_obj_align(relay_buttons[i], LV_ALIGN_TOP_LEFT, 14 + (i * 150), 18); lv_obj_add_event_cb( relay_buttons[i], relay_button_event_cb, LV_EVENT_CLICKED, reinterpret_cast(static_cast(i)) ); 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); } wifi_icon_label = lv_label_create(system_card); lv_label_set_text(wifi_icon_label, ""); lv_obj_add_flag(wifi_icon_label, LV_OBJ_FLAG_HIDDEN); for (int i = 0; i < 4; i++) { wifi_bars_obj[i] = lv_obj_create(system_card); lv_obj_set_size(wifi_bars_obj[i], 10, 10 + (i * 7)); lv_obj_set_style_radius(wifi_bars_obj[i], 2, 0); lv_obj_set_style_border_width(wifi_bars_obj[i], 0, 0); lv_obj_set_style_bg_color(wifi_bars_obj[i], lv_color_hex(0x3A4652), 0); lv_obj_align(wifi_bars_obj[i], LV_ALIGN_LEFT_MID, 12 + (i * 16), 10 - (i * 4)); } 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, 98, 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); 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, 140); lv_obj_set_style_text_align(page_dots, LV_TEXT_ALIGN_RIGHT, 0); lv_obj_align(page_dots, 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); show_boot_screen(); 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(); delay(250); }