Files
overland-controller/firmware/esp32-s3-dashboard/esp32-s3-dashboard.ino
T

1174 lines
39 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"
#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 *temp_tile_value_labels[2] = {nullptr};
static lv_obj_t *temp_tile_name_labels[2] = {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 *outside_status_temp_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 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_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 int last_wifi_bar_count = -1;
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 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_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 (wifi_icon_label == nullptr || cargo_status_dot == nullptr) {
return;
}
int bars = wifi_bar_count();
bool cargo_ok = status_model.api_ok;
if (bars == last_wifi_bar_count && cargo_ok == last_cargo_api_ok) {
return;
}
last_wifi_bar_count = bars;
last_cargo_api_ok = cargo_ok;
lvgl_port_lock(-1);
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,
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 = 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;
}
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(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<intptr_t>(lv_event_get_user_data(event));
int index = static_cast<int>(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 < 5000) {
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 = "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;
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;
}
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()));
}
if (doc["temps"].is<JsonArray>()) {
String outside_status = "OUT --";
struct TempDisplayItem {
String group;
int priority;
int rounded_f;
};
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);
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);
// Outside/weather is shown in the status strip, not the main temp card.
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)
};
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] = {"--", "--"};
int group_count = 0;
for (int i = 0; i < item_count; i++) {
int slot = -1;
for (int g = 0; g < group_count; g++) {
if (group_names[g] == display_temp_group_name(items[i].group)) {
slot = g;
break;
}
}
if (slot < 0) {
if (group_count >= 2) {
continue;
}
slot = group_count;
group_names[slot] = display_temp_group_name(items[i].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;
}
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]);
}
}
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();
}
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_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()
{
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, "", 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_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);
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 *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], 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);
}
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);
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, "● ○ ○ ○");
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<BusRGB *>(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);
}