1945 lines
67 KiB
Arduino
1945 lines
67 KiB
Arduino
#include <Arduino.h>
|
|
#include <WiFi.h>
|
|
#include <HTTPClient.h>
|
|
#include <ArduinoJson.h>
|
|
#include <esp_display_panel.hpp>
|
|
|
|
#include <lvgl.h>
|
|
#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);
|
|
|
|
if (current_dashboard_screen == SCREEN_OVERVIEW && dir == LV_DIR_RIGHT) {
|
|
queue_dashboard_page_switch(SCREEN_BATTERY_DETAIL);
|
|
} else if (current_dashboard_screen == SCREEN_BATTERY_DETAIL && dir == LV_DIR_LEFT) {
|
|
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, "", 285, 118, 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, 12);
|
|
|
|
*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, 250);
|
|
lv_obj_set_style_text_align(*value, LV_TEXT_ALIGN_LEFT, 0);
|
|
lv_obj_align(*value, LV_ALIGN_TOP_LEFT, 16, 58);
|
|
}
|
|
|
|
static void update_battery_detail_widgets()
|
|
{
|
|
if (battery_detail_soc_label == nullptr) return;
|
|
|
|
String soc = status_model.soc >= 0 ? String(status_model.soc) + "%" : "--%";
|
|
String voltage = String(status_model.voltage, 2) + " V";
|
|
String current = battery_detail_signed(status_model.current, 2, " A");
|
|
String power = battery_detail_signed(status_model.voltage * status_model.current, 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";
|
|
|
|
set_label_text_if_changed(battery_detail_soc_label, last_battery_detail_soc_text, soc);
|
|
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);
|
|
}
|
|
|
|
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;
|
|
|
|
lv_obj_t *system_card = create_card(battery_detail_page, "", 960, 48, LV_ALIGN_TOP_MID, 0, 12);
|
|
|
|
lv_obj_t *title = lv_label_create(system_card);
|
|
lv_label_set_text(title, "BATTERY DETAIL");
|
|
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
|
|
lv_obj_set_style_text_font(title, &lv_font_montserrat_26, 0);
|
|
lv_obj_align(title, LV_ALIGN_LEFT_MID, 22, 0);
|
|
|
|
lv_obj_t *hint = lv_label_create(system_card);
|
|
lv_label_set_text(hint, "Swipe left for main");
|
|
lv_obj_set_style_text_color(hint, lv_color_hex(0xB8C0C8), 0);
|
|
lv_obj_set_style_text_font(hint, &lv_font_montserrat_26, 0);
|
|
lv_obj_align(hint, LV_ALIGN_RIGHT_MID, -18, 0);
|
|
|
|
lv_obj_t *soc_card = create_card(battery_detail_page, "", 300, 500, LV_ALIGN_TOP_LEFT, 32, 78);
|
|
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_CENTER, 0, -20);
|
|
|
|
battery_detail_value_label(battery_detail_page, "Voltage", &battery_detail_voltage_label, 356, 78);
|
|
battery_detail_value_label(battery_detail_page, "Current", &battery_detail_current_label, 670, 78);
|
|
battery_detail_value_label(battery_detail_page, "Power", &battery_detail_power_label, 356, 220);
|
|
battery_detail_value_label(battery_detail_page, "Remaining", &battery_detail_remaining_label, 670, 220);
|
|
battery_detail_value_label(battery_detail_page, "Capacity", &battery_detail_capacity_label, 356, 362);
|
|
battery_detail_value_label(battery_detail_page, "Battery Temp", &battery_detail_temp_label, 670, 362);
|
|
battery_detail_value_label(battery_detail_page, "Cell Delta", &battery_detail_delta_label, 356, 504);
|
|
|
|
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 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>()) {
|
|
bool applied_state = response["state"].as<bool>();
|
|
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<intptr_t>(lv_event_get_user_data(event));
|
|
int index = static_cast<int>(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>()) {
|
|
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>()) {
|
|
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>()) {
|
|
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>()) {
|
|
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>()) {
|
|
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<JsonArray>()) {
|
|
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<JsonArray>()) {
|
|
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;
|
|
|
|
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<JsonArray>()) {
|
|
relay_count = 0;
|
|
|
|
for (JsonObject relay : doc["relays"].as<JsonArray>()) {
|
|
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;
|
|
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);
|
|
|
|
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<void *>(static_cast<intptr_t>(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_dots = lv_label_create(system_card);
|
|
lv_label_set_text(page_dots, "< MAIN");
|
|
lv_obj_set_style_text_color(page_dots, lv_color_hex(0xB8C0C8), 0);
|
|
lv_obj_set_width(page_dots, 120);
|
|
lv_obj_set_style_text_align(page_dots, LV_TEXT_ALIGN_RIGHT, 0);
|
|
lv_obj_align(page_dots, LV_ALIGN_RIGHT_MID, -10, 0);
|
|
}
|
|
|
|
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<BusRGB *>(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);
|
|
}
|