diff --git a/usermods/OLED_72x40/README.md b/usermods/OLED_72x40/README.md new file mode 100644 index 0000000000..8058830c16 --- /dev/null +++ b/usermods/OLED_72x40/README.md @@ -0,0 +1,72 @@ +# WLED Usermod: OLED 72x40 (SSD1306) + +A custom WLED usermod designed for the **ESP32-C3 Super Mini** development board featuring an integrated **0.42-inch OLED display** (72x40 pixels). + +## 📱 About This Device + +This specific board is a compact IoT platform based on the **ESP32-C3FN4/FH4** with built-in 4MB Flash, Wi-Fi, and Bluetooth 5.0. It is widely used for teeny-tiny projects like telemetry displays, altitude trackers, or localized status monitors. + +![ESP32-C3 Super Mini](esp32-c3-super-mini.png) +*Image source: AlexYeryomin/ESP32C3-OLED-72x40* + +### Technical Specifications + +* **Controller:** ESP32-C3 +* **OLED Resolution:** 72x40 pixels (Effective area) +* **Driver:** SSD1306 (requires 128x64 driver initialization with specific offsets to avoid clipping) +* **I2C Pins:** SDA (GPIO 5), SCL (GPIO 6) +* **Onboard LED:** GPIO 8 (used for heartbeat status) +* **Function Button:** GPIO 9 (normally used for Bootloader mode), assigned to toggling brightness between 0% and current value. + +--- + +## 🛠 Features & Functions + +### 1. Adaptive Dashboard + +The usermod displays a real-time WLED status dashboard including: + +* **Effect Name:** Shows the current WLED mode/effect. +* **Brightness Visualizer:** A horizontal scrolling graph showing brightness (`bri`) levels over time. +* **System Stats:** Quick view of Speed (S), Intensity (I), and Brightness (B) percentages. + +### 2. Smart Coordinate Mapping (Flipping Support) + +Because this 72x40 display uses a 128x64 driver, normal library rotations can cause the image to "fall off" the physical glass. This usermod includes **Dynamic Offset Calculation**: + +* **Normal Mode:** Centers the 72x40 UI in the top-left area. +* **Flipped Mode:** Automatically shifts coordinates to the bottom-right of the 128x64 memory buffer so the UI remains perfectly centered and visible when rotated 180°. + +### 3. LED Heartbeat & Network Status + +The onboard **GPIO 8** LED provides visual feedback of the device's connectivity: + +* **Blinking:** The device is disconnected from the network. +* **Pulsing (Sinusoidal):** The device is successfully connected to WiFi. + +### 4. Splash Screen & Info + +Upon boot, the display shows the **Akemi Logo** and current **mDNS name or IP Address** for 5 seconds to help you locate the device on your network. + +### 5. Configurable Sleep Timer + +To prevent OLED burn-in, the screen automatically blanks after a period of inactivity (default: 60 seconds). It wakes up instantly when: + +* The physical button is pressed. +* Settings are changed via the WLED Web UI or API. + +--- + +## ⚙️ Configuration + +The following settings are available directly in the **WLED Usermod Settings** page (v0.15.3+): + +* **Enabled:** Toggle the OLED on/off. +* **Flip Display:** Rotates the UI 180° and adjusts internal offsets. +* **X/Y-Offset:** Fine-tune the UI position on your specific glass. +* **Sleep Timeout:** Set how many seconds to wait before the screen turns off. + +## 🔗 References & Inspiration + +* [AlexYeryomin/ESP32C3-OLED-72x40](https://github.com/AlexYeryomin/ESP32C3-OLED-72x40) - Driver implementation and original Micropython demo. +* [Kevin's Blog: ESP32-C3 0.42 OLED](https://emalliab.wordpress.com/2025/02/12/esp32-c3-0-42-oled/) - Deep dive into coordinate offsets and hardware constraints. \ No newline at end of file diff --git a/usermods/OLED_72x40/UsermodOLED72x40.h b/usermods/OLED_72x40/UsermodOLED72x40.h new file mode 100644 index 0000000000..38ab547bd0 --- /dev/null +++ b/usermods/OLED_72x40/UsermodOLED72x40.h @@ -0,0 +1,234 @@ +#pragma once + +#include "wled.h" +#include +#include + +#define OLED_SDA 5 +#define OLED_SCL 6 +#define LED_PIN 8 + +// 128x40 bitmap generated by LCD Matrix Studio +const unsigned char akemi_logo [] PROGMEM = { + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x30, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x01, 0xFC, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x03, 0xFF, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x07, 0xFF, 0xC0, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x07, 0xFF, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x06, 0x7E, 0x40, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1E, 0x3C, 0x60, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x1C, 0x18, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x1C, 0x18, 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1C, 0x18, 0x20, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x1E, 0x3C, 0x60, 0x00, 0x38, 0x00, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0xE0, 0x00, 0x0C, 0x00, 0x80, 0x01, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0xE0, 0x00, + 0x02, 0x00, 0xC0, 0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x1F, 0xC3, 0xE0, 0x00, 0x01, 0x00, 0x43, 0xC3, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x1F, 0xE7, 0xE0, 0x00, 0x01, 0x02, 0x46, 0x43, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x1F, 0xFF, 0xE0, 0x00, + 0x01, 0x02, 0x44, 0x4F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x80, + 0x1F, 0xFF, 0xE0, 0x01, 0x01, 0x02, 0x45, 0xC9, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x38, 0x1F, 0xFF, 0xE0, 0x1C, 0x01, 0x32, 0x47, 0x09, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x08, 0x20, 0x00, 0x00, 0x10, + 0x01, 0x12, 0x42, 0x0D, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0C, + 0x30, 0x00, 0x00, 0x30, 0x01, 0xD2, 0x4B, 0x07, 0xE0, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x0C, 0x2E, 0xE7, 0x70, 0x30, 0x00, 0xDE, 0x79, 0xE0, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, 0x06, 0xE7, 0x60, 0x60, + 0x00, 0x7C, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x06, + 0x06, 0xC6, 0x60, 0x60, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x03, 0x08, 0xC6, 0x30, 0xC0, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x03, 0x10, 0xC6, 0x08, 0xC0, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + 0xE0, 0xC6, 0x0F, 0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0xE0, 0xC6, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC6, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xC6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xC6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC6, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0xC6, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xC0, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00 +}; + + +class UsermodOLED72x40 : public Usermod { + private: + U8G2_SSD1306_128X64_NONAME_F_HW_I2C* u8g2 = nullptr; + bool enabled = true, initDone = false, displayOff = false; + unsigned long lastUpdate = 0, lastInteraction = 0; + unsigned long screenTimeoutMS = 60000; + int xOff = 28, yOff = 24; + byte vValues[72]; + bool flipDisplay = false; + + public: + void setup() { + pinMode(LED_PIN, OUTPUT); + Wire.begin(OLED_SDA, OLED_SCL); + WiFi.setTxPower(WIFI_POWER_8_5dBm); + // Wire.setClock(100000); // Set to 100kHz (Standard Mode) to avoid frequent connection issues + u8g2 = new U8G2_SSD1306_128X64_NONAME_F_HW_I2C(U8G2_R0, U8X8_PIN_NONE); + + if (u8g2->begin()) { + u8g2->setContrast(255); + u8g2->setFlipMode(flipDisplay ? 1 : 0); + memset(vValues, 0, sizeof(vValues)); + u8g2->clearBuffer(); + + // FIXED X-OFFSET FOR SPLASH + // Since akemi_logo is 128px wide (16 bytes), we don't shift X. + // We only shift Y to align the 40px height within the 64px buffer. + int splashX = 0; + int splashY = flipDisplay ? (64 - 40 - yOff) : yOff; + + // Draw the 128x40 bitmap + u8g2->drawBitmap(splashX, splashY, 16, 40, akemi_logo); + + // The IP address/mDNS text still needs the relative shift to stay + // centered under the logo area + int textX = flipDisplay ? (128 - 72 - xOff) : xOff; + + u8g2->setFont(u8g2_font_4x6_tf); + u8g2->setCursor(textX + 12, splashY + 38); + + if (Network.isConnected()) u8g2->print(Network.localIP()); + else u8g2->print(cmDNS); + + u8g2->sendBuffer(); + delay(5000); + lastInteraction = millis(); + initDone = true; + } + } + + void loop() { + // 1. LED HEARTBEAT (Always runs) + static unsigned long ledTimer = 0; + if (!Network.isConnected()) { + if (millis() - ledTimer > 200) { ledTimer = millis(); digitalWrite(LED_PIN, !digitalRead(LED_PIN)); } + } else { + float pulse = (sin(millis() / 2000.0 * PI) + 1) * 127.5; + analogWrite(LED_PIN, (int)pulse); + } + + if (!enabled || !initDone) return; + + // 2. TIMEOUT LOGIC (Moved before the strip updating guard) + if (screenTimeoutMS > 0 && (millis() - lastInteraction > screenTimeoutMS)) { + if (!displayOff) { u8g2->setPowerSave(1); displayOff = true; } + return; + } + + // 3. DRAWING LOGIC (Bypassed if screen is blanked) + if (strip.isUpdating()) return; + + if (millis() - lastUpdate > 100) { + lastUpdate = millis(); + if (displayOff) { u8g2->setPowerSave(0); displayOff = false; } + + u8g2->clearBuffer(); + + // ADJUST OFFSETS BASED ON FLIP + // Normal: xOff=28, yOff=24 + // Flipped: We need to shift the content to the opposite side of the 128x64 buffer + int currentX = flipDisplay ? (128 - 72 - xOff) : xOff; + int currentY = flipDisplay ? (64 - 40 - yOff) : yOff; + + u8g2->setFont(u8g2_font_5x7_tf); + char lineBuffer[17]; + extractModeName(effectCurrent, JSON_mode_names, lineBuffer, 16); + u8g2->drawStr(currentX, currentY + 7, lineBuffer); + u8g2->drawHLine(currentX, currentY + 9, 72); + + for (int i = 0; i < 71; i++) vValues[i] = vValues[i+1]; + vValues[71] = map(bri, 0, 255, 0, 15); + for (int i = 0; i < 71; i++) { + u8g2->drawLine(currentX + i, currentY + 26, currentX + i, currentY + 26 - vValues[i]); + } + + u8g2->setFont(u8g2_font_4x6_tf); + u8g2->setCursor(currentX, currentY + 38); + u8g2->print("S:"); u8g2->print(map(effectSpeed, 0, 255, 0, 99)); u8g2->print("% "); + u8g2->print("I:"); u8g2->print(map(effectIntensity, 0, 255, 0, 99)); u8g2->print("% "); + u8g2->print("B:"); u8g2->print(map(bri, 0, 255, 0, 100)); u8g2->print("%"); + + u8g2->sendBuffer(); + } + } + + void onStateChange(uint8_t mode) { + lastInteraction = millis(); + lastUpdate = 0; + if (displayOff && u8g2) { u8g2->setPowerSave(0); displayOff = false; } + } + + bool handleButton(uint8_t b) { + lastInteraction = millis(); + if (displayOff && u8g2) { u8g2->setPowerSave(0); displayOff = false; } + return false; + } + + void appendConfigData(JsonArray &config) { + JsonObject top = config.createNestedObject(); + top[F("usermod")] = F("OLED_72x40"); + + // This creates the UI elements in the Usermod settings page + config.createNestedObject()[F("OLED_72x40:enabled")]; + config.createNestedObject()[F("OLED_72x40:flipDisplay")]; + config.createNestedObject()[F("OLED_72x40:x-offset")]; + config.createNestedObject()[F("OLED_72x40:y-offset")]; + config.createNestedObject()[F("OLED_72x40:sleepTimeout")]; + config.createNestedObject()[F("OLED_72x40:bootButtonPin")]; + } + void addToConfig(JsonObject& root) { + JsonObject top = root.createNestedObject(F("OLED_72x40")); + top[F("enabled")] = enabled; + top[F("flipDisplay")] = flipDisplay; // Registration for UI + top["bootButtonPin"] = 9; + top["x-offset"] = xOff; + top["y-offset"] = yOff; + top["sleepTimeout"] = screenTimeoutMS / 1000; + } + + bool readFromConfig(JsonObject& root) { + JsonObject top = root[F("OLED_72x40")]; + if (top.isNull()) return false; + + enabled = top[F("enabled")] | enabled; + flipDisplay = top[F("flipDisplay")] | flipDisplay; + xOff = top[F("x-offset")] | 28; + yOff = top[F("y-offset")] | 24; + + // Guard against 0 or negative timeout values + unsigned long timeoutSec = top[F("sleepTimeout")] | 60; + screenTimeoutMS = timeoutSec * 1000; + + if (initDone && u8g2) u8g2->setFlipMode(flipDisplay ? 1 : 0); + + // Apply the button pin 9 default logic + int pin = top[F("bootButtonPin")] | 9; + if (btnPin[0] == -1 || btnPin[0] != pin) { + btnPin[0] = pin; + buttonType[0] = BTN_TYPE_PUSH; + } + return true; + } + + uint16_t getId() { return USERMOD_ID_OLED_72x40; } +}; \ No newline at end of file diff --git a/usermods/OLED_72x40/esp32-c3-super-mini.png b/usermods/OLED_72x40/esp32-c3-super-mini.png new file mode 100644 index 0000000000..ce1cd2ae82 Binary files /dev/null and b/usermods/OLED_72x40/esp32-c3-super-mini.png differ diff --git a/wled00/const.h b/wled00/const.h index ff152beb62..ae44eee4b6 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -203,6 +203,7 @@ #define USERMOD_ID_LD2410 52 //Usermod "usermod_ld2410.h" #define USERMOD_ID_POV_DISPLAY 53 //Usermod "usermod_pov_display.h" #define USERMOD_ID_PIXELS_DICE_TRAY 54 //Usermod "pixels_dice_tray.h" +#define USERMOD_ID_OLED_72x40 55 //Usermod "OLED_72x40.h" //Access point behavior #define AP_BEHAVIOR_BOOT_NO_CONN 0 //Open AP when no connection after boot diff --git a/wled00/usermods_list.cpp b/wled00/usermods_list.cpp index 36bd122a51..9107f91774 100644 --- a/wled00/usermods_list.cpp +++ b/wled00/usermods_list.cpp @@ -182,6 +182,10 @@ #include "../usermods/Internal_Temperature_v2/usermod_internal_temperature.h" #endif +#ifdef USERMOD_OLED_72x40 +#include "../usermods/OLED_72x40/UsermodOLED72x40.h" +#endif + #if defined(WLED_USE_SD_MMC) || defined(WLED_USE_SD_SPI) // This include of SD.h and SD_MMC.h must happen here, else they won't be // resolved correctly (when included in mod's header only) @@ -470,4 +474,8 @@ void registerUsermods() #ifdef USERMOD_POV_DISPLAY UsermodManager::add(new PovDisplayUsermod()); #endif + + #ifdef USERMOD_OLED_72x40 + UsermodManager::add(new UsermodOLED72x40()); + #endif }