From 3408a1c2b517f57da8fd64fd7bdc2d24d0b7241b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 23 May 2026 03:39:01 +0000 Subject: [PATCH 1/2] Add I2C Rotary Encoder UI usermod for M5Stack Encoder Unit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new usermod at usermods/usermod_v2_i2c_encoder_ui that delivers the same WLED UI controls as the existing Rotary Encoder UI (brightness, effect, speed, intensity, palette, hue, saturation, CCT, presets, and custom sliders), but reads from an I2C rotary encoder instead of GPIO pins. Targets the M5Stack Encoder Unit (U135, I2C address 0x40) using the device's V1 register protocol directly over the globally-configured WLED I2C bus — no encoder-specific GPIO pins, no external library. Configurable settings: I2C address, preset range, apply-to-all-segments. FourLineDisplay integration is preserved. Usermod ID 59 added to const.h. --- usermods/usermod_v2_i2c_encoder_ui/readme.md | 63 ++ .../usermod_v2_i2c_encoder_ui.cpp | 726 ++++++++++++++++++ wled00/const.h | 1 + 3 files changed, 790 insertions(+) create mode 100644 usermods/usermod_v2_i2c_encoder_ui/readme.md create mode 100644 usermods/usermod_v2_i2c_encoder_ui/usermod_v2_i2c_encoder_ui.cpp diff --git a/usermods/usermod_v2_i2c_encoder_ui/readme.md b/usermods/usermod_v2_i2c_encoder_ui/readme.md new file mode 100644 index 0000000000..4d66aa6837 --- /dev/null +++ b/usermods/usermod_v2_i2c_encoder_ui/readme.md @@ -0,0 +1,63 @@ +# I2C Rotary Encoder UI + +A WLED usermod that provides the same rotary-encoder-driven UI as the **Rotary Encoder UI** usermod, but for the **M5Stack Encoder Unit** (UNIT-ENCODER, SKU: U135) — an I2C device that handles quadrature decoding internally. + +No encoder GPIO pins are needed. The encoder communicates over the I2C bus configured globally in **WLED Settings → Hardware**. + +## Compatible Hardware + +| Device | I2C Address | Notes | +|---|---|---| +| M5Stack Encoder Unit (U135) | 0x40 (default) | Verified against V1 firmware protocol | + +## Controls + +| Action | Effect | +|---|---| +| Rotate | Adjust the current parameter | +| Short press | Cycle to the next parameter | +| Double press | Toggle LEDs on/off | +| Hold 3 s | Show network info (requires FourLineDisplay usermod) | + +## Parameters cycled by button press + +Without FourLineDisplay: Brightness → Effect Speed → Effect Intensity → Color Palette → Effect +With FourLineDisplay: adds Main Color (hue) → Saturation → CCT → Preset → Custom 1/2/3 + +CCT only appears when the active segment supports it. Preset only appears when preset-low and preset-high are both set. + +## Setup + +1. In **WLED Settings → Hardware**, set the I2C SDA and SCL pins for your board. +2. Connect the M5Stack Encoder Unit to those pins (and 3.3 V / GND). +3. Enable this usermod in **WLED Settings → Usermods**. +4. The default I2C address is **64 (0x40)**. Change it if you have re-soldered the address pads. + +## Configuration options + +| Option | Default | Description | +|---|---|---| +| enabled | true | Enable or disable the usermod | +| i2c-address | 64 (0x40) | I2C address of the encoder unit (decimal) | +| preset-low | 0 | Lower bound of preset cycling range | +| preset-high | 0 | Upper bound of preset cycling range (0 disables preset mode) | +| apply-2-all-seg | true | Apply changes to all active segments, not just the main one | + +## Building + +Add to your `platformio_override.ini` build environment: + +```ini +build_flags = + -D USERMOD_I2C_ENCODER_UI +``` + +Or enable it from the WLED usermod settings page if your build includes it by default. + +Optionally pair with FourLineDisplay for the full set of controls: + +```ini +build_flags = + -D USERMOD_I2C_ENCODER_UI + -D USERMOD_FOUR_LINE_DISPLAY +``` diff --git a/usermods/usermod_v2_i2c_encoder_ui/usermod_v2_i2c_encoder_ui.cpp b/usermods/usermod_v2_i2c_encoder_ui/usermod_v2_i2c_encoder_ui.cpp new file mode 100644 index 0000000000..8d4b329a32 --- /dev/null +++ b/usermods/usermod_v2_i2c_encoder_ui/usermod_v2_i2c_encoder_ui.cpp @@ -0,0 +1,726 @@ +#include "wled.h" + +// +// I2C Rotary Encoder UI Usermod +// +// Functionally equivalent to the GPIO Rotary Encoder UI usermod +// (usermod_v2_rotary_encoder_ui_ALT), but uses an I2C rotary encoder +// instead of a 5-pin GPIO encoder. +// +// Designed for the M5Stack Encoder Unit (UNIT-ENCODER, SKU: U135). +// I2C address: 0x40 (default). No encoder GPIO pins are needed — +// configure I2C SDA/SCL globally in the WLED Hardware settings page. +// +// M5Stack Encoder Unit register map: +// 0x00 : Mode (0=Pulse, 1=AB) — write 0x00 to initialise +// 0x10 : Encoder count low byte +// 0x11 : Encoder count high byte (int16_t, little-endian) +// 0x20 : Button (0=Released, 1=Pressed) +// +// Controls (same as GPIO variant): +// Rotate : adjust the currently selected parameter +// Press : cycle through parameter modes +// 2×Press : toggle on/off +// Hold 3s : show network info overlay (requires FourLineDisplay) +// + +#ifdef USERMOD_FOUR_LINE_DISPLAY +#include "usermod_v2_four_line_display.h" +#endif + +#ifdef USERMOD_MODE_SORT + #error "Usermod Mode Sort is no longer required. Remove -D USERMOD_MODE_SORT from platformio.ini" +#endif + +#ifndef I2C_ENCODER_POLL_MS +#define I2C_ENCODER_POLL_MS 10 // poll every 10 ms (100 Hz) +#endif + +// With FourLineDisplay: Brightness/Speed/Intensity/Palette/Effect/Hue/Sat/CCT/Preset/Cx3 +// Without : Brightness/Speed/Intensity/Palette/Effect only +#ifdef USERMOD_FOUR_LINE_DISPLAY + #define I2CENC_LAST_UI_STATE 11 +#else + #define I2CENC_LAST_UI_STATE 4 +#endif + +#define I2CENC_MODE_SORT_SKIP_COUNT 1 + +// File-static helpers for qsort comparator (internal linkage, no conflict with other mods) +static const char **i2cenc_listBeingSorted; + +static int i2cenc_qstringCmp(const void *ap, const void *bp) { + const char *a = i2cenc_listBeingSorted[*((byte *)ap)]; + const char *b = i2cenc_listBeingSorted[*((byte *)bp)]; + int i = 0; + do { + char aVal = pgm_read_byte_near(a + i); + if (aVal >= 97 && aVal <= 122) aVal -= 32; + char bVal = pgm_read_byte_near(b + i); + if (bVal >= 97 && bVal <= 122) bVal -= 32; + if (aVal == '"' || bVal == '"' || aVal == '\0' || bVal == '\0') { + if (aVal == bVal) return 0; + else if (aVal == '"' || aVal == '\0') return -1; + else return 1; + } + if (aVal == bVal) { i++; continue; } + return (aVal < bVal) ? -1 : 1; + } while (true); + return 0; +} + + +class I2CEncoderUIUsermod : public Usermod { + + private: + + const int8_t fadeAmount; // step size for most parameters + unsigned long loopTime; + + unsigned long buttonPressedTime; + unsigned long buttonWaitTime; + bool buttonPressedBefore; + bool buttonLongPressed; + + uint8_t i2cAddress; // I2C address of the encoder unit + uint16_t lastRaw; // last raw count read from device + bool deviceFound; // set true when encoder responds at setup + + unsigned char select_state; // which parameter we are currently editing + + uint16_t currentHue1; + byte currentSat1; + uint8_t currentCCT; + + #ifdef USERMOD_FOUR_LINE_DISPLAY + FourLineDisplayUsermod *display; + #else + void *display; + #endif + + const char **modes_qstrings; + byte *modes_alpha_indexes; + const char **palettes_qstrings; + byte *palettes_alpha_indexes; + + bool currentEffectAndPaletteInitialized; + uint8_t effectCurrentIndex; + uint8_t effectPaletteIndex; + + byte presetHigh; + byte presetLow; + bool applyToAll; + bool initDone; + bool enabled; + + static const char _name[]; + static const char _enabled[]; + static const char _i2cAddress[]; + static const char _presetHigh[]; + static const char _presetLow[]; + static const char _applyToAll[]; + + // ------------------------------------------------------------------ I2C + + // Write a single byte to a register. Returns true on success. + bool writeReg(uint8_t reg, uint8_t value) { + Wire.beginTransmission(i2cAddress); + Wire.write(reg); + Wire.write(value); + return Wire.endTransmission() == 0; + } + + // Read the encoder count as a signed 16-bit delta from the last reading. + // Returns 0 on I2C error. + int16_t readEncoderDelta() { + Wire.beginTransmission(i2cAddress); + Wire.write(0x10); + if (Wire.endTransmission(false) != 0) return 0; + if (Wire.requestFrom((uint8_t)i2cAddress, (uint8_t)2) < 2) return 0; + uint16_t raw = (uint16_t)Wire.read() | ((uint16_t)Wire.read() << 8); + int16_t delta = (int16_t)(raw - lastRaw); + lastRaw = raw; + return delta; + } + + // Read the button state. Returns true when the button is pressed. + bool readButtonState() { + Wire.beginTransmission(i2cAddress); + Wire.write(0x20); + if (Wire.endTransmission(false) != 0) return false; + if (Wire.requestFrom((uint8_t)i2cAddress, (uint8_t)1) < 1) return false; + return Wire.read() == 1; // 0=Released, 1=Pressed + } + + // --------------------------------------------------------- sorting helpers + + void sortModesAndPalettes() { + modes_qstrings = strip.getModeDataSrc(); + modes_alpha_indexes = re_initIndexArray(strip.getModeCount()); + re_sortModes(modes_qstrings, modes_alpha_indexes, strip.getModeCount(), I2CENC_MODE_SORT_SKIP_COUNT); + + palettes_qstrings = re_findModeStrings(JSON_palette_names, getPaletteCount()); + palettes_alpha_indexes = re_initIndexArray(getPaletteCount()); + if (customPalettes.size()) { + for (int i = 0; i < (int)customPalettes.size(); i++) { + palettes_alpha_indexes[FIXED_PALETTE_COUNT + i] = 255 - i; + palettes_qstrings[FIXED_PALETTE_COUNT + i] = PSTR("~Custom~"); + } + } + int skipPaletteCount = 1; + while (pgm_read_byte_near(palettes_qstrings[skipPaletteCount]) == '*') skipPaletteCount++; + re_sortModes(palettes_qstrings, palettes_alpha_indexes, FIXED_PALETTE_COUNT, skipPaletteCount); + } + + byte *re_initIndexArray(int numModes) { + byte *indexes = (byte *)malloc(sizeof(byte) * numModes); + for (unsigned i = 0; i < (unsigned)numModes; i++) indexes[i] = i; + return indexes; + } + + const char **re_findModeStrings(const char json[], int numModes) { + const char **modeStrings = (const char **)malloc(sizeof(const char *) * numModes); + uint8_t modeIndex = 0; + bool insideQuotes = false; + bool complete = false; + for (size_t i = 0; i < strlen_P(json); i++) { + char c = pgm_read_byte_near(json + i); + if (c == '\0') break; + switch (c) { + case '"': + insideQuotes = !insideQuotes; + if (insideQuotes) modeStrings[modeIndex] = (char *)(json + i + 1); + break; + case ']': + if (!insideQuotes) complete = true; + break; + case ',': + if (!insideQuotes) modeIndex++; + break; + default: break; + } + if (complete) break; + } + return modeStrings; + } + + void re_sortModes(const char **modeNames, byte *indexes, int count, int numSkip) { + if (!modeNames) return; + i2cenc_listBeingSorted = modeNames; + qsort(indexes + numSkip, count - numSkip, sizeof(byte), i2cenc_qstringCmp); + i2cenc_listBeingSorted = nullptr; + } + + public: + + I2CEncoderUIUsermod() + : fadeAmount(5) + , loopTime(0) + , buttonPressedTime(0) + , buttonWaitTime(0) + , buttonPressedBefore(false) + , buttonLongPressed(false) + , i2cAddress(0x40) + , lastRaw(0) + , deviceFound(false) + , select_state(0) + , currentHue1(16) + , currentSat1(255) + , currentCCT(128) + , display(nullptr) + , modes_qstrings(nullptr) + , modes_alpha_indexes(nullptr) + , palettes_qstrings(nullptr) + , palettes_alpha_indexes(nullptr) + , currentEffectAndPaletteInitialized(false) + , effectCurrentIndex(0) + , effectPaletteIndex(0) + , presetHigh(0) + , presetLow(0) + , applyToAll(true) + , initDone(false) + , enabled(true) + {} + + uint16_t getId() override { return USERMOD_ID_I2C_ENCODER_UI; } + + void setup() override { + DEBUG_PRINTLN(F("I2C Encoder UI: init.")); + + if (i2c_sda < 0 || i2c_scl < 0) { + DEBUG_PRINTLN(F("I2C Encoder UI: I2C pins not configured, disabling.")); + enabled = false; + return; + } + + // Set Pulse mode and verify device is present. + // A failed write means wrong address or not connected — keep enabled so the + // user can correct the address in settings without rebooting. + if (!writeReg(0x00, 0x00)) { + DEBUG_PRINTLN(F("I2C Encoder UI: device not found at address.")); + deviceFound = false; + return; + } + deviceFound = true; + + // Read baseline count so first delta is 0 + Wire.beginTransmission(i2cAddress); + Wire.write(0x10); + Wire.endTransmission(false); + if (Wire.requestFrom((uint8_t)i2cAddress, (uint8_t)2) >= 2) { + lastRaw = (uint16_t)Wire.read() | ((uint16_t)Wire.read() << 8); + } + + currentCCT = (approximateKelvinFromRGB(RGBW32(colPri[0], colPri[1], colPri[2], colPri[3])) - 1900) >> 5; + + if (!initDone) sortModesAndPalettes(); + + #ifdef USERMOD_FOUR_LINE_DISPLAY + display = (FourLineDisplayUsermod *)UsermodManager::lookup(USERMOD_ID_FOUR_LINE_DISP); + if (display != nullptr) display->setMarkLine(1, 0); + #endif + + loopTime = millis(); + initDone = true; + } + + void loop() override { + if (!enabled || !deviceFound) return; + + unsigned long currentTime = millis(); + if (strip.isUpdating() && (currentTime - loopTime) < I2C_ENCODER_POLL_MS) return; + if (currentTime - loopTime < I2C_ENCODER_POLL_MS) return; + + if (!currentEffectAndPaletteInitialized) findCurrentEffectAndPalette(); + + if (modes_alpha_indexes[effectCurrentIndex] != effectCurrent || + palettes_alpha_indexes[effectPaletteIndex] != effectPalette) { + currentEffectAndPaletteInitialized = false; + } + + // ---- button ---- + bool buttonPressed = readButtonState(); + if (buttonPressed) { + if (!buttonPressedBefore) buttonPressedTime = currentTime; + buttonPressedBefore = true; + if (currentTime - buttonPressedTime > 3000) { + if (!buttonLongPressed) displayNetworkInfo(); + buttonLongPressed = true; + } + } else if (!buttonPressed && buttonPressedBefore) { + bool doublePress = buttonWaitTime; + buttonWaitTime = 0; + if (!buttonLongPressed) { + if (doublePress) { + toggleOnOff(); + lampUdated(); + } else { + buttonWaitTime = currentTime; + } + } + buttonLongPressed = false; + buttonPressedBefore = false; + } + + // Single-press: cycle modes after 350 ms with no second press + if (buttonWaitTime && currentTime - buttonWaitTime > 350 && !buttonPressedBefore) { + buttonWaitTime = 0; + char newState = select_state + 1; + bool changedState = false; + char lineBuffer[64]; + do { + switch (newState) { + case 0: strcpy_P(lineBuffer, PSTR("Brightness")); changedState = true; break; + case 1: if (!extractModeSlider(effectCurrent, 0, lineBuffer, 63)) newState++; else changedState = true; break; + case 2: if (!extractModeSlider(effectCurrent, 1, lineBuffer, 63)) newState++; else changedState = true; break; + case 3: strcpy_P(lineBuffer, PSTR("Color Palette")); changedState = true; break; + case 4: strcpy_P(lineBuffer, PSTR("Effect")); changedState = true; break; + case 5: strcpy_P(lineBuffer, PSTR("Main Color")); changedState = true; break; + case 6: strcpy_P(lineBuffer, PSTR("Saturation")); changedState = true; break; + case 7: + if (!(strip.getSegment(applyToAll ? strip.getFirstSelectedSegId() : strip.getMainSegmentId()).getLightCapabilities() & 0x04)) newState++; + else { strcpy_P(lineBuffer, PSTR("CCT")); changedState = true; } + break; + case 8: if (presetHigh == 0 || presetLow == 0) newState++; else { strcpy_P(lineBuffer, PSTR("Preset")); changedState = true; } break; + case 9: + case 10: + case 11: if (!extractModeSlider(effectCurrent, newState - 7, lineBuffer, 63)) newState++; else changedState = true; break; + } + if (newState > I2CENC_LAST_UI_STATE) newState = 0; + } while (!changedState); + + if (display != nullptr) { + switch (newState) { + case 0: changedState = changeState(lineBuffer, 1, 0, 1); break; + case 1: changedState = changeState(lineBuffer, 1, 4, 2); break; + case 2: changedState = changeState(lineBuffer, 1, 8, 3); break; + case 3: changedState = changeState(lineBuffer, 2, 0, 4); break; + case 4: changedState = changeState(lineBuffer, 3, 0, 5); break; + case 5: changedState = changeState(lineBuffer, 255, 255, 7); break; + case 6: changedState = changeState(lineBuffer, 255, 255, 8); break; + case 7: changedState = changeState(lineBuffer, 255, 255, 10); break; + case 8: changedState = changeState(lineBuffer, 255, 255, 11); break; + case 9: changedState = changeState(lineBuffer, 255, 255, 10); break; + case 10: changedState = changeState(lineBuffer, 255, 255, 10); break; + case 11: changedState = changeState(lineBuffer, 255, 255, 10); break; + } + } + if (changedState) select_state = newState; + } + + // ---- encoder ---- + int16_t delta = readEncoderDelta(); + if (delta != 0) { + bool increase = delta > 0; + switch (select_state) { + case 0: changeBrightness(increase); break; + case 1: changeEffectSpeed(increase); break; + case 2: changeEffectIntensity(increase); break; + case 3: changePalette(increase); break; + case 4: changeEffect(increase); break; + case 5: changeHue(increase); break; + case 6: changeSat(increase); break; + case 7: changeCCT(increase); break; + case 8: changePreset(increase); break; + case 9: changeCustom(1, increase); break; + case 10: changeCustom(2, increase); break; + case 11: changeCustom(3, increase); break; + } + } + + loopTime = currentTime; + } + + // ------------------------------------------------------------------ display + + void displayNetworkInfo() { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) display->networkOverlay(PSTR("NETWORK INFO"), 10000); + #endif + } + + void findCurrentEffectAndPalette() { + currentEffectAndPaletteInitialized = true; + effectCurrentIndex = 0; + for (int i = 0; i < strip.getModeCount(); i++) { + if (modes_alpha_indexes[i] == effectCurrent) { effectCurrentIndex = i; break; } + } + effectPaletteIndex = 0; + for (unsigned i = 0; i < getPaletteCount() + customPalettes.size(); i++) { + if (palettes_alpha_indexes[i] == effectPalette) { effectPaletteIndex = i; break; } + } + } + + bool changeState(const char *stateName, byte markedLine, byte markedCol, byte glyph) { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display != nullptr) { + if (display->wakeDisplay()) { display->redraw(true); return false; } + display->overlay(stateName, 750, glyph); + display->setMarkLine(markedLine, markedCol); + } + #endif + return true; + } + + void lampUdated() { + stateUpdated(CALL_MODE_BUTTON); + updateInterfaces(CALL_MODE_BUTTON); + } + + // ------------------------------------------------------------ change methods + + void changeBrightness(bool increase) { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display && display->wakeDisplay()) { display->redraw(true); return; } + if (display) display->updateRedrawTime(); + #endif + if (bri < 40) bri = max(min((increase ? bri + fadeAmount/2 : bri - fadeAmount/2), 255), 0); + else bri = max(min((increase ? bri + fadeAmount : bri - fadeAmount), 255), 0); + lampUdated(); + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) display->updateBrightness(); + #endif + } + + void changeEffect(bool increase) { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display && display->wakeDisplay()) { display->redraw(true); return; } + if (display) display->updateRedrawTime(); + #endif + effectCurrentIndex = max(min((increase ? effectCurrentIndex + 1 : effectCurrentIndex - 1), strip.getModeCount() - 1), 0); + effectCurrent = modes_alpha_indexes[effectCurrentIndex]; + stateChanged = true; + if (applyToAll) { + for (unsigned i = 0; i < strip.getSegmentsNum(); i++) { + Segment &seg = strip.getSegment(i); + if (!seg.isActive()) continue; + seg.setMode(effectCurrent); + } + } else { + strip.getSegment(strip.getMainSegmentId()).setMode(effectCurrent); + } + lampUdated(); + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) display->showCurrentEffectOrPalette(effectCurrent, JSON_mode_names, 3); + #endif + } + + void changeEffectSpeed(bool increase) { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display && display->wakeDisplay()) { display->redraw(true); return; } + if (display) display->updateRedrawTime(); + #endif + effectSpeed = max(min((increase ? effectSpeed + fadeAmount : effectSpeed - fadeAmount), 255), 0); + stateChanged = true; + if (applyToAll) { + for (unsigned i = 0; i < strip.getSegmentsNum(); i++) { + Segment &seg = strip.getSegment(i); + if (!seg.isActive()) continue; + seg.speed = effectSpeed; + } + } else { + strip.getSegment(strip.getMainSegmentId()).speed = effectSpeed; + } + lampUdated(); + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) display->updateSpeed(); + #endif + } + + void changeEffectIntensity(bool increase) { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display && display->wakeDisplay()) { display->redraw(true); return; } + if (display) display->updateRedrawTime(); + #endif + effectIntensity = max(min((increase ? effectIntensity + fadeAmount : effectIntensity - fadeAmount), 255), 0); + stateChanged = true; + if (applyToAll) { + for (unsigned i = 0; i < strip.getSegmentsNum(); i++) { + Segment &seg = strip.getSegment(i); + if (!seg.isActive()) continue; + seg.intensity = effectIntensity; + } + } else { + strip.getSegment(strip.getMainSegmentId()).intensity = effectIntensity; + } + lampUdated(); + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) display->updateIntensity(); + #endif + } + + void changeCustom(uint8_t par, bool increase) { + uint8_t val = 0; + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display && display->wakeDisplay()) { display->redraw(true); return; } + if (display) display->updateRedrawTime(); + #endif + stateChanged = true; + if (applyToAll) { + uint8_t id = strip.getFirstSelectedSegId(); + Segment &sid = strip.getSegment(id); + switch (par) { + case 3: val = sid.custom3 = max(min((increase ? sid.custom3 + fadeAmount : sid.custom3 - fadeAmount), 255), 0); break; + case 2: val = sid.custom2 = max(min((increase ? sid.custom2 + fadeAmount : sid.custom2 - fadeAmount), 255), 0); break; + default: val = sid.custom1 = max(min((increase ? sid.custom1 + fadeAmount : sid.custom1 - fadeAmount), 255), 0); break; + } + for (unsigned i = 0; i < strip.getSegmentsNum(); i++) { + Segment &seg = strip.getSegment(i); + if (!seg.isActive() || i == id) continue; + switch (par) { + case 3: seg.custom3 = sid.custom3; break; + case 2: seg.custom2 = sid.custom2; break; + default: seg.custom1 = sid.custom1; break; + } + } + } else { + Segment &seg = strip.getMainSegment(); + switch (par) { + case 3: val = seg.custom3 = max(min((increase ? seg.custom3 + fadeAmount : seg.custom3 - fadeAmount), 255), 0); break; + case 2: val = seg.custom2 = max(min((increase ? seg.custom2 + fadeAmount : seg.custom2 - fadeAmount), 255), 0); break; + default: val = seg.custom1 = max(min((increase ? seg.custom1 + fadeAmount : seg.custom1 - fadeAmount), 255), 0); break; + } + } + lampUdated(); + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) { + char lineBuffer[64]; + sprintf(lineBuffer, "%d", val); + display->overlay(lineBuffer, 500, 10); + } + #endif + } + + void changePalette(bool increase) { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display && display->wakeDisplay()) { display->redraw(true); return; } + if (display) display->updateRedrawTime(); + #endif + effectPaletteIndex = max(min((unsigned)(increase ? effectPaletteIndex + 1 : effectPaletteIndex - 1), getPaletteCount() + customPalettes.size() - 1), 0U); + effectPalette = palettes_alpha_indexes[effectPaletteIndex]; + stateChanged = true; + if (applyToAll) { + for (unsigned i = 0; i < strip.getSegmentsNum(); i++) { + Segment &seg = strip.getSegment(i); + if (!seg.isActive()) continue; + seg.setPalette(effectPalette); + } + } else { + strip.getSegment(strip.getMainSegmentId()).setPalette(effectPalette); + } + lampUdated(); + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) display->showCurrentEffectOrPalette(effectPalette, JSON_palette_names, 2); + #endif + } + + void changeHue(bool increase) { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display && display->wakeDisplay()) { display->redraw(true); return; } + if (display) display->updateRedrawTime(); + #endif + currentHue1 = max(min((increase ? currentHue1 + fadeAmount : currentHue1 - fadeAmount), 255), 0); + colorHStoRGB(currentHue1 * 256, currentSat1, colPri); + stateChanged = true; + if (applyToAll) { + for (unsigned i = 0; i < strip.getSegmentsNum(); i++) { + Segment &seg = strip.getSegment(i); + if (!seg.isActive()) continue; + seg.colors[0] = RGBW32(colPri[0], colPri[1], colPri[2], colPri[3]); + } + } else { + strip.getSegment(strip.getMainSegmentId()).colors[0] = RGBW32(colPri[0], colPri[1], colPri[2], colPri[3]); + } + lampUdated(); + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) { + char lineBuffer[64]; + sprintf(lineBuffer, "%d", currentHue1); + display->overlay(lineBuffer, 500, 7); + } + #endif + } + + void changeSat(bool increase) { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display && display->wakeDisplay()) { display->redraw(true); return; } + if (display) display->updateRedrawTime(); + #endif + currentSat1 = max(min((increase ? currentSat1 + fadeAmount : currentSat1 - fadeAmount), 255), 0); + colorHStoRGB(currentHue1 * 256, currentSat1, colPri); + if (applyToAll) { + for (unsigned i = 0; i < strip.getSegmentsNum(); i++) { + Segment &seg = strip.getSegment(i); + if (!seg.isActive()) continue; + seg.colors[0] = RGBW32(colPri[0], colPri[1], colPri[2], colPri[3]); + } + } else { + strip.getSegment(strip.getMainSegmentId()).colors[0] = RGBW32(colPri[0], colPri[1], colPri[2], colPri[3]); + } + lampUdated(); + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) { + char lineBuffer[64]; + sprintf(lineBuffer, "%d", currentSat1); + display->overlay(lineBuffer, 500, 8); + } + #endif + } + + void changePreset(bool increase) { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display && display->wakeDisplay()) { display->redraw(true); return; } + if (display) display->updateRedrawTime(); + #endif + if (presetHigh && presetLow && presetHigh > presetLow) { + StaticJsonDocument<64> root; + char str[64]; + sprintf_P(str, PSTR("%d~%d~%s"), presetLow, presetHigh, increase ? "" : "-"); + root["ps"] = str; + deserializeState(root.as(), CALL_MODE_BUTTON_PRESET); + lampUdated(); + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) { + sprintf(str, "%d", currentPreset); + display->overlay(str, 500, 11); + } + #endif + } + } + + void changeCCT(bool increase) { + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display && display->wakeDisplay()) { display->redraw(true); return; } + if (display) display->updateRedrawTime(); + #endif + currentCCT = max(min((increase ? currentCCT + fadeAmount : currentCCT - fadeAmount), 255), 0); + for (unsigned i = 0; i < strip.getSegmentsNum(); i++) { + Segment &seg = strip.getSegment(i); + if (!seg.isActive()) continue; + seg.setCCT(currentCCT); + } + lampUdated(); + #ifdef USERMOD_FOUR_LINE_DISPLAY + if (display) { + char lineBuffer[64]; + sprintf(lineBuffer, "%d", currentCCT); + display->overlay(lineBuffer, 500, 10); + } + #endif + } + + // ------------------------------------------------------------------ config + + void addToConfig(JsonObject &root) override { + JsonObject top = root.createNestedObject(FPSTR(_name)); + top[FPSTR(_enabled)] = enabled; + top[FPSTR(_i2cAddress)] = i2cAddress; + top[FPSTR(_presetLow)] = presetLow; + top[FPSTR(_presetHigh)] = presetHigh; + top[FPSTR(_applyToAll)] = applyToAll; + } + + void appendConfigData() override { + oappend(F("addInfo('I2C-Encoder-UI:i2c-address',1,'default: 64 (0x40)');")); + } + + bool readFromConfig(JsonObject &root) override { + JsonObject top = root[FPSTR(_name)]; + if (top.isNull()) { + DEBUG_PRINT(FPSTR(_name)); + DEBUG_PRINTLN(F(": No config found. (Using defaults.)")); + return false; + } + + uint8_t newAddr = top[FPSTR(_i2cAddress)] | i2cAddress; + + presetHigh = top[FPSTR(_presetHigh)] | presetHigh; + presetLow = top[FPSTR(_presetLow)] | presetLow; + presetHigh = MIN(250, MAX(0, presetHigh)); + presetLow = MIN(250, MAX(0, presetLow)); + enabled = top[FPSTR(_enabled)] | enabled; + applyToAll = top[FPSTR(_applyToAll)] | applyToAll; + + if (!initDone) { + i2cAddress = newAddr; + } else if (i2cAddress != newAddr) { + i2cAddress = newAddr; + deviceFound = false; + setup(); + } + + return !top[FPSTR(_applyToAll)].isNull(); + } +}; + + +const char I2CEncoderUIUsermod::_name[] PROGMEM = "I2C-Encoder-UI"; +const char I2CEncoderUIUsermod::_enabled[] PROGMEM = "enabled"; +const char I2CEncoderUIUsermod::_i2cAddress[] PROGMEM = "i2c-address"; +const char I2CEncoderUIUsermod::_presetHigh[] PROGMEM = "preset-high"; +const char I2CEncoderUIUsermod::_presetLow[] PROGMEM = "preset-low"; +const char I2CEncoderUIUsermod::_applyToAll[] PROGMEM = "apply-2-all-seg"; + + +static I2CEncoderUIUsermod usermod_v2_i2c_encoder_ui; +REGISTER_USERMOD(usermod_v2_i2c_encoder_ui); diff --git a/wled00/const.h b/wled00/const.h index 62d9c45f4d..604d462809 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -225,6 +225,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_RF433 56 //Usermod "usermod_v2_RF433.h" #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" +#define USERMOD_ID_I2C_ENCODER_UI 59 //Usermod "usermod_v2_i2c_encoder_ui" //Wifi encryption type #ifdef WLED_ENABLE_WPA_ENTERPRISE From 4778397a581339b15f7a4aab410ec32f9f108ca4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 24 May 2026 04:11:53 +0000 Subject: [PATCH 2/2] Add GC9A01 round display usermod MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new usermod at usermods/usermod_v2_gc9a01_display for the Waveshare 1.28" Round LCD Module (240x240 IPS, GC9A01 driver, SPI). Shows a circular watch-face layout: outer arc for brightness (0-360° yellow progress), colour disc filled with the primary segment colour, effect name, brightness %, and IP address. Screen blanks after a configurable timeout. Uses TFT_eSPI (Bodmer) which requires compile-time pin configuration via build flags — a ready-to-use platformio_override.sample.ini is included. Pins are registered with PinManager for conflict detection. Exposes wakeDisplay(), showOverlay(), and forceRedraw() for pairing with the I2C encoder usermod in a later pass. USERMOD_ID_GC9A01_DISPLAY (60) added to const.h. UM_GC9A01Display and UM_I2CEncoderUI added to pin_manager.h. --- .../usermod_v2_gc9a01_display/library.json | 8 + .../platformio_override.sample.ini | 46 ++ usermods/usermod_v2_gc9a01_display/readme.md | 73 +++ .../usermod_v2_gc9a01_display.cpp | 459 ++++++++++++++++++ wled00/const.h | 1 + wled00/pin_manager.h | 4 +- 6 files changed, 590 insertions(+), 1 deletion(-) create mode 100644 usermods/usermod_v2_gc9a01_display/library.json create mode 100644 usermods/usermod_v2_gc9a01_display/platformio_override.sample.ini create mode 100644 usermods/usermod_v2_gc9a01_display/readme.md create mode 100644 usermods/usermod_v2_gc9a01_display/usermod_v2_gc9a01_display.cpp diff --git a/usermods/usermod_v2_gc9a01_display/library.json b/usermods/usermod_v2_gc9a01_display/library.json new file mode 100644 index 0000000000..f4e6908ba7 --- /dev/null +++ b/usermods/usermod_v2_gc9a01_display/library.json @@ -0,0 +1,8 @@ +{ + "name": "gc9a01_display", + "build": { "libArchive": false }, + "dependencies": { + "bodmer/TFT_eSPI": "~2.5.43", + "SPI": "" + } +} diff --git a/usermods/usermod_v2_gc9a01_display/platformio_override.sample.ini b/usermods/usermod_v2_gc9a01_display/platformio_override.sample.ini new file mode 100644 index 0000000000..9ef9aea717 --- /dev/null +++ b/usermods/usermod_v2_gc9a01_display/platformio_override.sample.ini @@ -0,0 +1,46 @@ +; GC9A01 Round Display usermod — sample build configuration +; Copy/rename to platformio_override.ini and adjust pin numbers for your board. +; +; All SPI pins MUST be set at compile time (TFT_eSPI requirement). +; CS / DC / RST / BL can also be changed here freely. +; +; Default pin assignments below are for a generic ESP32 DevKit: +; MOSI = GPIO 23 SCLK = GPIO 18 +; CS = GPIO 15 DC = GPIO 2 +; RST = GPIO 4 BL = GPIO 32 (set -1 if backlight is always on) + +[env:esp32dev_gc9a01] +extends = env:esp32dev +lib_deps = ${esp32dev.lib_deps} + bodmer/TFT_eSPI @ ~2.5.43 +build_flags = ${esp32dev.build_flags} + ; ---- Enable the usermod ---- + -D USERMOD_GC9A01_DISPLAY + + ; ---- TFT_eSPI configuration ---- + -D USER_SETUP_LOADED=1 + -D USER_SETUP_ID=1 + -D GC9A01_DRIVER=1 + -D TFT_WIDTH=240 + -D TFT_HEIGHT=240 + + ; ---- SPI data / clock (shared bus) ---- + -D TFT_MOSI=23 + -D TFT_SCLK=18 + -D TFT_MISO=-1 + + ; ---- Display-specific pins ---- + -D TFT_CS=15 + -D TFT_DC=2 + -D TFT_RST=4 + -D TFT_BL=32 + -D TFT_BACKLIGHT_ON=HIGH + + ; ---- Font / feature selection ---- + -D LOAD_GLCD=1 + -D LOAD_FONT2=1 + -D LOAD_FONT4=1 + -D SMOOTH_FONT=1 + + ; ---- SPI clock speed ---- + -D SPI_FREQUENCY=40000000 diff --git a/usermods/usermod_v2_gc9a01_display/readme.md b/usermods/usermod_v2_gc9a01_display/readme.md new file mode 100644 index 0000000000..f4a677f332 --- /dev/null +++ b/usermods/usermod_v2_gc9a01_display/readme.md @@ -0,0 +1,73 @@ +# GC9A01 Round Display Usermod + +Displays WLED state on a **1.28" round IPS LCD** (240×240, GC9A01 driver, 4-wire SPI). +Tested with the Waveshare 1.28" Round LCD Module. + +## Watch-face layout + +``` + ╭──────────────────╮ + ╱ │ ░░░░░░░░░░░░░░ │ ╲ ← brightness arc (yellow, 0–360°) + ╱ │ [Effect Name] │ ╲ + │ │ ╭────────────╮ │ │ + │ │ │ │ │ │ ← colour disc (primary segment colour) + │ │ │ 75 % │ │ │ + │ │ ╰────────────╯ │ │ + ╲ │ 192.168.1.10 │ ╱ + ╲ │ │ ╱ + ╰──────────────────╯ +``` + +| Element | Content | +|---|---| +| Outer arc | Brightness 0-100 % (yellow on dark grey ring) | +| Centre disc | Primary colour of the main segment | +| Disc text | Brightness percentage (contrast-adaptive colour) | +| Upper text | Current effect name | +| Lower text | IP address / AP mode address / "No WiFi" | + +## Wiring + +| Display | ESP32 (default) | +|---|---| +| VCC | 3.3 V | +| GND | GND | +| DIN (MOSI) | GPIO 23 | +| CLK | GPIO 18 | +| CS | GPIO 15 | +| DC | GPIO 2 | +| RST | GPIO 4 | +| BL | GPIO 32 | + +## Building + +Copy `platformio_override.sample.ini` to `platformio_override.ini` and adjust pins for your board. Then build the `esp32dev_gc9a01` environment: + +```bash +pio run -e esp32dev_gc9a01 +``` + +All eight pin values **must** be set as build flags — TFT_eSPI does not support runtime pin selection. + +## Configuration (WLED Settings → Usermods) + +| Option | Default | Description | +|---|---|---| +| `enabled` | true | Enable/disable the display | +| `screenTimeoutSec` | 300 | Seconds of inactivity before display blanks (0 = never) | +| `flip` | false | Rotate display 180° | + +The four pin fields shown in the UI are read-only — they reflect the compile-time values. + +## Public API (for pairing with other usermods) + +```cpp +GC9A01DisplayUsermod *disp = + (GC9A01DisplayUsermod *)UsermodManager::lookup(USERMOD_ID_GC9A01_DISPLAY); + +if (disp) { + disp->wakeDisplay(); // wake from sleep; returns true if was off + disp->showOverlay("Brightness", "75%", 1500); // 2-line overlay for 1.5 s + disp->forceRedraw(); // immediate full refresh +} +``` diff --git a/usermods/usermod_v2_gc9a01_display/usermod_v2_gc9a01_display.cpp b/usermods/usermod_v2_gc9a01_display/usermod_v2_gc9a01_display.cpp new file mode 100644 index 0000000000..ecae1a82b4 --- /dev/null +++ b/usermods/usermod_v2_gc9a01_display/usermod_v2_gc9a01_display.cpp @@ -0,0 +1,459 @@ +#include "wled.h" + +// +// GC9A01 Round Display Usermod +// +// Adds support for the Waveshare 1.28" Round LCD Module (and compatible +// GC9A01-based 240×240 IPS displays) as a WLED status screen. +// +// Uses TFT_eSPI (Bodmer) which must be configured via build flags — +// see platformio_override.sample.ini for a ready-to-use example. +// +// Displays: +// • Outer ring : brightness progress arc +// • Centre fill : primary segment colour +// • Top text : current effect name +// • Centre text : brightness percentage +// • Bottom text : IP address (or "No WiFi") +// +// Public API (for pairing with a rotary encoder or other usermods): +// wakeDisplay() — wake from timeout, returns true if was sleeping +// showOverlay(...) — display temporary text over the watch face +// forceRedraw() — request immediate full refresh +// + +#ifdef USERMOD_GC9A01_DISPLAY + +#include +#include + +// Verify required build flags are present +#ifndef USER_SETUP_LOADED + #ifndef GC9A01_DRIVER + #error "GC9A01_DISPLAY: define GC9A01_DRIVER=1 in build flags" + #endif + #ifndef TFT_WIDTH + #error "GC9A01_DISPLAY: define TFT_WIDTH=240 in build flags" + #endif + #ifndef TFT_HEIGHT + #error "GC9A01_DISPLAY: define TFT_HEIGHT=240 in build flags" + #endif + #ifndef TFT_CS + #error "GC9A01_DISPLAY: define TFT_CS in build flags" + #endif + #ifndef TFT_DC + #error "GC9A01_DISPLAY: define TFT_DC in build flags" + #endif + #ifndef TFT_RST + #error "GC9A01_DISPLAY: define TFT_RST in build flags" + #endif +#endif + +#ifndef TFT_BL + #define TFT_BL -1 +#endif + +// How often the loop checks for state changes (ms) +#define GC9A01_REFRESH_MS 500 +// Screen off after this many ms without a state change +#define GC9A01_TIMEOUT_MS (5UL * 60UL * 1000UL) // 5 minutes +// Overlay display duration default (ms) +#define GC9A01_OVERLAY_MS 2000 +// Max chars for effect/palette name display +#define GC9A01_NAME_BUF 24 + +// Display geometry +#define GC9A01_CX 120 // centre X +#define GC9A01_CY 120 // centre Y +#define GC9A01_R 120 // display radius +// Brightness arc: outer radius, inner radius +#define GC9A01_ARC_R_OUT 115 +#define GC9A01_ARC_R_IN 104 + +// ------------------------------------------------------------------------- +// File-scope TFT object (must be file-scope so build flags apply) +// ------------------------------------------------------------------------- +TFT_eSPI tft = TFT_eSPI(TFT_WIDTH, TFT_HEIGHT); + +// ------------------------------------------------------------------------- + +class GC9A01DisplayUsermod : public Usermod { + + private: + + bool enabled = true; + bool initDone = false; + bool displayOff = false; + bool needRedraw = true; + + // For state-change detection + uint8_t knownBri = 0; + uint8_t knownMode = 255; + uint32_t knownColor = 0; + bool knownWifi = false; + IPAddress knownIp; + + unsigned long lastCheck = 0; + unsigned long lastChange = 0; // millis of last detected state change + + // Overlay support + bool overlayActive = false; + unsigned long overlayUntil = 0; + char overlayLine1[GC9A01_NAME_BUF] = {0}; + char overlayLine2[GC9A01_NAME_BUF] = {0}; + + // Config + uint16_t screenTimeoutSec = 300; // 5 minutes + bool flipDisplay = false; + + static const char _name[]; + static const char _enabled[]; + static const char _timeout[]; + static const char _flip[]; + + // ---------------------------------------------------------------- helpers + + // Copy TFT_MOSI / TFT_SCLK compile-time pins into WLED's global SPI vars + // so PinManager and the web UI see them (same pattern as pixels_dice_tray). + static void setSPIPinsFromMacros() { +#ifdef TFT_MOSI + spi_mosi = TFT_MOSI; +#endif +#ifdef TFT_MISO + #if defined(TFT_MOSI) && (TFT_MISO == TFT_MOSI) + spi_miso = -1; // shared data line — not a real MISO + #else + spi_miso = TFT_MISO; + #endif +#endif +#ifdef TFT_SCLK + spi_sclk = TFT_SCLK; +#endif + } + + // Convert WLED 0xRRGGBB(WW) to TFT RGB565 + uint16_t wledToRGB565(uint32_t color) { + uint8_t r = (color >> 16) & 0xFF; + uint8_t g = (color >> 8) & 0xFF; + uint8_t b = color & 0xFF; + return tft.color565(r, g, b); + } + + void setBacklight(bool on) { + if (TFT_BL < 0) return; +#ifndef TFT_BACKLIGHT_ON + #define TFT_BACKLIGHT_ON HIGH +#endif + digitalWrite(TFT_BL, on ? TFT_BACKLIGHT_ON : (TFT_BACKLIGHT_ON == HIGH ? LOW : HIGH)); + } + + // ---------------------------------------------------------------- drawing + + // Fill the circular display background with a solid colour. + void fillDisplay(uint32_t color) { + tft.fillCircle(GC9A01_CX, GC9A01_CY, GC9A01_R, wledToRGB565(color)); + } + + // Draw the brightness arc (0° = 12 o'clock, clockwise). + // TFT_eSPI drawArc: angle 0 = top, clockwise. + void drawBrightnessArc(uint8_t brightness) { + // Background ring + tft.drawArc(GC9A01_CX, GC9A01_CY, + GC9A01_ARC_R_OUT, GC9A01_ARC_R_IN, + 0, 360, + 0x2104 /*dark grey*/, TFT_BLACK); + if (brightness > 0) { + uint16_t endAngle = (uint16_t)((uint32_t)brightness * 360 / 255); + if (endAngle == 0) endAngle = 1; + tft.drawArc(GC9A01_CX, GC9A01_CY, + GC9A01_ARC_R_OUT, GC9A01_ARC_R_IN, + 0, endAngle, + TFT_YELLOW, TFT_BLACK); + } + } + + // Draw the primary-colour filled centre circle. + void drawColorDisk(uint32_t color) { + uint16_t c565 = wledToRGB565(color); + // 80 px radius gives a nice centre disc with room for text + tft.fillCircle(GC9A01_CX, GC9A01_CY, 80, c565); + // Thin border between disc and text area + tft.drawCircle(GC9A01_CX, GC9A01_CY, 80, TFT_BLACK); + } + + // Centered text helper — writes text centered at (cx, y). + void drawCenteredText(const char *text, int16_t y, + uint8_t textSize, uint32_t color) { + tft.setTextSize(textSize); + tft.setTextDatum(TC_DATUM); + tft.setTextColor(color, TFT_BLACK); + tft.drawString(text, GC9A01_CX, y); + } + + // Full watch-face redraw. + void drawWatchFace() { + uint32_t primaryColor = RGBW32(colPri[0], colPri[1], colPri[2], colPri[3]); + + // Black background + tft.fillScreen(TFT_BLACK); + + // Outer brightness ring + drawBrightnessArc(bri); + + // Coloured centre disc + drawColorDisk(primaryColor); + + // Effect name — white text above centre + char modeName[GC9A01_NAME_BUF]; + extractModeName(effectCurrent, JSON_mode_names, modeName, GC9A01_NAME_BUF - 1); + tft.setTextColor(TFT_WHITE, TFT_BLACK); + tft.setTextDatum(TC_DATUM); + tft.setTextSize(2); + tft.drawString(modeName, GC9A01_CX, 54); + + // Brightness % — large, centred on the disc + char briBuf[8]; + snprintf_P(briBuf, sizeof(briBuf), PSTR("%d%%"), (bri * 100 + 127) / 255); + + // Pick a contrasting text colour based on primary colour brightness + uint8_t luma = (77 * colPri[0] + 150 * colPri[1] + 29 * colPri[2]) >> 8; + uint16_t textCol = (luma > 128) ? TFT_BLACK : TFT_WHITE; + tft.setTextDatum(MC_DATUM); + tft.setTextColor(textCol); + tft.setTextSize(3); + tft.drawString(briBuf, GC9A01_CX, GC9A01_CY); + + // IP address — small, bottom of display + char ipBuf[20]; + if (WLED_CONNECTED) { + IPAddress ip = Network.localIP(); + snprintf_P(ipBuf, sizeof(ipBuf), PSTR("%d.%d.%d.%d"), + ip[0], ip[1], ip[2], ip[3]); + } else if (apActive) { + strlcpy_P(ipBuf, PSTR("AP 4.3.2.1"), sizeof(ipBuf)); + } else { + strlcpy_P(ipBuf, PSTR("No WiFi"), sizeof(ipBuf)); + } + tft.setTextDatum(BC_DATUM); + tft.setTextColor(TFT_DARKGREY); + tft.setTextSize(1); + tft.drawString(ipBuf, GC9A01_CX, 195); + } + + // Overlay: two lines of text in a rounded rect, auto-cleared after duration. + void drawOverlay() { + tft.fillRoundRect(30, 85, 180, 70, 12, TFT_NAVY); + tft.drawRoundRect(30, 85, 180, 70, 12, TFT_WHITE); + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_WHITE); + tft.setTextSize(2); + tft.drawString(overlayLine1, GC9A01_CX, 108); + if (overlayLine2[0]) { + tft.setTextSize(1); + tft.drawString(overlayLine2, GC9A01_CX, 135); + } + } + + // WLED splash screen shown once at boot. + void drawSplash() { + tft.fillScreen(TFT_BLACK); + tft.setTextDatum(MC_DATUM); + tft.setTextColor(TFT_ORANGE); + tft.setTextSize(4); + tft.drawString("WLED", GC9A01_CX, GC9A01_CY - 20); + tft.setTextColor(TFT_DARKGREY); + tft.setTextSize(1); + tft.drawString(versionString, GC9A01_CX, GC9A01_CY + 25); + } + + public: + + uint16_t getId() override { return USERMOD_ID_GC9A01_DISPLAY; } + + // ------------------------------------------------------ public API + + // Wake the display from timeout sleep. + // Returns true if the display was sleeping (so callers can discard input). + bool wakeDisplay() { + if (!enabled || !initDone) return false; + if (displayOff) { + setBacklight(true); + displayOff = false; + needRedraw = true; + lastChange = millis(); + return true; + } + return false; + } + + // Show a temporary two-line overlay over the watch face. + void showOverlay(const char *line1, const char *line2 = nullptr, + uint32_t durationMs = GC9A01_OVERLAY_MS) { + if (!enabled || !initDone) return; + wakeDisplay(); + strlcpy(overlayLine1, line1 ? line1 : "", sizeof(overlayLine1)); + strlcpy(overlayLine2, line2 ? line2 : "", sizeof(overlayLine2)); + overlayUntil = millis() + durationMs; + overlayActive = true; + drawOverlay(); + } + + // Request an immediate full redraw on the next loop tick. + void forceRedraw() { + needRedraw = true; + lastChange = millis(); + } + + // ------------------------------------------------------ lifecycle + + void setup() override { + DEBUG_PRINTLN(F("GC9A01 Display: init.")); + + setSPIPinsFromMacros(); + + // Register shared SPI bus pins + PinManagerPinType spiPins[] = { + { spi_mosi, true }, { spi_miso, false }, { spi_sclk, true } + }; + if (spi_sclk >= 0 && !PinManager::allocateMultiplePins(spiPins, 3, PinOwner::HW_SPI)) { + DEBUG_PRINTLN(F("GC9A01 Display: SPI pin allocation failed, disabling.")); + enabled = false; + return; + } + + // Register display-specific pins (CS, DC, RST, BL) + PinManagerPinType dispPins[] = { + { TFT_CS, true }, { TFT_DC, true }, { TFT_RST, true }, { TFT_BL, true } + }; + if (!PinManager::allocateMultiplePins(dispPins, 4, PinOwner::UM_GC9A01Display)) { + PinManager::deallocateMultiplePins(spiPins, 3, PinOwner::HW_SPI); + DEBUG_PRINTLN(F("GC9A01 Display: display pin allocation failed, disabling.")); + enabled = false; + return; + } + + tft.init(); + tft.setRotation(flipDisplay ? 2 : 0); + tft.fillScreen(TFT_BLACK); + + if (TFT_BL >= 0) { + pinMode(TFT_BL, OUTPUT); + setBacklight(true); + } + + drawSplash(); + delay(1200); + + lastChange = millis(); + lastCheck = millis(); + initDone = true; + } + + void loop() override { + if (!enabled || !initDone) return; + + unsigned long now = millis(); + if (now - lastCheck < GC9A01_REFRESH_MS) return; + lastCheck = now; + + // Clear overlay once it expires + if (overlayActive && now >= overlayUntil) { + overlayActive = false; + needRedraw = true; + } + + // Detect state changes + uint32_t curColor = RGBW32(colPri[0], colPri[1], colPri[2], colPri[3]); + bool curWifi = WLED_CONNECTED || apActive; + if (bri != knownBri || + effectCurrent != knownMode || + curColor != knownColor || + curWifi != knownWifi || + (curWifi && Network.localIP() != knownIp)) { + needRedraw = true; + lastChange = now; + knownBri = bri; + knownMode = effectCurrent; + knownColor = curColor; + knownWifi = curWifi; + knownIp = curWifi ? Network.localIP() : IPAddress(0,0,0,0); + } + + // Screen timeout + unsigned long timeoutMs = (unsigned long)screenTimeoutSec * 1000UL; + if (!displayOff && (now - lastChange > timeoutMs)) { + setBacklight(false); + tft.fillScreen(TFT_BLACK); + displayOff = true; + needRedraw = false; + return; + } + + if (displayOff || !needRedraw || overlayActive) return; + + needRedraw = false; + drawWatchFace(); + } + + // ------------------------------------------------------ config + + void addToConfig(JsonObject &root) override { + JsonObject top = root.createNestedObject(FPSTR(_name)); + top[FPSTR(_enabled)] = enabled; + top[FPSTR(_timeout)] = screenTimeoutSec; + top[FPSTR(_flip)] = flipDisplay; + // Expose compile-time pin values so the UI can show them (read-only) + JsonArray pins = top.createNestedArray("pin"); + pins.add(TFT_CS); + pins.add(TFT_DC); + pins.add(TFT_RST); + pins.add(TFT_BL); + } + + void appendConfigData() override { + // Show pin labels (read-only — pins are compile-time constants) + oappend(F("addInfo('GC9A01-Display:pin[]',0,'','CS (compile-time)');")); + oappend(F("addInfo('GC9A01-Display:pin[]',1,'','DC (compile-time)');")); + oappend(F("addInfo('GC9A01-Display:pin[]',2,'','RST (compile-time)');")); + oappend(F("addInfo('GC9A01-Display:pin[]',3,'','BL (compile-time)');")); + } + + bool readFromConfig(JsonObject &root) override { + JsonObject top = root[FPSTR(_name)]; + if (top.isNull()) return false; + bool en = top[FPSTR(_enabled)] | enabled; + if (en != enabled) { + enabled = en; + if (!enabled && TFT_BL >= 0) setBacklight(false); + } + screenTimeoutSec = top[FPSTR(_timeout)] | screenTimeoutSec; + bool newFlip = top[FPSTR(_flip)] | flipDisplay; + if (newFlip != flipDisplay && initDone) { + flipDisplay = newFlip; + tft.setRotation(flipDisplay ? 2 : 0); + needRedraw = true; + } else { + flipDisplay = newFlip; + } + return !top[FPSTR(_timeout)].isNull(); + } + + void addToJsonInfo(JsonObject &root) override { + JsonObject user = root["u"]; + if (user.isNull()) user = root.createNestedObject("u"); + JsonArray arr = user.createNestedArray(F("GC9A01")); + arr.add(enabled ? F("active") : F("disabled")); + } +}; + + +const char GC9A01DisplayUsermod::_name[] PROGMEM = "GC9A01-Display"; +const char GC9A01DisplayUsermod::_enabled[] PROGMEM = "enabled"; +const char GC9A01DisplayUsermod::_timeout[] PROGMEM = "screenTimeoutSec"; +const char GC9A01DisplayUsermod::_flip[] PROGMEM = "flip"; + + +static GC9A01DisplayUsermod gc9a01_display; +REGISTER_USERMOD(gc9a01_display); + +#endif // USERMOD_GC9A01_DISPLAY diff --git a/wled00/const.h b/wled00/const.h index 604d462809..c332906872 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -226,6 +226,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" #define USERMOD_ID_I2C_ENCODER_UI 59 //Usermod "usermod_v2_i2c_encoder_ui" +#define USERMOD_ID_GC9A01_DISPLAY 60 //Usermod "usermod_v2_gc9a01_display" //Wifi encryption type #ifdef WLED_ENABLE_WPA_ENTERPRISE diff --git a/wled00/pin_manager.h b/wled00/pin_manager.h index 7bdd5cfc20..8c872f65fe 100644 --- a/wled00/pin_manager.h +++ b/wled00/pin_manager.h @@ -76,7 +76,9 @@ enum struct PinOwner : uint8_t { UM_LDR_DUSK_DAWN = USERMOD_ID_LDR_DUSK_DAWN, // 0x2B // Usermod "usermod_LDR_Dusk_Dawn_v2.h" UM_MAX17048 = USERMOD_ID_MAX17048, // 0x2F // Usermod "usermod_max17048.h" UM_BME68X = USERMOD_ID_BME68X, // 0x31 // Usermod "usermod_bme68x.h -- Uses "standard" HW_I2C pins - UM_PIXELS_DICE_TRAY = USERMOD_ID_PIXELS_DICE_TRAY // 0x35 // Usermod "pixels_dice_tray.h" -- Needs compile time specified 6 pins for display including SPI. + UM_PIXELS_DICE_TRAY = USERMOD_ID_PIXELS_DICE_TRAY, // 0x35 // Usermod "pixels_dice_tray.h" -- Needs compile time specified 6 pins for display including SPI. + UM_I2CEncoderUI = USERMOD_ID_I2C_ENCODER_UI, // 0x3B // Usermod "usermod_v2_i2c_encoder_ui" + UM_GC9A01Display = USERMOD_ID_GC9A01_DISPLAY // 0x3C // Usermod "usermod_v2_gc9a01_display" -- Needs compile time SPI pins }; static_assert(0u == static_cast(PinOwner::None), "PinOwner::None must be zero, so default array initialization works as expected");