#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 lv_obj_t *battery_label = nullptr; static lv_obj_t *temps_label = nullptr; static lv_obj_t *outputs_label = 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 lv_obj_t *system_label = nullptr; static lv_obj_t *wifi_label = nullptr; static lv_obj_t *touch_count_label = nullptr; static uint32_t touch_count = 0; 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 String last_battery_text; static String last_temps_text; static String last_outputs_text; static String last_system_text; static String last_wifi_text; static DashboardStatus status_model; static String on_off(bool value) { return value ? "ON" : "OFF"; } static void force_status_poll_now() { last_status_poll_ms = 0; } static bool post_relay_state(const String &relay_id, bool state) { if (WiFi.status() != WL_CONNECTED) { Serial.println("Relay command skipped: WiFi not connected"); return false; } 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(); 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) { Serial.println("Relay button index out of range"); return; } String relay_id = relay_ids[index]; bool previous_state = relay_states[index]; bool next_state = !previous_state; relay_states[index] = next_state; update_relay_buttons(); update_outputs_label_from_relays(); String pending_text = "Sending relay command...\n"; pending_text += relay_id; pending_text += " -> "; pending_text += on_off(next_state); set_label_text_if_changed(system_label, last_system_text, pending_text); bool ok = post_relay_state(relay_id, next_state); if (ok) { String optimistic_text = "Relay command sent\n"; optimistic_text += relay_id; optimistic_text += " -> "; optimistic_text += on_off(next_state); optimistic_text += "\nRefreshing status..."; set_label_text_if_changed(system_label, last_system_text, optimistic_text); force_status_poll_now(); poll_status_api(); } else { relay_states[index] = previous_state; update_relay_buttons(); update_outputs_label_from_relays(); set_label_text_if_changed(system_label, last_system_text, "Relay command failed"); } } static void update_outputs_label_from_relays() { String outputs = ""; for (int i = 0; i < relay_count && i < 6; i++) { outputs += String(i + 1); outputs += ". "; outputs += relay_names[i].length() > 0 ? relay_names[i] : relay_ids[i]; outputs += ": "; outputs += on_off(relay_states[i]); outputs += "\n"; } if (outputs.length() == 0) { outputs = "No outputs reported"; } status_model.outputs_text = outputs; set_label_text_if_changed(outputs_label, last_outputs_text, outputs); } static void update_relay_buttons() { lvgl_port_lock(-1); 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_ids[i]; label += relay_states[i] ? " OFF" : " ON"; 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 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 update_overview_widgets() { String battery; battery += "SOC: "; battery += status_model.soc >= 0 ? String(status_model.soc) + "%" : "n/a"; battery += "\nVoltage: "; battery += String(status_model.voltage, 2); battery += " V\nCurrent: "; battery += String(status_model.current, 2); battery += " A\nRemaining: "; battery += String(status_model.remaining_ah, 1); battery += " / "; battery += String(status_model.capacity_ah, 0); battery += " Ah\nBatt Temp: "; battery += String(status_model.battery_temp_f, 1); battery += " F\nBMS: "; battery += status_model.bms_connected ? "online" : "offline"; if (status_model.cell_delta_mv >= 0) { battery += "\nCell Delta: "; battery += String(status_model.cell_delta_mv); battery += " mV"; } String system; system += "API: "; system += status_model.api_ok ? "connected" : "error"; system += "\nHTTP: "; system += String(status_model.http_code); system += "\nProfile: "; system += status_model.hardware_profile; system += "\nOutputs: "; system += String(status_model.output_count); system += "\nFirmware: "; system += status_model.firmware_version; system += "\nUptime: "; system += String(status_model.uptime_seconds); system += " sec\nCargo AP: "; system += status_model.cargo_ap_ssid; system += "\nCargo STA IP: "; system += status_model.cargo_sta_ip; set_label_text_if_changed(battery_label, last_battery_text, battery); set_label_text_if_changed(temps_label, last_temps_text, status_model.temps_text); set_label_text_if_changed(outputs_label, last_outputs_text, status_model.outputs_text); set_label_text_if_changed(system_label, last_system_text, system); } static void parse_status_json(const String &body) { DynamicJsonDocument doc(20000); DeserializationError error = deserializeJson(doc, body); if (error) { status_model.api_ok = false; status_model.http_code = 200; String parse_error; parse_error += "JSON parse failed\n"; parse_error += error.c_str(); Serial.println(parse_error); set_label_text_if_changed(battery_label, last_battery_text, "Battery\nJSON parse failed"); set_label_text_if_changed(temps_label, last_temps_text, parse_error); set_label_text_if_changed(outputs_label, last_outputs_text, "Outputs\nNo parsed data"); set_label_text_if_changed(system_label, last_system_text, parse_error); return; } Serial.println("JSON parse OK"); status_model.api_ok = true; status_model.http_code = 200; 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 temps = ""; JsonArray temp_array = doc["temps"]; int shown_temps = 0; for (JsonObject temp : temp_array) { bool enabled = temp["enabled"] | false; bool online = temp["online"] | false; if (!enabled && shown_temps >= 2) { continue; } const char *name = temp["name"] | temp["id"] | "Temp"; temps += name; temps += ": "; if (!enabled) { temps += "disabled"; } else if (!online) { temps += "offline"; } else if (temp["temperature_f"].isNull()) { temps += "n/a"; } else { float temperature_f = temp["temperature_f"] | 0.0; temps += String(temperature_f, 1); temps += " F"; } temps += "\n"; shown_temps++; if (shown_temps >= 5) { break; } } if (temps.length() == 0) { temps = "No temp sensors reported"; } status_model.temps_text = temps; } if (doc["relays"].is()) { String outputs = ""; JsonArray relay_array = doc["relays"]; relay_count = 0; for (JsonObject relay : relay_array) { 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; int channel = relay["hardware_channel"] | 0; 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++; } outputs += String(channel); outputs += ". "; outputs += name; outputs += ": "; if (!available) { outputs += "unavailable"; } else if (!enabled) { outputs += "disabled"; } else { outputs += on_off(state); } outputs += "\n"; } if (outputs.length() == 0) { outputs = "No outputs reported"; } status_model.outputs_text = outputs; update_relay_buttons(); } Serial.println("Parsed status summary:"); Serial.print(" SOC: "); Serial.println(status_model.soc); Serial.print(" Voltage: "); Serial.println(status_model.voltage); Serial.print(" Temps text: "); Serial.println(status_model.temps_text); Serial.print(" Outputs text: "); Serial.println(status_model.outputs_text); update_overview_widgets(); } static void touch_test_event_cb(lv_event_t *event) { if (lv_event_get_code(event) != LV_EVENT_CLICKED) { return; } touch_count++; if (touch_count_label != nullptr) { lvgl_port_lock(-1); lv_label_set_text_fmt(touch_count_label, "Touch count: %lu", (unsigned long)touch_count); lvgl_port_unlock(); } Serial.print("Touch count: "); Serial.println(touch_count); } 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_wifi_label() { if (millis() - last_wifi_label_update_ms < 5000) { return; } last_wifi_label_update_ms = millis(); String text; if (WiFi.status() == WL_CONNECTED) { text += "Dashboard WiFi: connected"; text += "\nIP: "; text += WiFi.localIP().toString(); text += "\nRSSI: "; text += String(WiFi.RSSI()); text += " dBm"; } else { text += "Dashboard WiFi: not connected"; text += "\nSSID: "; text += CARGO_WIFI_SSID; } set_label_text_if_changed(wifi_label, last_wifi_text, text); } static bool fetch_status_fields(const char *url, const char *label) { if (WiFi.status() != WL_CONNECTED) { set_label_text_if_changed(system_label, last_system_text, "API: waiting for WiFi"); return false; } Serial.print("GET "); Serial.println(url); HTTPClient http; http.setTimeout(5000); http.begin(url); int code = http.GET(); status_model.http_code = code; Serial.print(label); Serial.print(" HTTP code: "); Serial.println(code); if (code == 200) { String body = http.getString(); Serial.print(label); Serial.print(" body length: "); Serial.println(body.length()); parse_status_json(body); http.end(); return true; } status_model.api_ok = false; String system; system += "API: error\n"; system += label; system += "\nHTTP: "; system += String(code); if (code <= 0) { system += "\n"; system += http.errorToString(code); } Serial.println(system); set_label_text_if_changed(system_label, last_system_text, system); http.end(); return false; } static void poll_status_api() { if (WiFi.status() != WL_CONNECTED) { set_label_text_if_changed(system_label, last_system_text, "API: waiting for WiFi"); return; } if (millis() - last_status_poll_ms >= 2000) { last_status_poll_ms = millis(); fetch_status_fields(CARGO_API_FAST_STATUS_URL, "fast status"); } if (millis() - last_metadata_poll_ms >= 30000) { 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, 16, 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, 12, 0); 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(0xFFFFFF), 0); lv_obj_align(title_label, LV_ALIGN_TOP_LEFT, 0, 0); return card; } static lv_obj_t *create_body_label(lv_obj_t *card, int width) { lv_obj_t *label = lv_label_create(card); lv_label_set_text(label, "Waiting for /api/v1/status..."); lv_obj_set_style_text_color(label, lv_color_hex(0xDDE3EA), 0); lv_obj_set_width(label, width); lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP); lv_obj_align(label, LV_ALIGN_TOP_LEFT, 0, 32); return label; } static void create_overland_status_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_30, 0); lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16); lv_obj_t *subtitle = lv_label_create(screen); lv_label_set_text(subtitle, "ESP32-S3 Dashboard - Cargo ESP Overview"); lv_obj_set_style_text_color(subtitle, lv_color_hex(0xB8C0C8), 0); lv_obj_set_width(subtitle, 920); lv_obj_set_style_text_align(subtitle, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align_to(subtitle, title, LV_ALIGN_OUT_BOTTOM_MID, 0, 6); lv_obj_t *battery_card = create_card(screen, "Battery", 300, 230, LV_ALIGN_TOP_LEFT, 32, 96); lv_obj_t *temps_card = create_card(screen, "Temperatures", 300, 230, LV_ALIGN_TOP_MID, 0, 96); lv_obj_t *outputs_card = create_card(screen, "Outputs", 300, 230, LV_ALIGN_TOP_RIGHT, -32, 96); lv_obj_t *system_card = create_card(screen, "System / Network", 620, 190, LV_ALIGN_BOTTOM_LEFT, 32, -50); lv_obj_t *touch_card = create_card(screen, "Relay Controls", 300, 190, LV_ALIGN_BOTTOM_RIGHT, -32, -50); battery_label = create_body_label(battery_card, 260); temps_label = create_body_label(temps_card, 260); outputs_label = create_body_label(outputs_card, 260); lv_label_set_text(battery_label, "Waiting for battery data..."); lv_label_set_text(temps_label, "Waiting for temp data..."); lv_label_set_text(outputs_label, "Waiting for output data..."); wifi_label = create_body_label(system_card, 275); system_label = lv_label_create(system_card); lv_label_set_text(system_label, "API: waiting"); lv_obj_set_style_text_color(system_label, lv_color_hex(0xB8C0C8), 0); lv_obj_set_width(system_label, 285); lv_label_set_long_mode(system_label, LV_LABEL_LONG_WRAP); lv_obj_align(system_label, LV_ALIGN_TOP_LEFT, 305, 32); for (int i = 0; i < 2; i++) { relay_buttons[i] = lv_btn_create(touch_card); lv_obj_set_size(relay_buttons[i], 120, 48); lv_obj_align(relay_buttons[i], LV_ALIGN_TOP_LEFT, i * 132, 34); 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], "Relay"); lv_obj_center(relay_button_labels[i]); } for (int i = 2; i < 6; i++) { relay_buttons[i] = lv_btn_create(touch_card); lv_obj_set_size(relay_buttons[i], 120, 42); lv_obj_align(relay_buttons[i], LV_ALIGN_TOP_LEFT, ((i - 2) % 2) * 132, 92 + (((i - 2) / 2) * 48)); 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], "Relay"); lv_obj_center(relay_button_labels[i]); lv_obj_add_flag(relay_buttons[i], LV_OBJ_FLAG_HIDDEN); } touch_count_label = lv_label_create(touch_card); lv_label_set_text(touch_count_label, "Relay controls"); lv_obj_set_style_text_color(touch_count_label, lv_color_hex(0xB8C0C8), 0); lv_obj_set_width(touch_count_label, 250); lv_obj_align(touch_count_label, LV_ALIGN_TOP_LEFT, 0, 142); lv_obj_t *footer = lv_label_create(screen); lv_label_set_text(footer, "Fast: battery/temps/relays every 2s | Slow: system/config/network every 30s"); lv_obj_set_style_text_color(footer, lv_color_hex(0x7D8996), 0); lv_obj_set_width(footer, 900); lv_obj_set_style_text_align(footer, LV_TEXT_ALIGN_CENTER, 0); lv_obj_align(footer, LV_ALIGN_BOTTOM_MID, 0, -14); } 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); create_overland_status_screen(); lvgl_port_unlock(); Serial.println("Dashboard clean overview ready"); } void loop() { connect_wifi_if_needed(); update_wifi_label(); poll_status_api(); delay(500); }