From 46d52052e3b8558712f8eb181349ee2282107df3 Mon Sep 17 00:00:00 2001 From: nick Date: Mon, 8 Jun 2026 08:45:24 -0600 Subject: [PATCH] firmware: scaffold optional cargo OLED display --- docs/HARDWARE.md | 28 +++ docs/roadmap.md | 25 ++ .../overland-controller.ino | 238 ++++++++++++++++++ tests/test_http_api_contract.py | 24 ++ 4 files changed, 315 insertions(+) diff --git a/docs/HARDWARE.md b/docs/HARDWARE.md index 9b99e97..ebfce6c 100644 --- a/docs/HARDWARE.md +++ b/docs/HARDWARE.md @@ -113,3 +113,31 @@ Implementation: - Sender to ADS1115 over I2C if no suitable exposed ADC exists Use lookup-table interpolation. + + +## Cargo OLED service display + +Planned optional hardware: + +- 0.96 inch SSD1306 OLED +- 128x64 resolution +- I2C interface +- Typical address: 0x3C +- Suggested initial pins: + - SDA: GPIO 21 + - SCL: GPIO 22 + - VCC: 3.3V + - GND: common ground + +Purpose: + +- Show first-boot AP setup credentials without requiring Serial Monitor. +- Show AP SSID, password, and IP address during setup/recovery. +- Show quick service status: + - battery SOC + - voltage/current + - relay states + - WiFi/IP + - active alarms + +Firmware support is optional and disabled by default with OLED_ENABLED set to 0. Enable only after the OLED libraries are installed and the display is wired. diff --git a/docs/roadmap.md b/docs/roadmap.md index deff2b2..c93f7c9 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -103,3 +103,28 @@ UI direction: - Add QR code display for joining Cargo AP. - Add dashboard settings UI. - Add LVGL on-screen keyboard only after core dashboard functionality is stable. + + +## Cargo OLED firmware foundation + +Status: planned / scaffolded + +Initial OLED firmware goals: + +- Add optional SSD1306 128x64 support behind a compile-time flag. +- Keep OLED support disabled by default so the controller firmware builds without extra libraries. +- Display boot/setup status. +- Display AP SSID/password/IP for phone-first setup. +- Display rotating service pages: + - battery + - relays + - network + - alarms + +Future OLED goals: + +- Add setup/status button. +- Short press cycles pages. +- Long press shows AP credentials. +- Extended hold enters recovery/reset mode. +- Add first-boot setup screen once AP password generation is finalized. diff --git a/firmware/esp32/overland-controller/overland-controller.ino b/firmware/esp32/overland-controller/overland-controller.ino index e7a8549..953764a 100644 --- a/firmware/esp32/overland-controller/overland-controller.ino +++ b/firmware/esp32/overland-controller/overland-controller.ino @@ -3,6 +3,15 @@ #include #include #include + +#define OLED_ENABLED 0 + +#if OLED_ENABLED +#include +#include +#include +#endif + #include #include "config.h" @@ -1163,6 +1172,231 @@ void handleUpdateApConfig() { server.send(200, "application/json", output); } + +#if OLED_ENABLED +const int OLED_SCREEN_WIDTH = 128; +const int OLED_SCREEN_HEIGHT = 64; +const int OLED_RESET_PIN = -1; +const uint8_t OLED_I2C_ADDR = 0x3C; + +// Default ESP32 I2C pins. Change these if the cargo controller wiring uses a different I2C bus. +const int OLED_I2C_SDA = 21; +const int OLED_I2C_SCL = 22; + +// Optional future setup/status button. Leave disabled until hardware is wired. +const int OLED_BUTTON_PIN = -1; + +Adafruit_SSD1306 oled(OLED_SCREEN_WIDTH, OLED_SCREEN_HEIGHT, &Wire, OLED_RESET_PIN); + +bool oledReady = false; +unsigned long lastOledUpdateMs = 0; +unsigned long lastOledPageChangeMs = 0; +uint8_t oledPage = 0; + +void oledClear(const char* title) { + oled.clearDisplay(); + oled.setTextSize(1); + oled.setTextColor(SSD1306_WHITE); + oled.setCursor(0, 0); + oled.println(title); + oled.drawLine(0, 10, OLED_SCREEN_WIDTH - 1, 10, SSD1306_WHITE); +} + +void oledPrintTrimmed(const String& value, int maxChars = 21) { + if (value.length() <= maxChars) { + oled.println(value); + } else { + oled.println(value.substring(0, maxChars)); + } +} + +void oledShowBoot() { + if (!oledReady) return; + + oledClear("Overland Controller"); + oled.setCursor(0, 18); + oled.println("Booting..."); + oled.println(); + oled.println("Cargo Controller"); + oled.display(); +} + +void oledShowSetupPage() { + if (!oledReady) return; + + oledClear("Setup / AP"); + oled.setCursor(0, 14); + oled.print("SSID: "); + oledPrintTrimmed(apSsid, 15); + oled.print("PASS: "); + oledPrintTrimmed(apPassword, 15); + oled.print("IP: "); + oled.println(WiFi.softAPIP()); + oled.display(); +} + +void oledShowBatteryPage() { + if (!oledReady) return; + + oledClear("Battery"); + oled.setCursor(0, 16); + + if (bmsData.valid) { + oled.print("SOC: "); + oled.print(bmsData.soc); + oled.println("%"); + + oled.print("V: "); + oled.print(bmsData.voltage, 2); + oled.println("V"); + + oled.print("A: "); + oled.print(bmsData.current, 1); + oled.println("A"); + + oled.print("Temp: "); + oled.print(bmsData.temperatureF, 1); + oled.println("F"); + } else { + oled.println("BMS unavailable"); + oled.println(); + oled.println("Check BLE/config"); + } + + oled.display(); +} + +void oledShowRelayPage() { + if (!oledReady) return; + + oledClear("Relays"); + oled.setCursor(0, 16); + + oled.print(appConfig.relays[0].name); + oled.print(": "); + oled.println(relays.relay1 ? "ON" : "OFF"); + + oled.print(appConfig.relays[1].name); + oled.print(": "); + oled.println(relays.relay2 ? "ON" : "OFF"); + + oled.display(); +} + +void oledShowNetworkPage() { + if (!oledReady) return; + + oledClear("Network"); + oled.setCursor(0, 14); + + oled.print("AP: "); + oledPrintTrimmed(apSsid, 17); + + oled.print("AP IP: "); + oled.println(WiFi.softAPIP()); + + oled.print("STA: "); + if (WiFi.status() == WL_CONNECTED) { + oled.println(WiFi.localIP()); + } else { + oled.println("not connected"); + } + + oled.display(); +} + +void oledShowAlarmPage() { + if (!oledReady) return; + + oledClear("Alarms"); + oled.setCursor(0, 14); + + bool any = false; + + if (alarms.lowSoc) { + oled.println("LOW SOC"); + any = true; + } + if (alarms.criticalSoc) { + oled.println("CRITICAL SOC"); + any = true; + } + if (alarms.bmsDisconnected) { + oled.println("BMS DISCONNECTED"); + any = true; + } + if (alarms.highBatteryTemp) { + oled.println("HIGH BATT TEMP"); + any = true; + } + if (alarms.cellImbalance) { + oled.println("CELL IMBALANCE"); + any = true; + } + + if (!any) { + oled.println("No active alarms"); + } + + oled.display(); +} + +void oledRenderPage() { + switch (oledPage) { + case 0: + oledShowBatteryPage(); + break; + case 1: + oledShowRelayPage(); + break; + case 2: + oledShowNetworkPage(); + break; + case 3: + oledShowAlarmPage(); + break; + default: + oledPage = 0; + oledShowBatteryPage(); + break; + } +} + +void oledBegin() { + Wire.begin(OLED_I2C_SDA, OLED_I2C_SCL); + + oledReady = oled.begin(SSD1306_SWITCHCAPVCC, OLED_I2C_ADDR); + + if (!oledReady) { + Serial.println("OLED not detected"); + return; + } + + Serial.println("OLED initialized"); + oledShowBoot(); +} + +void oledLoop() { + if (!oledReady) return; + + unsigned long now = millis(); + + if (now - lastOledPageChangeMs > 5000) { + oledPage = (oledPage + 1) % 4; + lastOledPageChangeMs = now; + } + + if (now - lastOledUpdateMs > 1000) { + oledRenderPage(); + lastOledUpdateMs = now; + } +} +#else +void oledBegin() {} +void oledShowSetupPage() {} +void oledLoop() {} +#endif + int findNextWifiIndexByPriority(bool tried[]) { int bestIndex = -1; int bestPriority = 1000000; @@ -3139,6 +3373,8 @@ void setup() { Serial.println("Overland Controller Booting"); Serial.println("=================================="); + oledBegin(); + loadConfig(); printConfig(); @@ -3153,6 +3389,7 @@ void setup() { WiFi.mode(WIFI_AP_STA); startAccessPoint(); + oledShowSetupPage(); connectStaWifi(); @@ -3235,6 +3472,7 @@ void loop() { updateBms(); updateAlarms(); pollDashboardUart(); + oledLoop(); static unsigned long lastSensorUpdate = 0; diff --git a/tests/test_http_api_contract.py b/tests/test_http_api_contract.py index 39016ca..f77d662 100644 --- a/tests/test_http_api_contract.py +++ b/tests/test_http_api_contract.py @@ -401,3 +401,27 @@ def test_embedded_webui_config_subtabs(): assert "config-maintenance" in source assert "WiFi / AP" in source assert "subtabbtn active" in source + + +def test_cargo_oled_support_is_optional(): + source = firmware_source() + + assert "#define OLED_ENABLED 0" in source + assert "#if OLED_ENABLED" in source + assert "void oledBegin()" in source + assert "void oledLoop()" in source + assert "void oledShowSetupPage()" in source + assert "OLED_I2C_ADDR = 0x3C" in source + assert "Adafruit_SSD1306" in source + + +def test_cargo_oled_shows_setup_and_service_pages(): + source = firmware_source() + + assert 'oledClear("Setup / AP")' in source + assert 'oledClear("Battery")' in source + assert 'oledClear("Relays")' in source + assert 'oledClear("Network")' in source + assert 'oledClear("Alarms")' in source + assert "oledShowSetupPage();" in source + assert "oledLoop();" in source