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

682 lines
21 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 lv_obj_t *battery_label = nullptr;
static lv_obj_t *temps_label = nullptr;
static lv_obj_t *outputs_label = nullptr;
static lv_obj_t *relay_buttons[6] = {nullptr};
static lv_obj_t *relay_button_labels[6] = {nullptr};
static String relay_ids[6];
static String relay_names[6];
static bool relay_states[6] = {false};
static int relay_count = 0;
static lv_obj_t *system_label = nullptr;
static lv_obj_t *wifi_label = nullptr;
static lv_obj_t *touch_count_label = nullptr;
static uint32_t touch_count = 0;
static unsigned long last_wifi_attempt_ms = 0;
static unsigned long last_wifi_label_update_ms = 0;
static unsigned long last_status_poll_ms = 0;
static String last_battery_text;
static String last_temps_text;
static String last_outputs_text;
static String last_system_text;
static String last_wifi_text;
static DashboardStatus status_model;
static String on_off(bool value)
{
return value ? "ON" : "OFF";
}
static void force_status_poll_now()
{
last_status_poll_ms = 0;
}
static bool post_relay_state(const String &relay_id, bool state)
{
if (WiFi.status() != WL_CONNECTED) {
Serial.println("Relay command skipped: WiFi not connected");
return false;
}
DynamicJsonDocument request(256);
request["id"] = relay_id;
request["state"] = state;
String payload;
serializeJson(request, payload);
Serial.print("POST ");
Serial.println(CARGO_API_RELAY_SET_URL);
Serial.println(payload);
HTTPClient http;
http.setTimeout(5000);
http.begin(CARGO_API_RELAY_SET_URL);
http.addHeader("Content-Type", "application/json");
int code = http.POST(payload);
String body = http.getString();
Serial.print("Relay HTTP code: ");
Serial.println(code);
Serial.println(body);
http.end();
return code >= 200 && code < 300;
}
static void relay_button_event_cb(lv_event_t *event)
{
if (lv_event_get_code(event) != LV_EVENT_CLICKED) {
return;
}
intptr_t index_value = reinterpret_cast<intptr_t>(lv_event_get_user_data(event));
int index = static_cast<int>(index_value);
if (index < 0 || index >= relay_count) {
Serial.println("Relay button index out of range");
return;
}
String relay_id = relay_ids[index];
bool previous_state = relay_states[index];
bool next_state = !previous_state;
relay_states[index] = next_state;
update_relay_buttons();
update_outputs_label_from_relays();
String pending_text = "Sending relay command...\n";
pending_text += relay_id;
pending_text += " -> ";
pending_text += on_off(next_state);
set_label_text_if_changed(system_label, last_system_text, pending_text);
bool ok = post_relay_state(relay_id, next_state);
if (ok) {
String optimistic_text = "Relay command sent\n";
optimistic_text += relay_id;
optimistic_text += " -> ";
optimistic_text += on_off(next_state);
optimistic_text += "\nRefreshing status...";
set_label_text_if_changed(system_label, last_system_text, optimistic_text);
force_status_poll_now();
poll_status_api();
} else {
relay_states[index] = previous_state;
update_relay_buttons();
update_outputs_label_from_relays();
set_label_text_if_changed(system_label, last_system_text, "Relay command failed");
}
}
static void update_outputs_label_from_relays()
{
String outputs = "";
for (int i = 0; i < relay_count && i < 6; i++) {
outputs += String(i + 1);
outputs += ". ";
outputs += relay_names[i].length() > 0 ? relay_names[i] : relay_ids[i];
outputs += ": ";
outputs += on_off(relay_states[i]);
outputs += "\n";
}
if (outputs.length() == 0) {
outputs = "No outputs reported";
}
status_model.outputs_text = outputs;
set_label_text_if_changed(outputs_label, last_outputs_text, outputs);
}
static void update_relay_buttons()
{
lvgl_port_lock(-1);
for (int i = 0; i < 6; i++) {
if (relay_buttons[i] == nullptr || relay_button_labels[i] == nullptr) {
continue;
}
if (i < relay_count) {
lv_obj_clear_flag(relay_buttons[i], LV_OBJ_FLAG_HIDDEN);
String label = relay_ids[i];
label += relay_states[i] ? " OFF" : " ON";
lv_label_set_text(relay_button_labels[i], label.c_str());
} else {
lv_obj_add_flag(relay_buttons[i], LV_OBJ_FLAG_HIDDEN);
}
}
lvgl_port_unlock();
}
static void set_label_text_if_changed(lv_obj_t *label, String &last_text, const String &text)
{
if (label == nullptr || last_text == text) {
return;
}
last_text = text;
lvgl_port_lock(-1);
lv_label_set_text(label, text.c_str());
lvgl_port_unlock();
}
static void update_overview_widgets()
{
String battery;
battery += "SOC: ";
battery += status_model.soc >= 0 ? String(status_model.soc) + "%" : "n/a";
battery += "\nVoltage: ";
battery += String(status_model.voltage, 2);
battery += " V\nCurrent: ";
battery += String(status_model.current, 2);
battery += " A\nRemaining: ";
battery += String(status_model.remaining_ah, 1);
battery += " / ";
battery += String(status_model.capacity_ah, 0);
battery += " Ah\nBatt Temp: ";
battery += String(status_model.battery_temp_f, 1);
battery += " F\nBMS: ";
battery += status_model.bms_connected ? "online" : "offline";
if (status_model.cell_delta_mv >= 0) {
battery += "\nCell Delta: ";
battery += String(status_model.cell_delta_mv);
battery += " mV";
}
String system;
system += "API: ";
system += status_model.api_ok ? "connected" : "error";
system += "\nHTTP: ";
system += String(status_model.http_code);
system += "\nProfile: ";
system += status_model.hardware_profile;
system += "\nOutputs: ";
system += String(status_model.output_count);
system += "\nFirmware: ";
system += status_model.firmware_version;
system += "\nUptime: ";
system += String(status_model.uptime_seconds);
system += " sec\nCargo AP: ";
system += status_model.cargo_ap_ssid;
system += "\nCargo STA IP: ";
system += status_model.cargo_sta_ip;
set_label_text_if_changed(battery_label, last_battery_text, battery);
set_label_text_if_changed(temps_label, last_temps_text, status_model.temps_text);
set_label_text_if_changed(outputs_label, last_outputs_text, status_model.outputs_text);
set_label_text_if_changed(system_label, last_system_text, system);
}
static void parse_status_json(const String &body)
{
DynamicJsonDocument doc(20000);
DeserializationError error = deserializeJson(doc, body);
if (error) {
status_model.api_ok = false;
status_model.http_code = 200;
String parse_error;
parse_error += "JSON parse failed\n";
parse_error += error.c_str();
Serial.println(parse_error);
set_label_text_if_changed(battery_label, last_battery_text, "Battery\nJSON parse failed");
set_label_text_if_changed(temps_label, last_temps_text, parse_error);
set_label_text_if_changed(outputs_label, last_outputs_text, "Outputs\nNo parsed data");
set_label_text_if_changed(system_label, last_system_text, parse_error);
return;
}
Serial.println("JSON parse OK");
status_model.api_ok = true;
status_model.http_code = 200;
JsonObject battery = doc["battery"];
status_model.bms_connected = battery["connected"] | false;
status_model.soc = battery["soc"] | -1;
status_model.voltage = battery["voltage"] | 0.0;
status_model.current = battery["current"] | 0.0;
status_model.battery_temp_f = battery["temperature_f"] | 0.0;
status_model.remaining_ah = battery["remaining_ah"] | 0.0;
status_model.capacity_ah = battery["capacity_ah"] | 0.0;
status_model.cell_delta_mv = battery["cell_delta_mv"] | -1;
JsonObject config = doc["config"];
status_model.hardware_profile = String((const char *)(config["hardware_profile"] | "unknown"));
status_model.output_count = config["output_count"] | 0;
JsonObject system = doc["system"];
status_model.firmware_version = String((const char *)(system["firmware_version"] | "unknown"));
status_model.uptime_seconds = system["uptime_seconds"] | 0;
JsonObject network = doc["network"];
status_model.cargo_ap_ssid = String((const char *)(network["ap_ssid"] | "unknown"));
status_model.cargo_sta_ip = String((const char *)(network["sta_ip"] | "n/a"));
String temps = "";
JsonArray temp_array = doc["temps"];
int shown_temps = 0;
for (JsonObject temp : temp_array) {
bool enabled = temp["enabled"] | false;
bool online = temp["online"] | false;
if (!enabled && shown_temps >= 2) {
continue;
}
const char *name = temp["name"] | temp["id"] | "Temp";
temps += name;
temps += ": ";
if (!enabled) {
temps += "disabled";
} else if (!online) {
temps += "offline";
} else if (temp["temperature_f"].isNull()) {
temps += "n/a";
} else {
float temperature_f = temp["temperature_f"] | 0.0;
temps += String(temperature_f, 1);
temps += " F";
}
temps += "\n";
shown_temps++;
if (shown_temps >= 5) {
break;
}
}
if (temps.length() == 0) {
temps = "No temp sensors reported";
}
String outputs = "";
JsonArray relay_array = doc["relays"];
relay_count = 0;
for (JsonObject relay : relay_array) {
const char *id = relay["id"] | "";
const char *name = relay["name"] | relay["id"] | "Output";
bool enabled = relay["enabled"] | false;
bool available = relay["available"] | true;
bool state = relay["state"] | false;
int channel = relay["hardware_channel"] | 0;
if (relay_count < 6 && enabled && available) {
relay_ids[relay_count] = String(id);
relay_names[relay_count] = String(name);
relay_states[relay_count] = state;
relay_count++;
}
outputs += String(channel);
outputs += ". ";
outputs += name;
outputs += ": ";
if (!available) {
outputs += "unavailable";
} else if (!enabled) {
outputs += "disabled";
} else {
outputs += on_off(state);
}
outputs += "\n";
}
if (outputs.length() == 0) {
outputs = "No outputs reported";
}
status_model.temps_text = temps;
status_model.outputs_text = outputs;
update_relay_buttons();
Serial.println("Parsed status summary:");
Serial.print(" SOC: ");
Serial.println(status_model.soc);
Serial.print(" Voltage: ");
Serial.println(status_model.voltage);
Serial.print(" Temps text: ");
Serial.println(status_model.temps_text);
Serial.print(" Outputs text: ");
Serial.println(status_model.outputs_text);
update_overview_widgets();
}
static void touch_test_event_cb(lv_event_t *event)
{
if (lv_event_get_code(event) != LV_EVENT_CLICKED) {
return;
}
touch_count++;
if (touch_count_label != nullptr) {
lvgl_port_lock(-1);
lv_label_set_text_fmt(touch_count_label, "Touch count: %lu", (unsigned long)touch_count);
lvgl_port_unlock();
}
Serial.print("Touch count: ");
Serial.println(touch_count);
}
static void connect_wifi_if_needed()
{
if (WiFi.status() == WL_CONNECTED) {
return;
}
if (millis() - last_wifi_attempt_ms < 10000) {
return;
}
last_wifi_attempt_ms = millis();
Serial.print("Connecting to Cargo ESP AP: ");
Serial.println(CARGO_WIFI_SSID);
WiFi.mode(WIFI_STA);
WiFi.begin(CARGO_WIFI_SSID, CARGO_WIFI_PASSWORD);
}
static void update_wifi_label()
{
if (millis() - last_wifi_label_update_ms < 5000) {
return;
}
last_wifi_label_update_ms = millis();
String text;
if (WiFi.status() == WL_CONNECTED) {
text += "Dashboard WiFi: connected";
text += "\nIP: ";
text += WiFi.localIP().toString();
text += "\nRSSI: ";
text += String(WiFi.RSSI());
text += " dBm";
} else {
text += "Dashboard WiFi: not connected";
text += "\nSSID: ";
text += CARGO_WIFI_SSID;
}
set_label_text_if_changed(wifi_label, last_wifi_text, text);
}
static void poll_status_api()
{
if (WiFi.status() != WL_CONNECTED) {
set_label_text_if_changed(system_label, last_system_text, "API: waiting for WiFi");
return;
}
if (millis() - last_status_poll_ms < 5000) {
return;
}
last_status_poll_ms = millis();
String requesting;
requesting += "API: requesting\n";
requesting += "/api/v1/status\n";
requesting += "Dashboard IP:\n";
requesting += WiFi.localIP().toString();
set_label_text_if_changed(system_label, last_system_text, requesting);
Serial.print("GET ");
Serial.println(CARGO_API_STATUS_URL);
HTTPClient http;
http.setTimeout(5000);
http.begin(CARGO_API_STATUS_URL);
int code = http.GET();
status_model.http_code = code;
Serial.print("HTTP code: ");
Serial.println(code);
if (code == 200) {
String body = http.getString();
Serial.print("Body length: ");
Serial.println(body.length());
Serial.println(body);
String received;
received += "API: received\nHTTP: 200\nBytes: ";
received += String(body.length());
set_label_text_if_changed(system_label, last_system_text, received);
parse_status_json(body);
} else {
status_model.api_ok = false;
String system;
system += "API: error\nHTTP: ";
system += String(code);
if (code <= 0) {
system += "\n";
system += http.errorToString(code);
}
Serial.println(system);
set_label_text_if_changed(system_label, last_system_text, system);
}
http.end();
}
static lv_obj_t *create_card(lv_obj_t *parent, const char *title, int width, int height, lv_align_t align, int x, int y)
{
lv_obj_t *card = lv_obj_create(parent);
lv_obj_set_size(card, width, height);
lv_obj_align(card, align, x, y);
lv_obj_set_style_radius(card, 16, 0);
lv_obj_set_style_bg_color(card, lv_color_hex(0x1F2933), 0);
lv_obj_set_style_border_color(card, lv_color_hex(0x3A4652), 0);
lv_obj_set_style_border_width(card, 2, 0);
lv_obj_set_style_pad_all(card, 12, 0);
lv_obj_t *title_label = lv_label_create(card);
lv_label_set_text(title_label, title);
lv_obj_set_style_text_color(title_label, lv_color_hex(0xFFFFFF), 0);
lv_obj_align(title_label, LV_ALIGN_TOP_LEFT, 0, 0);
return card;
}
static lv_obj_t *create_body_label(lv_obj_t *card, int width)
{
lv_obj_t *label = lv_label_create(card);
lv_label_set_text(label, "Waiting for /api/v1/status...");
lv_obj_set_style_text_color(label, lv_color_hex(0xDDE3EA), 0);
lv_obj_set_width(label, width);
lv_label_set_long_mode(label, LV_LABEL_LONG_WRAP);
lv_obj_align(label, LV_ALIGN_TOP_LEFT, 0, 32);
return label;
}
static void create_overland_status_screen()
{
lv_obj_t *screen = lv_scr_act();
lv_obj_clean(screen);
lv_obj_set_style_bg_color(screen, lv_color_hex(0x101418), 0);
lv_obj_t *title = lv_label_create(screen);
lv_label_set_text(title, "Overland Controller");
lv_obj_set_style_text_color(title, lv_color_hex(0xFFFFFF), 0);
lv_obj_set_style_text_font(title, &lv_font_montserrat_30, 0);
lv_obj_align(title, LV_ALIGN_TOP_MID, 0, 16);
lv_obj_t *subtitle = lv_label_create(screen);
lv_label_set_text(subtitle, "ESP32-S3 Dashboard - Cargo ESP Overview");
lv_obj_set_style_text_color(subtitle, lv_color_hex(0xB8C0C8), 0);
lv_obj_set_width(subtitle, 920);
lv_obj_set_style_text_align(subtitle, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align_to(subtitle, title, LV_ALIGN_OUT_BOTTOM_MID, 0, 6);
lv_obj_t *battery_card = create_card(screen, "Battery", 300, 230, LV_ALIGN_TOP_LEFT, 32, 96);
lv_obj_t *temps_card = create_card(screen, "Temperatures", 300, 230, LV_ALIGN_TOP_MID, 0, 96);
lv_obj_t *outputs_card = create_card(screen, "Outputs", 300, 230, LV_ALIGN_TOP_RIGHT, -32, 96);
lv_obj_t *system_card = create_card(screen, "System / Network", 620, 190, LV_ALIGN_BOTTOM_LEFT, 32, -50);
lv_obj_t *touch_card = create_card(screen, "Relay Controls", 300, 190, LV_ALIGN_BOTTOM_RIGHT, -32, -50);
battery_label = create_body_label(battery_card, 260);
temps_label = create_body_label(temps_card, 260);
outputs_label = create_body_label(outputs_card, 260);
lv_label_set_text(battery_label, "Waiting for battery data...");
lv_label_set_text(temps_label, "Waiting for temp data...");
lv_label_set_text(outputs_label, "Waiting for output data...");
wifi_label = create_body_label(system_card, 275);
system_label = lv_label_create(system_card);
lv_label_set_text(system_label, "API: waiting");
lv_obj_set_style_text_color(system_label, lv_color_hex(0xB8C0C8), 0);
lv_obj_set_width(system_label, 285);
lv_label_set_long_mode(system_label, LV_LABEL_LONG_WRAP);
lv_obj_align(system_label, LV_ALIGN_TOP_LEFT, 305, 32);
for (int i = 0; i < 2; i++) {
relay_buttons[i] = lv_btn_create(touch_card);
lv_obj_set_size(relay_buttons[i], 120, 48);
lv_obj_align(relay_buttons[i], LV_ALIGN_TOP_LEFT, i * 132, 34);
lv_obj_add_event_cb(
relay_buttons[i],
relay_button_event_cb,
LV_EVENT_CLICKED,
reinterpret_cast<void *>(static_cast<intptr_t>(i))
);
relay_button_labels[i] = lv_label_create(relay_buttons[i]);
lv_label_set_text(relay_button_labels[i], "Relay");
lv_obj_center(relay_button_labels[i]);
}
for (int i = 2; i < 6; i++) {
relay_buttons[i] = lv_btn_create(touch_card);
lv_obj_set_size(relay_buttons[i], 120, 42);
lv_obj_align(relay_buttons[i], LV_ALIGN_TOP_LEFT, ((i - 2) % 2) * 132, 92 + (((i - 2) / 2) * 48));
lv_obj_add_event_cb(
relay_buttons[i],
relay_button_event_cb,
LV_EVENT_CLICKED,
reinterpret_cast<void *>(static_cast<intptr_t>(i))
);
relay_button_labels[i] = lv_label_create(relay_buttons[i]);
lv_label_set_text(relay_button_labels[i], "Relay");
lv_obj_center(relay_button_labels[i]);
lv_obj_add_flag(relay_buttons[i], LV_OBJ_FLAG_HIDDEN);
}
touch_count_label = lv_label_create(touch_card);
lv_label_set_text(touch_count_label, "Relay controls");
lv_obj_set_style_text_color(touch_count_label, lv_color_hex(0xB8C0C8), 0);
lv_obj_set_width(touch_count_label, 250);
lv_obj_align(touch_count_label, LV_ALIGN_TOP_LEFT, 0, 142);
lv_obj_t *footer = lv_label_create(screen);
lv_label_set_text(footer, "Polling Cargo ESP /api/v1/status every 5 seconds");
lv_obj_set_style_text_color(footer, lv_color_hex(0x7D8996), 0);
lv_obj_set_width(footer, 900);
lv_obj_set_style_text_align(footer, LV_TEXT_ALIGN_CENTER, 0);
lv_obj_align(footer, LV_ALIGN_BOTTOM_MID, 0, -14);
}
void setup()
{
Serial.begin(115200);
delay(1000);
Serial.println();
Serial.println("================================");
Serial.println("Overland Controller Dashboard");
Serial.println("================================");
Serial.print("Version: ");
Serial.println(DASHBOARD_VERSION);
Serial.println("Target: Waveshare ESP32-S3 Touch LCD 5B");
Serial.println("Display: 1024x600");
Serial.println("API Base: /api/v1");
Board *board = new Board();
board->init();
#if LVGL_PORT_AVOID_TEARING_MODE
auto lcd = board->getLCD();
lcd->configFrameBufferNumber(LVGL_PORT_DISP_BUFFER_NUM);
#if ESP_PANEL_DRIVERS_BUS_ENABLE_RGB && CONFIG_IDF_TARGET_ESP32S3
auto lcd_bus = lcd->getBus();
if (lcd_bus->getBasicAttributes().type == ESP_PANEL_BUS_TYPE_RGB) {
static_cast<BusRGB *>(lcd_bus)->configRGB_BounceBufferSize(lcd->getFrameWidth() * 10);
}
#endif
#endif
assert(board->begin());
lvgl_port_init(board->getLCD(), board->getTouch());
lvgl_port_lock(-1);
create_overland_status_screen();
lvgl_port_unlock();
Serial.println("Dashboard clean overview ready");
}
void loop()
{
connect_wifi_if_needed();
update_wifi_label();
poll_status_api();
delay(500);
}