diff --git a/pio-scripts/cores3_v17_neopixelbus_patch.py b/pio-scripts/cores3_v17_neopixelbus_patch.py new file mode 100644 index 0000000000..52de00242f --- /dev/null +++ b/pio-scripts/cores3_v17_neopixelbus_patch.py @@ -0,0 +1,519 @@ +# M5Stack CoreS3 / WLED V17 NeoPixelBus Production Integration +# +# PlatformIO PRE script for the m5stack_cores3 environment. +# +# This script makes the two hardware-validated NeoPixelBus fixes reproducible: +# +# 1) ESP32-S3 RMT output: +# - DMA enabled +# - 1024-symbol TX buffer +# - non-blocking readiness check without ESP-IDF timeout=0 log noise +# - guarded RMT handle lifecycle +# +# 2) ESP32-S3 LCD/GDMA output: +# - fully stop/reset/disconnect/delete the GDMA channel when the last +# LCD mux bus is destroyed +# - prevents stale LCD peripheral ownership across runtime bus rebuilds +# +# The script patches the NeoPixelBus source downloaded by PlatformIO under +# .pio/libdeps. It does not require modified library files to be committed. +# +# NeoPixelBus itself is licensed under LGPL-3.0-or-later. This script keeps +# the upstream license header in the patched files untouched. +# +# The patch is idempotent and is safe to run on every PlatformIO build. + +from pathlib import Path + +Import("env") + +RMT_MARKER = "CoreS3 NeoPixelBus RMT DMA1024 production patch" +LCD_MARKER = "CoreS3 NeoPixelBus LCD GDMA teardown production patch" + +LCD_TEST_PRAGMAS = ( + '#pragma message("=== CORES3 TEST: ACTIVE NeoPixelBus NeoEsp32LcdXMethod.h IS COMPILED ===")', + '#pragma message("=== CORES3 TEST: NeoEsp32LcdXMethod.h IS COMPILED ===")', +) + + +def _replace_function(source: str, signature: str, replacement: str) -> str: + '''Replace one C++ function body by matching balanced braces.''' + sig_pos = source.find(signature) + if sig_pos < 0: + raise RuntimeError(f"signature not found: {signature}") + + brace_start = source.find("{", sig_pos + len(signature)) + if brace_start < 0: + raise RuntimeError(f"opening brace not found: {signature}") + + depth = 0 + in_string = False + in_char = False + escape = False + line_comment = False + block_comment = False + i = brace_start + + while i < len(source): + ch = source[i] + nxt = source[i + 1] if i + 1 < len(source) else "" + + if line_comment: + if ch == "\n": + line_comment = False + i += 1 + continue + + if block_comment: + if ch == "*" and nxt == "/": + block_comment = False + i += 2 + continue + i += 1 + continue + + if escape: + escape = False + i += 1 + continue + + if (in_string or in_char) and ch == "\\": + escape = True + i += 1 + continue + + if not in_string and not in_char: + if ch == "/" and nxt == "/": + line_comment = True + i += 2 + continue + if ch == "/" and nxt == "*": + block_comment = True + i += 2 + continue + + if not in_char and ch == '"': + in_string = not in_string + i += 1 + continue + + if not in_string and ch == "'": + in_char = not in_char + i += 1 + continue + + if not in_string and not in_char: + if ch == "{": + depth += 1 + elif ch == "}": + depth -= 1 + if depth == 0: + return source[:sig_pos] + replacement + source[i + 1:] + + i += 1 + + raise RuntimeError(f"matching closing brace not found: {signature}") + + +def _find_rmt_target() -> Path: + '''Locate the active NeoPixelBus RMT header used by this PIO environment.''' + libdeps_root = Path(env.subst("$PROJECT_LIBDEPS_DIR")) / env.subst("$PIOENV") + + candidates = [] + for path in libdeps_root.rglob("NeoEsp32RmtXMethod.h"): + try: + text = path.read_text(encoding="utf-8") + except Exception: + continue + + if "NeoEsp32RmtMethodBase" in text and "rmt_new_tx_channel" in text: + candidates.append(path) + + # WLED's source-pinned NeoPixelBus package is installed as NeoPixelBus@src-... + # Prefer that copy when PlatformIO also leaves another NeoPixelBus directory. + preferred = [p for p in candidates if "NeoPixelBus@src-" in str(p)] + + if len(preferred) == 1: + return preferred[0] + if len(candidates) == 1: + return candidates[0] + + detail = "\n".join(f" {p}" for p in candidates) or " " + raise RuntimeError( + "CoreS3 NeoPixelBus patch could not uniquely locate " + "NeoEsp32RmtXMethod.h:\n" + detail + ) + + +def _find_lcd_target(rmt_target: Path) -> Path: + ''' + Locate NeoEsp32LcdXMethod.h from the same active NeoPixelBus package. + + The sibling lookup is intentionally preferred so RMT and LCD patches can + never be applied to different NeoPixelBus copies in .pio/libdeps. + ''' + sibling = rmt_target.with_name("NeoEsp32LcdXMethod.h") + if sibling.is_file(): + try: + text = sibling.read_text(encoding="utf-8") + except Exception as exc: + raise RuntimeError( + f"CoreS3 LCD patch could not read {sibling}: {exc}" + ) from exc + + if "NeoEspLcdMonoBuffContext" in text and "gdma_connect" in text: + return sibling + + libdeps_root = Path(env.subst("$PROJECT_LIBDEPS_DIR")) / env.subst("$PIOENV") + candidates = [] + + for path in libdeps_root.rglob("NeoEsp32LcdXMethod.h"): + try: + text = path.read_text(encoding="utf-8") + except Exception: + continue + + if "NeoEspLcdMonoBuffContext" in text and "gdma_connect" in text: + candidates.append(path) + + preferred = [p for p in candidates if "NeoPixelBus@src-" in str(p)] + + if len(preferred) == 1: + return preferred[0] + if len(candidates) == 1: + return candidates[0] + + detail = "\n".join(f" {p}" for p in candidates) or " " + raise RuntimeError( + "CoreS3 NeoPixelBus patch could not uniquely locate " + "NeoEsp32LcdXMethod.h:\n" + detail + ) + + +def _patch_rmt(target: Path) -> None: + '''Apply the already validated ESP32-S3 RMT DMA1024 production patch.''' + text = target.read_text(encoding="utf-8") + + if RMT_MARKER in text: + required = [ + "config.mem_block_symbols = 1024;", + "config.flags.with_dma = true;", + "return (_channel != nullptr && _led_encoder != nullptr);", + ] + missing = [item for item in required if item not in text] + if missing: + raise RuntimeError( + "CoreS3 RMT patch marker exists but verification failed; " + f"missing: {missing}" + ) + + print(f"[CoreS3 RMT DMA1024] patch already present: {target.name}") + return + + destructor = f''' ~NeoEsp32RmtMethodBase() + {{ + // {RMT_MARKER} + if (_channel != nullptr) + {{ + ESP_ERROR_CHECK_WITHOUT_ABORT( + rmt_tx_wait_all_done(_channel, 10000 / portTICK_PERIOD_MS) + ); + ESP_ERROR_CHECK_WITHOUT_ABORT(rmt_disable(_channel)); + }} + + if (_led_encoder != nullptr) + {{ + ESP_ERROR_CHECK_WITHOUT_ABORT(rmt_del_encoder(_led_encoder)); + _led_encoder = nullptr; + }} + + if (_channel != nullptr) + {{ + ESP_ERROR_CHECK_WITHOUT_ABORT(rmt_del_channel(_channel)); + _channel = nullptr; + }} + + gpio_matrix_out(_pin, 0x100, false, false); + pinMode(_pin, INPUT); + + free(_dataEditing); + free(_dataSending); + }}''' + + ready = f''' bool IsReadyToUpdate() const + {{ + // {RMT_MARKER} + // ESP-IDF 5.5 emits an error log when timeout=0 is used as a busy poll. + // Update() performs the actual completion wait before transmitting. + return (_channel != nullptr && _led_encoder != nullptr); + }}''' + + initialize = f''' void Initialize() + {{ + // {RMT_MARKER} + rmt_tx_channel_config_t config = {{}}; + config.clk_src = RMT_CLK_SRC_DEFAULT; + config.gpio_num = static_cast(_pin); + +#if defined(CONFIG_IDF_TARGET_ESP32S3) + // CoreS3 / ESP32-S3 uses DMA with a 1024-symbol buffer. + // A 32-pixel RGB frame is 768 RMT symbols, so the complete frame + // fits in one DMA buffer and avoids the refill boundary implicated + // in the previously observed tail-pixel anomaly. + config.mem_block_symbols = 1024; +#else + config.mem_block_symbols = 192; +#endif + + config.resolution_hz = T_SPEED::RmtTicksPerSecond; + config.trans_queue_depth = 4; + config.flags.invert_out = T_INVERTED::Inverted; +#if defined(CONFIG_IDF_TARGET_ESP32S3) + config.flags.with_dma = true; +#else + config.flags.with_dma = false; +#endif + + esp_err_t ret = rmt_new_tx_channel(&config, &_channel); + if (ret != ESP_OK || _channel == nullptr) + {{ + _channel = nullptr; + return; + }} + + led_strip_encoder_config_t encoder_config = {{}}; + encoder_config.resolution = T_SPEED::RmtTicksPerSecond; + _tx_config.loop_count = 0; + + ret = rmt_new_led_strip_encoder( + &encoder_config, + &_led_encoder, + T_SPEED::RmtBit0, + T_SPEED::RmtBit1 + ); + + if (ret != ESP_OK || _led_encoder == nullptr) + {{ + ESP_ERROR_CHECK_WITHOUT_ABORT(rmt_del_channel(_channel)); + _channel = nullptr; + _led_encoder = nullptr; + return; + }} + + ret = rmt_enable(_channel); + if (ret != ESP_OK) + {{ + ESP_ERROR_CHECK_WITHOUT_ABORT(rmt_del_encoder(_led_encoder)); + _led_encoder = nullptr; + ESP_ERROR_CHECK_WITHOUT_ABORT(rmt_del_channel(_channel)); + _channel = nullptr; + return; + }} + }}''' + + update = f''' void Update(bool maintainBufferConsistency) + {{ + // {RMT_MARKER} + if (_channel == nullptr || _led_encoder == nullptr) + {{ + return; + }} + + // Serialize writes: wait for the previous asynchronous RMT transfer + // to finish before starting the next frame. + if (ESP_OK == ESP_ERROR_CHECK_WITHOUT_ABORT( + rmt_tx_wait_all_done(_channel, 10000 / portTICK_PERIOD_MS))) + {{ + const esp_err_t ret = rmt_transmit( + _channel, + _led_encoder, + _dataEditing, + _sizeData, + &_tx_config + ); + + if (ret != ESP_OK) + {{ + return; + }} + + if (maintainBufferConsistency) + {{ + memcpy(_dataSending, _dataEditing, _sizeData); + }} + + std::swap(_dataSending, _dataEditing); + }} + }}''' + + try: + text = _replace_function( + text, "~NeoEsp32RmtMethodBase()", destructor + ) + text = _replace_function( + text, "bool IsReadyToUpdate() const", ready + ) + text = _replace_function( + text, "void Initialize()", initialize + ) + text = _replace_function( + text, "void Update(bool maintainBufferConsistency)", update + ) + except RuntimeError as exc: + raise RuntimeError( + f"CoreS3 RMT DMA1024 patch failed for {target}: {exc}" + ) from exc + + required = [ + RMT_MARKER, + "config.mem_block_symbols = 1024;", + "config.flags.with_dma = true;", + "return (_channel != nullptr && _led_encoder != nullptr);", + ] + for item in required: + if item not in text: + raise RuntimeError( + f"CoreS3 RMT DMA1024 verification failed: missing {item}" + ) + + target.write_text(text, encoding="utf-8", newline="\n") + print( + "[CoreS3 RMT DMA1024] applied ESP32-S3 DMA / " + f"1024-symbol patch: {target}" + ) + + +def _patch_lcd_gdma(target: Path) -> None: + '''Apply the validated LCD/GDMA full teardown and remove TEST-only markers.''' + text = target.read_text(encoding="utf-8") + original = text + + # Remove the compile-time TEST banner used during hardware diagnosis. + for pragma in LCD_TEST_PRAGMAS: + text = text.replace(pragma + "\n\n", "") + text = text.replace(pragma + "\r\n\r\n", "") + text = text.replace(pragma + "\n", "") + text = text.replace(pragma + "\r\n", "") + text = text.replace(pragma, "") + + if LCD_MARKER not in text: + destruct = f''' void Destruct() + {{ + if (_dmaItems == nullptr) + {{ + return; + }} + + // {LCD_MARKER} + // + // gdma_reset() alone resets the channel state but does not release + // the LCD peripheral ownership held by the GDMA driver. A runtime + // WLED bus rebuild can therefore fail on the next gdma_connect(). + // + // NeoEsp32LcdXMethodBase waits for the active LCD transfer to finish + // before DeregisterMuxBus() reaches this teardown. + if (_dmaChannel != nullptr) + {{ + esp_err_t dmaResult; + + dmaResult = gdma_stop(_dmaChannel); + if (dmaResult != ESP_OK) + {{ + log_w( + "LCD GDMA stop during teardown failed: %d", + (int)dmaResult + ); + }} + + dmaResult = gdma_reset(_dmaChannel); + if (dmaResult != ESP_OK) + {{ + log_w( + "LCD GDMA reset during teardown failed: %d", + (int)dmaResult + ); + }} + + dmaResult = gdma_disconnect(_dmaChannel); + if (dmaResult != ESP_OK) + {{ + log_w( + "LCD GDMA disconnect during teardown failed: %d", + (int)dmaResult + ); + }} + + dmaResult = gdma_del_channel(_dmaChannel); + if (dmaResult != ESP_OK) + {{ + log_w( + "LCD GDMA delete during teardown failed: %d", + (int)dmaResult + ); + }} + + _dmaChannel = nullptr; + }} + + periph_module_disable(PERIPH_LCD_CAM_MODULE); + periph_module_reset(PERIPH_LCD_CAM_MODULE); + + heap_caps_free(LcdBuffer); + heap_caps_free(_dmaItems); + + LcdBufferSize = 0; + _dmaItems = nullptr; + LcdBuffer = nullptr; + + MuxMap.Reset(); + }}''' + + try: + text = _replace_function(text, "void Destruct()", destruct) + except RuntimeError as exc: + raise RuntimeError( + f"CoreS3 LCD GDMA teardown patch failed for {target}: {exc}" + ) from exc + + required = [ + LCD_MARKER, + "gdma_stop(_dmaChannel);", + "gdma_reset(_dmaChannel);", + "gdma_disconnect(_dmaChannel);", + "gdma_del_channel(_dmaChannel);", + "_dmaChannel = nullptr;", + ] + for item in required: + if item not in text: + raise RuntimeError( + f"CoreS3 LCD GDMA verification failed: missing {item}" + ) + + for pragma in LCD_TEST_PRAGMAS: + if pragma in text: + raise RuntimeError( + "CoreS3 LCD GDMA production cleanup failed: " + f"TEST pragma still present: {pragma}" + ) + + if text == original: + print(f"[CoreS3 LCD GDMA] patch already present: {target.name}") + return + + target.write_text(text, encoding="utf-8", newline="\n") + print( + "[CoreS3 LCD GDMA] applied full GDMA teardown production patch: " + f"{target}" + ) + + +def _apply_patches() -> None: + rmt_target = _find_rmt_target() + lcd_target = _find_lcd_target(rmt_target) + + _patch_rmt(rmt_target) + _patch_lcd_gdma(lcd_target) + + +if not env.IsIntegrationDump(): + _apply_patches() diff --git a/usermods/CoreS3_Audio/CoreS3_Audio.cpp b/usermods/CoreS3_Audio/CoreS3_Audio.cpp new file mode 100644 index 0000000000..2a9817b82b --- /dev/null +++ b/usermods/CoreS3_Audio/CoreS3_Audio.cpp @@ -0,0 +1,599 @@ +#include "wled.h" +#include +#include + +// =========================================================== +// M5Stack CoreS3 Audio Usermod +// +// CoreS3 Audio Production Baseline +// +// Responsibilities +// - Access the CoreS3 internal I2C bus through the M5GFX +// I2C_NUM_1 owner shared with Display / Touch. +// - Detect the AXP2101 PMU and ES7210 audio codec. +// - Configure and verify the built-in ES7210 dual microphones. +// - Reserve the fixed internal audio pins used by WLED. +// - Publish codec readiness to the AudioReactive usermod. +// - Report hardware and integration status to WLED Info. +// +// Ownership +// CoreS3_Audio +// - GPIO0 / GPIO14 reservation +// - ES7210 configuration +// - shared internal I2C access +// - codec-ready signal +// +// AudioReactive +// - I2S_NUM_1 +// - PCM sampling +// - AGC / FFT +// - audio-reactive effects +// +// This usermod intentionally does not install or read I2S, run FFT, +// change LED state, modify Display / Touch behavior, change power rails, +// or reinitialize the CoreS3 internal I2C bus. +// +// CoreS3 internal audio hardware +// ES7210 I2C : 0x40 +// I2C SDA : GPIO12 +// I2C SCL : GPIO11 +// I2S MCLK : GPIO0 +// I2S BCLK : GPIO34 +// I2S WS : GPIO33 +// I2S DATA : GPIO14 +// I2S Port : I2S_NUM_1 +// Channels : Stereo (MIC1 / MIC2) +// +// M5GFX owns the CoreS3 internal I2C bus after Display initialization. +// Audio therefore uses lgfx::i2c transactions on I2C_NUM_1 and never +// calls Wire.begin(), i2c_driver_install(), lgfx::i2c::init(), or +// release() on that shared bus. +// +// The ES7210 register configuration and pin mapping follow the +// M5Stack/M5Unified CoreS3 microphone implementation. +// =========================================================== + +static volatile bool coreS3AudioCodecReadyState = false; +static volatile bool coreS3AudioInitializationFinishedState = false; + +extern "C" bool coreS3AudioCodecReady() +{ + return coreS3AudioCodecReadyState; +} + +extern "C" bool coreS3AudioInitializationFinished() +{ + return coreS3AudioInitializationFinishedState; +} + +#if defined(WLED_M5STACK_CORES3_AUDIO) +extern "C" bool coreS3AudioReactiveSourceReady(); +#endif + +class CoreS3AudioUsermod : public Usermod +{ +private: + // --------------------------------------------------------- + // CoreS3 hardware constants + // --------------------------------------------------------- + + static constexpr uint8_t ES7210_ADDR = 0x40; + static constexpr uint8_t AXP2101_ADDR = 0x34; + static constexpr int CORES3_I2C_SDA = 12; + static constexpr int CORES3_I2C_SCL = 11; + static constexpr i2c_port_t CORES3_INTERNAL_I2C_PORT = I2C_NUM_1; + static constexpr uint32_t CORES3_INTERNAL_I2C_FREQUENCY = 400000; + + static constexpr int AUDIO_MCLK_PIN = 0; + static constexpr int AUDIO_BCLK_PIN = 34; + static constexpr int AUDIO_WS_PIN = 33; + static constexpr int AUDIO_DATA_IN_PIN = 14; + static constexpr uint32_t AUDIO_SAMPLE_RATE = 16000; + static constexpr unsigned long AUDIO_INIT_DELAY_MS = 2500; + static constexpr unsigned long AUDIO_INIT_RETRY_MS = 1000; + static constexpr uint8_t AUDIO_INIT_MAX_ATTEMPTS = 5; + + // --------------------------------------------------------- + // Runtime state + // --------------------------------------------------------- + + bool coreS3PinsValid = false; + bool audioPinsReserved = false; + bool es7210Found = false; + bool es7210Configured = false; + bool initializationFinished = false; + uint8_t initAttemptCount = 0; + + unsigned long setupStartMs = 0; + unsigned long lastInitAttemptMs = 0; + + bool axp2101Found = false; + bool axp2101RegistersRead = false; + uint8_t axp2101Reg90 = 0; + uint8_t axp2101Reg93 = 0; + uint8_t es7210ProbeReg00 = 0; + + void finishInitialization() + { + initializationFinished = true; + coreS3AudioInitializationFinishedState = true; + } + + // --------------------------------------------------------- + // CoreS3 internal audio pin reservation + // + // GPIO0 = ES7210 MCLK (output) + // GPIO14 = ES7210 DIN (input) + // + // GPIO33/34 are already unavailable to WLED on this CoreS3 build + // and appear as "System" in Pin Info, so only GPIO0/14 need an + // explicit PinManager reservation. + // + // PinOwner::UM_Audioreactive is used intentionally so WLED Pin Info + // reports these as Usermod-owned and all normal pin selectors treat + // them as unavailable. + // --------------------------------------------------------- + + void neutralizePersistedGpio0Button() + { + if (PinManager::getPinOwner(AUDIO_MCLK_PIN) == PinOwner::Button) { + PinManager::deallocatePin(AUDIO_MCLK_PIN, PinOwner::Button); + } + + for (auto& button : buttons) { + if (button.pin == AUDIO_MCLK_PIN) { + button.pin = -1; + button.type = BTN_TYPE_NONE; + button.pressedBefore = false; + button.longPressed = false; + button.pressedTime = 0; + button.waitTime = 0; + } + } + } + + bool reserveInternalAudioPins() + { + neutralizePersistedGpio0Button(); + + if (PinManager::isPinAllocated(AUDIO_MCLK_PIN, PinOwner::UM_Audioreactive) && + PinManager::isPinAllocated(AUDIO_DATA_IN_PIN, PinOwner::UM_Audioreactive)) { + return true; + } + + if (PinManager::isPinAllocated(AUDIO_MCLK_PIN) || + PinManager::isPinAllocated(AUDIO_DATA_IN_PIN)) { + Serial.printf( + "[CoreS3_Audio] ERROR: internal audio pin conflict MCLK0=%s DIN14=%s\n", + PinManager::getPinOwnerName(AUDIO_MCLK_PIN), + PinManager::getPinOwnerName(AUDIO_DATA_IN_PIN) + ); + return false; + } + + const managed_pin_type audioPins[] = { + { AUDIO_MCLK_PIN, true }, // ES7210 master clock output + { AUDIO_DATA_IN_PIN, false } // ES7210 PCM data input + }; + + if (!PinManager::allocateMultiplePins( + audioPins, + sizeof(audioPins) / sizeof(audioPins[0]), + PinOwner::UM_Audioreactive + )) { + Serial.println(F("[CoreS3_Audio] ERROR: failed to reserve GPIO0/GPIO14 for internal audio")); + return false; + } + + return true; + } + + // --------------------------------------------------------- + // Shared CoreS3 internal I2C helpers + // + // IMPORTANT: + // M5GFX owns the CoreS3 internal bus as I2C_NUM_1 on GPIO12/11. + // Audio must use that same owner after Display initialization. + // Do not call Wire.begin(), i2c_driver_install(), lgfx::i2c::init(), + // release(), or otherwise reinitialize the bus here. + // --------------------------------------------------------- + + bool readRegister(uint8_t address, uint8_t reg, uint8_t& value) + { + auto result = lgfx::i2c::transactionWriteRead( + CORES3_INTERNAL_I2C_PORT, + address, + ®, + 1, + &value, + 1, + CORES3_INTERNAL_I2C_FREQUENCY + ); + + return result.has_value(); + } + + bool writeRegister(uint8_t address, uint8_t reg, uint8_t value) + { + const uint8_t data[2] = { reg, value }; + + auto result = lgfx::i2c::transactionWrite( + CORES3_INTERNAL_I2C_PORT, + address, + data, + sizeof(data), + CORES3_INTERNAL_I2C_FREQUENCY + ); + + return result.has_value(); + } + + // --------------------------------------------------------- + // ES7210 microphone configuration + // + // MIC1 / MIC2 are enabled for the two built-in microphones. + // MIC3 / MIC4 remain powered down. + // --------------------------------------------------------- + + bool configureES7210() + { + struct RegisterValue { + uint8_t reg; + uint8_t value; + }; + + // Reset before applying the CoreS3 microphone profile. + if (!writeRegister(ES7210_ADDR, 0x00, 0xFF)) { + return false; + } + + delay(1); + + static constexpr RegisterValue registers[] = { + { 0x00, 0x41 }, + { 0x01, 0x1F }, + { 0x06, 0x00 }, + { 0x07, 0x20 }, + { 0x08, 0x10 }, + { 0x09, 0x30 }, + { 0x0A, 0x30 }, + { 0x20, 0x0A }, + { 0x21, 0x2A }, + { 0x22, 0x0A }, + { 0x23, 0x2A }, + { 0x02, 0xC1 }, + { 0x04, 0x01 }, + { 0x05, 0x00 }, + { 0x11, 0x60 }, + { 0x40, 0x42 }, + { 0x41, 0x70 }, + { 0x42, 0x70 }, + { 0x43, 0x1B }, + { 0x44, 0x1B }, + { 0x45, 0x00 }, + { 0x46, 0x00 }, + { 0x47, 0x00 }, + { 0x48, 0x00 }, + { 0x49, 0x00 }, + { 0x4A, 0x00 }, + { 0x4B, 0x00 }, + { 0x4C, 0xFF }, + { 0x01, 0x14 } + }; + + for (const auto& item : registers) { + if (!writeRegister(ES7210_ADDR, item.reg, item.value)) { + return false; + } + } + + // Verify a few stable key values instead of assuming the writes succeeded. + uint8_t clockControl = 0; + uint8_t mic1Gain = 0; + uint8_t mic2Gain = 0; + uint8_t mic12Power = 0; + uint8_t mic34Power = 0; + + if (!readRegister(ES7210_ADDR, 0x01, clockControl) || + !readRegister(ES7210_ADDR, 0x43, mic1Gain) || + !readRegister(ES7210_ADDR, 0x44, mic2Gain) || + !readRegister(ES7210_ADDR, 0x4B, mic12Power) || + !readRegister(ES7210_ADDR, 0x4C, mic34Power)) { + return false; + } + + return clockControl == 0x14 && + mic1Gain == 0x1B && + mic2Gain == 0x1B && + mic12Power == 0x00 && + mic34Power == 0xFF; + } + + // --------------------------------------------------------- + // Deferred initialization + // + // Audio initialization is delayed until all WLED usermods have + // completed setup. This avoids depending on usermod registration + // order while the existing CoreS3 Display / Power startup remains + // unchanged. + // --------------------------------------------------------- + + void attemptInitialization() + { + if (!audioPinsReserved) { + Serial.println(F("[CoreS3_Audio] ERROR: audio pin reservation unavailable; codec initialization blocked")); + finishInitialization(); + return; + } + + initAttemptCount++; + + Serial.printf( + "[CoreS3_Audio] Initialization attempt %u/%u\n", + initAttemptCount, + AUDIO_INIT_MAX_ATTEMPTS + ); + + if (i2c_sda != CORES3_I2C_SDA || i2c_scl != CORES3_I2C_SCL) { + Serial.printf( + "[CoreS3_Audio] ERROR: invalid CoreS3 I2C pins SDA=%d SCL=%d\n", + i2c_sda, + i2c_scl + ); + + coreS3PinsValid = false; + finishInitialization(); + return; + } + + coreS3PinsValid = true; + + // Read-only PMU diagnostics. Power-rail ownership remains + // separate from this usermod. Reading AXP2101 register 0x90 also + // verifies that the shared M5GFX I2C_NUM_1 bus is reachable. + axp2101RegistersRead = false; + axp2101Found = readRegister(AXP2101_ADDR, 0x90, axp2101Reg90); + + if (axp2101Found) { + axp2101RegistersRead = + readRegister(AXP2101_ADDR, 0x93, axp2101Reg93); + + if (!axp2101RegistersRead) { + Serial.println(F("[CoreS3_Audio] WARNING: AXP2101 REG93 unavailable on M5GFX I2C1")); + } + } + else { + Serial.println(F("[CoreS3_Audio] WARNING: AXP2101 unavailable on M5GFX I2C1")); + } + + // ES7210 register 0x00 is RESET_CTL and is safe to read. + // This probe does not modify the codec or its power rail. + es7210Found = readRegister(ES7210_ADDR, 0x00, es7210ProbeReg00); + + Serial.printf( + "[CoreS3_Audio] ES7210: %s\n", + es7210Found ? "FOUND" : "NOT FOUND" + ); + + if (!es7210Found) { + if (initAttemptCount >= AUDIO_INIT_MAX_ATTEMPTS) { + Serial.println( + F("[CoreS3_Audio] ES7210 unavailable after retries; microphone initialization stopped") + ); + finishInitialization(); + } + + return; + } + + es7210Configured = configureES7210(); + + Serial.printf( + "[CoreS3_Audio] ES7210 configuration: %s\n", + es7210Configured ? "VERIFIED" : "FAILED" + ); + + if (!es7210Configured) { + finishInitialization(); + return; + } + + coreS3AudioCodecReadyState = true; + finishInitialization(); + + Serial.println(F("[CoreS3_Audio] READY - waiting for AudioReactive I2S1")); + } + + const char* getAudioStatusName() const + { + if (!initializationFinished) { + return "INITIALIZING"; + } + + if (!audioPinsReserved) { + return "AUDIO PIN RESERVATION ERROR"; + } + + if (!coreS3PinsValid) { + return "I2C PIN ERROR"; + } + + if (!es7210Found) { + return "ES7210 NOT FOUND"; + } + + if (!es7210Configured) { + return "ES7210 CONFIG FAILED"; + } + +#if defined(WLED_M5STACK_CORES3_AUDIO) + if (coreS3AudioReactiveSourceReady()) { + return "READY - AudioReactive"; + } + + return "CODEC READY - waiting AudioReactive"; +#else + return "AUDIOREACTIVE BUILD FLAG MISSING"; +#endif + } + +public: + // --------------------------------------------------------- + // WLED Usermod setup + // --------------------------------------------------------- + + void setup() override + { + coreS3AudioCodecReadyState = false; + coreS3AudioInitializationFinishedState = false; + initializationFinished = false; + + Serial.println(); + Serial.println(F("[CoreS3_Audio][BUILD] CoreS3 Audio v0.1.5")); + Serial.println(F("[CoreS3_Audio] Initialization start")); + + audioPinsReserved = reserveInternalAudioPins(); + + Serial.printf( + "[CoreS3_Audio] Internal audio pins: %s (MCLK=GPIO0 DIN=GPIO14)\n", + audioPinsReserved ? "READY" : "FAILED" + ); + + Serial.println(F("[CoreS3_Audio] Codec initialization deferred")); + + setupStartMs = millis(); + lastInitAttemptMs = setupStartMs; + } + + // --------------------------------------------------------- + // WLED Usermod loop + // --------------------------------------------------------- + + void loop() override + { + const unsigned long now = millis(); + + if (!initializationFinished) { + if (now - setupStartMs < AUDIO_INIT_DELAY_MS) { + return; + } + + if (initAttemptCount == 0 || + now - lastInitAttemptMs >= AUDIO_INIT_RETRY_MS) { + lastInitAttemptMs = now; + attemptInitialization(); + } + + return; + } + + // No periodic PCM work here. + // Audio Reactive owns I2S1 sampling and FFT processing. + } + + // --------------------------------------------------------- + // WLED Info status + // --------------------------------------------------------- + + void addToJsonInfo(JsonObject& root) override + { + JsonObject user = root["u"]; + + if (user.isNull()) { + user = root.createNestedObject("u"); + } + + JsonArray statusInfo = user.createNestedArray("CoreS3 Audio"); + statusInfo.add(getAudioStatusName()); + + JsonArray i2cInfo = user.createNestedArray("CoreS3 Audio I2C"); + i2cInfo.add("M5GFX I2C1 GPIO12/GPIO11 400kHz"); + + JsonArray codecInfo = user.createNestedArray("CoreS3 ES7210"); + + if (!initializationFinished && !es7210Found) { + codecInfo.add("Waiting for probe"); + } + else if (!es7210Found) { + codecInfo.add("Not found on M5GFX I2C1 (0x40)"); + } + else if (!es7210Configured) { + codecInfo.add("Found - config failed"); + } + else { + codecInfo.add("Found / configured / verified on I2C1"); + } + + JsonArray integrationInfo = user.createNestedArray("CoreS3 Audio Integration"); + +#if defined(WLED_M5STACK_CORES3_AUDIO) + integrationInfo.add( + coreS3AudioReactiveSourceReady() + ? "AudioReactive source READY" + : (coreS3AudioCodecReadyState + ? "Codec ready / waiting I2S1 source" + : "Waiting for ES7210 codec") + ); +#else + integrationInfo.add("AudioReactive CoreS3 build flag missing"); +#endif + + JsonArray i2sInfo = user.createNestedArray("CoreS3 Audio I2S"); + i2sInfo.add("AudioReactive owner: I2S1 Stereo 16bit 16000Hz"); + + JsonArray pmuInfo = user.createNestedArray("CoreS3 Audio PMU"); + + if (!initializationFinished && !axp2101Found) { + pmuInfo.add("Waiting for probe"); + } + else if (!axp2101Found) { + pmuInfo.add("AXP2101 not found on M5GFX I2C1"); + } + else if (!axp2101RegistersRead) { + pmuInfo.add("AXP2101 found / registers unavailable"); + } + else { + char pmuText[48]; + + snprintf( + pmuText, + sizeof(pmuText), + "REG90=0x%02X REG93=0x%02X", + axp2101Reg90, + axp2101Reg93 + ); + + pmuInfo.add(pmuText); + } + + JsonArray pinReservationInfo = + user.createNestedArray("CoreS3 Audio Pin Reservation"); + + if (audioPinsReserved) { + pinReservationInfo.add("GPIO0 MCLK / GPIO14 DIN RESERVED (Usermod)"); + } + else { + char reservationText[80]; + + snprintf( + reservationText, + sizeof(reservationText), + "FAILED: GPIO0=%s GPIO14=%s", + PinManager::getPinOwnerName(AUDIO_MCLK_PIN), + PinManager::getPinOwnerName(AUDIO_DATA_IN_PIN) + ); + + pinReservationInfo.add(reservationText); + } + + JsonArray pinInfo = user.createNestedArray("CoreS3 Audio Pins"); + pinInfo.add("FIXED: MCLK0 BCLK34 WS33 DIN14"); + } +}; + +// ----------------------------------------------------------- +// Register CoreS3 Audio Usermod with WLED +// ----------------------------------------------------------- + +static CoreS3AudioUsermod coreS3AudioUsermod; +REGISTER_USERMOD(coreS3AudioUsermod); diff --git a/usermods/CoreS3_Audio/library.json b/usermods/CoreS3_Audio/library.json new file mode 100644 index 0000000000..14aa812370 --- /dev/null +++ b/usermods/CoreS3_Audio/library.json @@ -0,0 +1,11 @@ +{ + "name": "CoreS3_Audio", + "version": "0.1.5", + "description": "M5Stack CoreS3 built-in microphone Audio Reactive integration usermod for WLED", + "build": { + "libArchive": false + }, + "dependencies": { + "M5GFX": "https://github.com/m5stack/M5GFX.git#0.2.26" + } +} diff --git a/usermods/CoreS3_Display/CoreS3_Display.cpp b/usermods/CoreS3_Display/CoreS3_Display.cpp new file mode 100644 index 0000000000..5ef2a60b29 --- /dev/null +++ b/usermods/CoreS3_Display/CoreS3_Display.cpp @@ -0,0 +1,7865 @@ +#include "wled.h" +#include +#include +#include +#include + +#include "M5StackDisplayHardwareBackend.h" +#include "M5StackDisplayUI.h" +#include "M5StackDisplayTouchState.h" +#include "M5StackDisplayTouchHelpers.h" +#include "M5StackDisplayTouchContext.h" + +#include "CoreS3_WLED_Logo.h" + +// =========================================================== +// M5Stack Display Controller Usermod +// +// Current verified runtime +// - M5Stack CoreS3 +// - 320 x 240 display +// - Touch input +// - LCD brightness control +// +// Prepared hardware profiles +// - M5Stack Core2 +// - M5Stack Core2 for AWS +// +// The Core2-family profiles remain diagnostic-only until their +// Display / Touch / Power paths are implemented and verified on +// real hardware. +// +// Responsibilities +// - WLED power / brightness control +// - Effect and palette navigation +// - Color hue / saturation control +// - Preset navigation and management +// - Boot-preset selection +// - Startup animation +// - Display sleep / wake +// - Runtime synchronization with WLED state +// - Browser screenshot endpoint (/cores3/screenshot.bmp) +// +// Architecture +// UI and WLED-state logic are kept separate from the thin +// Display / Touch / brightness hardware-access boundary so the +// same controller behavior can later be reused by Core2-family +// hardware profiles. +// +// Compatibility +// The existing "CoreS3_Display" configuration key and usermod +// class identity are intentionally retained so existing CoreS3 +// settings continue to load without migration. +// =========================================================== + +static const char CORES3_DISPLAY_CONFIG_NAME[] PROGMEM = "CoreS3_Display"; + +// CoreS3_Power publishes read-only runtime health state. +// Display consumes these signals only for user-facing warning UX; it does +// not own or modify the power-control implementation. +extern "C" bool coreS3PowerInitializationComplete(); +extern "C" bool coreS3PowerExternal5VReady(); +extern "C" bool coreS3PowerSafeShutdownMonitorReady(); + +#if defined(WLED_M5STACK_CORES3_AUDIO) +// CoreS3_Audio publishes terminal initialization and codec-ready state. +// Display consumes these signals only to annotate Audio Reactive effects when +// the built-in microphone is definitively unavailable. +extern "C" bool coreS3AudioInitializationFinished(); +extern "C" bool coreS3AudioCodecReady(); +#endif + +class CoreS3DisplayUsermod : public Usermod { + private: + + // ========================================================= + // Display + // ========================================================= + + M5GFX display; + M5StackDisplayHardwareBackend hardwareBackend; + + bool displayReady = false; + bool touchReady = false; + bool initDone = false; + + int16_t screenWidth = 0; + int16_t screenHeight = 0; + + unsigned long lastUpdate = 0; + + // ========================================================= + // Browser Screenshot + // ========================================================= + // + // A GET request to /cores3/screenshot.bmp returns a one-shot + // 24-bit BMP capture of the current 320 x 240 LCD contents. + // + // The frame and BMP buffers are allocated only while a request + // is active and are placed in PSRAM. The finished BMP buffer is + // retained by the asynchronous HTTP response until transmission + // completes, then released automatically. + // + // This is intentionally a still-image endpoint. It does not + // continuously stream frames and therefore does not add normal + // runtime Display/Wi-Fi load when unused. + // ========================================================= + + bool screenshotCaptureInProgress = false; + + static constexpr size_t SCREENSHOT_BMP_HEADER_SIZE = 54; + + // ========================================================= + // Network access state + // ========================================================= + // + // CoreS3 is a local controller first. STA connectivity must not be + // treated as a prerequisite for the touch UI because WLED can also be + // reached through its SoftAP, and local LED control must remain usable + // even when no network interface is currently available. + // ========================================================= + + enum NetworkAccessMode : uint8_t { + NETWORK_ACCESS_NONE = 0, + NETWORK_ACCESS_STA, + NETWORK_ACCESS_AP + }; + + NetworkAccessMode lastNetworkAccessMode = NETWORK_ACCESS_NONE; + String lastNetworkDisplayText = ""; + + // Session-only Recovery AP state. This never changes the persisted + // WLED AP behavior or the user's configured AP credentials. + bool recoveryApSessionActive = false; + String recoveryApSSID = ""; + unsigned long recoveryApLastStartAttempt = 0; + + bool readyScreenShown = false; + bool connectingScreenShown = false; + + // ========================================================= + // Battery status + // ========================================================= + // + // MAIN-only status display. Battery is intentionally informational: + // it has no touch target and does not alter WLED behavior. + // ========================================================= + + M5StackBatteryStatus batteryStatus; + bool batteryStatusInitialized = false; + + unsigned long lastBatteryStatusRead = 0; + + static constexpr unsigned long BATTERY_STATUS_UPDATE_MS = 10000; + + // ========================================================= + // Runtime Health / Error UX + // ========================================================= + // + // Normal operation stays visually unchanged. Only actionable failures are + // surfaced, once per boot, and only while MAIN is idle. Warnings are + // temporary so the controller remains usable even when a subsystem fails. + // ========================================================= + + enum RuntimeHealthWarning : uint8_t { + RUNTIME_HEALTH_WARNING_NONE = 0, + RUNTIME_HEALTH_WARNING_LED_POWER, + RUNTIME_HEALTH_WARNING_POWER_SAFETY, + RUNTIME_HEALTH_WARNING_TOUCH + }; + + RuntimeHealthWarning activeRuntimeHealthWarning = + RUNTIME_HEALTH_WARNING_NONE; + + uint8_t runtimeHealthWarningsShownMask = 0; + unsigned long runtimeHealthStartMs = 0; + unsigned long runtimeHealthWarningStartMs = 0; + + static constexpr uint8_t RUNTIME_HEALTH_SHOWN_LED_POWER = 0x01; + static constexpr uint8_t RUNTIME_HEALTH_SHOWN_POWER_SAFETY = 0x02; + static constexpr uint8_t RUNTIME_HEALTH_SHOWN_TOUCH = 0x04; + + // Give the deferred M5GFX I2C1 power-key monitor time to ARM before + // declaring Safe Shutdown unavailable. + static constexpr unsigned long POWER_SAFETY_WARNING_GRACE_MS = 5000; + static constexpr unsigned long RUNTIME_HEALTH_WARNING_HOLD_MS = 2200; + + // Audio health is contextual rather than a global warning: only an effect + // that actually depends on audio is annotated when codec initialization has + // definitively failed. During initialization the normal capability text is + // kept unchanged. + bool lastAudioUnavailable = false; + + // ========================================================= + // Cached WLED state + // ========================================================= + + int8_t lastLedState = -1; + + int lastBrightnessValue = -1; + int lastEffectMode = -1; + + int lastSpeedValue = -1; + int lastIntensityValue = -1; + + int lastPaletteValue = -1; + + int lastHueValue = -1; + int lastSaturationValue = -1; + + uint32_t lastPrimaryColor = 0; + bool lastPrimaryColorValid = false; + + // COLOR page multi-slot editor state. + // + // selectedColorSlot is runtime-only UI state: + // 0 = C1, 1 = C2, 2 = C3 + // + // WLED Effect metadata decides which slots are selectable. + uint8_t selectedColorSlot = 0; + + uint32_t lastSelectedColor = 0; + bool lastSelectedColorValid = false; + + uint32_t lastColorSlots[3] = { 0, 0, 0 }; + bool lastColorSlotsValid = false; + + // Runtime-only BLACK toggle history. + // + // A long-press on C1/C2/C3 toggles that WLED color slot between black + // (#000000) and its last observed non-black value. This history is never + // written to Flash/config and is refreshed by both CoreS3 and Web UI color + // changes. + uint32_t lastNonBlackColorSlots[3] = { 0, 0, 0 }; + bool lastNonBlackColorSlotValid[3] = { false, false, false }; + + // ========================================================= + // Preset state + // ========================================================= + + int lastPresetValue = -1; + int lastBootPresetValue = -1; + + // CoreS3 UI navigation position. + // + // This intentionally differs from WLED currentPreset: + // - currentPreset = WLED's currently active Preset + // - presetNavigationCursorId = last Preset selected/browsed by this UI + // + // When currentPreset becomes 0 (Custom State) after Color/Effect changes, + // the cursor remains on the last useful Preset position. + uint8_t presetNavigationCursorId = 0; + + // Last non/zero WLED currentPreset value observed by the cursor sync logic. + // This is runtime-only UI state and is not persisted to Flash. + uint8_t lastObservedCurrentPreset = 0; + + uint8_t pendingPresetId = 0; + String pendingPresetName = ""; + + unsigned long pendingPresetRequestMs = 0; + + unsigned long lastPresetsModifiedTime = 0; + + bool presetNoEntries = false; + + static constexpr unsigned long PRESET_APPLY_PENDING_MS = 1500; + + // ========================================================= + // Preset RAM cache + // + // WLED Preset names are limited to 32 characters. + // + // Cache is intentionally a fixed array: + // - no repeated heap allocation during navigation + // - predictable memory use + // - maximum WLED persistent Presets = 250 + // + // Approximate RAM: + // 250 x 34 bytes = about 8.5KB + // ========================================================= + + struct PresetCacheEntry { + uint8_t id; + char name[33]; + }; + + PresetCacheEntry presetCache[250]; + + uint16_t presetCacheCount = 0; + + bool presetCacheReady = false; + bool presetCacheBuilding = false; + + uint16_t presetCacheScanId = 1; + + unsigned long presetCacheLastScanMs = 0; + + unsigned long presetCacheSourceModifiedTime = 0; + unsigned long presetCacheBuildSourceModifiedTime = 0; + + static constexpr unsigned long PRESET_CACHE_SCAN_INTERVAL_MS = 5; + + // ========================================================= + // Persistent Display Settings + // ========================================================= + + uint16_t lcdBrightness = 128; + + // 0 = Never + uint16_t sleepTimeoutSec = 30; + + bool fadeEnabled = true; + + uint16_t fadeDurationMs = 250; + + // ========================================================= + // Current physical LCD brightness + // ========================================================= + + uint8_t currentDisplayBrightness = 0; + + // ========================================================= + // Display suspend + // ========================================================= + + static constexpr uint8_t DISPLAY_FADE_STEP = 4; + + enum DisplayPowerState : uint8_t { + DISPLAY_POWER_ACTIVE = 0, DISPLAY_POWER_SLEEP_FADE_OUT, DISPLAY_POWER_SLEEPING, DISPLAY_POWER_WAKE_FADE_IN, DISPLAY_POWER_WAKE_WAIT_RELEASE }; + + DisplayPowerState displayPowerState = DISPLAY_POWER_ACTIVE; + + unsigned long lastUserActivityMs = 0; + unsigned long displayFadeLastStep = 0; + + // ========================================================= + // Startup animation + // ========================================================= + + enum StartupState : uint8_t { + STARTUP_FADE_IN = 0, STARTUP_WAIT_WIFI, STARTUP_READY_HOLD, STARTUP_FADE_OUT, STARTUP_MAIN_FADE_IN, STARTUP_DONE }; + + StartupState startupState = STARTUP_FADE_IN; + + unsigned long startupStateStart = 0; + unsigned long startupLastFadeStep = 0; + unsigned long startupLastDotsUpdate = 0; + + uint8_t startupDotCount = 0; + + NetworkAccessMode startupNetworkAccessMode = NETWORK_ACCESS_NONE; + String startupIPAddress = ""; + + static constexpr unsigned long STARTUP_READY_HOLD_MS = 700; + static constexpr unsigned long STARTUP_DOTS_INTERVAL_MS = 350; + + // Display/UI fail-safe only. WLED itself continues its normal Wi-Fi + // connection/reconnection behavior after the local UI becomes available. + static constexpr unsigned long STARTUP_NETWORK_WAIT_TIMEOUT_MS = 10000; + + // ========================================================= + // Persistent logical HSV + // ========================================================= + + CHSV32 logicalColorHsv; + + bool logicalColorHsvValid = false; + + uint8_t logicalHueValue = 0; + uint8_t logicalSaturationValue = 0; + uint8_t logicalWhiteValue = 0; + + // ========================================================= + // Pages + // ========================================================= + + enum ScreenPage : uint8_t { + SCREEN_MAIN = 0, SCREEN_COLOR, SCREEN_EFFECT, SCREEN_PRESET }; + + ScreenPage currentPage = SCREEN_MAIN; + + // ========================================================= + // Preset sub pages + // ========================================================= + + enum PresetSubPage : uint8_t { + PRESET_SUBPAGE_NAV = 0, PRESET_SUBPAGE_MANAGE, PRESET_SUBPAGE_SAVE, PRESET_SUBPAGE_OVERWRITE, PRESET_SUBPAGE_DELETE, PRESET_SUBPAGE_BOOT }; + + PresetSubPage presetSubPage = PRESET_SUBPAGE_NAV; + + // ========================================================= + // New Preset save operation + // ========================================================= + + enum PresetSaveOperationState : uint8_t { + PRESET_SAVE_OP_IDLE = 0, PRESET_SAVE_OP_WAIT_WLED, PRESET_SAVE_OP_WAIT_CACHE, PRESET_SAVE_OP_SUCCESS, PRESET_SAVE_OP_FAILED }; + + PresetSaveOperationState presetSaveOperationState = PRESET_SAVE_OP_IDLE; + + uint8_t presetSaveCandidateId = 0; + String presetSaveCandidateName = ""; + + unsigned long presetSaveHoldStartTime = 0; + bool presetSaveHoldTriggered = false; + + unsigned long presetSaveResultStartMs = 0; + + static constexpr unsigned long PRESET_SAVE_HOLD_MS = 1000; + static constexpr unsigned long PRESET_SAVE_RESULT_HOLD_MS = 900; + + // ========================================================= + // Existing Preset overwrite selection + // ========================================================= + + uint8_t presetOverwriteTargetId = 0; + String presetOverwriteTargetName = ""; + + bool presetSaveOperationIsOverwrite = false; + + // ========================================================= + // Existing Preset delete selection / operation + // ========================================================= + + enum PresetDeleteOperationState : uint8_t { + PRESET_DELETE_OP_IDLE = 0, PRESET_DELETE_OP_WAIT_CACHE, PRESET_DELETE_OP_SUCCESS, PRESET_DELETE_OP_FAILED }; + + PresetDeleteOperationState presetDeleteOperationState = PRESET_DELETE_OP_IDLE; + + uint8_t presetDeleteTargetId = 0; + String presetDeleteTargetName = ""; + + bool presetDeleteWasCurrentPreset = false; + bool presetDeleteWasBootPreset = false; + + unsigned long presetDeleteResultStartMs = 0; + + // ========================================================= + // Boot Preset selection / operation + // ========================================================= + + enum PresetBootOperationState : uint8_t { + PRESET_BOOT_OP_IDLE = 0, PRESET_BOOT_OP_WAIT_CONFIG, PRESET_BOOT_OP_SUCCESS, PRESET_BOOT_OP_FAILED }; + + PresetBootOperationState presetBootOperationState = PRESET_BOOT_OP_IDLE; + + uint8_t presetBootTargetId = 0; + String presetBootTargetName = "NONE"; + + unsigned long presetBootResultStartMs = 0; + + // ========================================================= + // Touch state + // ========================================================= + + M5StackTouchRuntimeState touchState; + + // ========================================================= + // Hue gesture + // ========================================================= + + CHSV32 hueEditHsv; + + bool hueEditValid = false; + + uint8_t hueEditValue = 0; + uint8_t hueEditWhite = 0; + + // ========================================================= + // Saturation gesture + // ========================================================= + + CHSV32 saturationEditHsv; + + bool saturationEditValid = false; + + uint8_t saturationEditValue = 0; + uint8_t saturationEditWhite = 0; + + // ========================================================= + // Layout + // ========================================================= + + static constexpr int16_t POWER_BUTTON_X = 8; + static constexpr int16_t POWER_BUTTON_Y = 8; + static constexpr int16_t POWER_BUTTON_W = 44; + static constexpr int16_t POWER_BUTTON_H = 44; + + static constexpr int16_t HEADER_CONTENT_LEFT = 60; + static constexpr int16_t HEADER_CONTENT_RIGHT = 312; + + static constexpr int16_t HEADER_CENTER_X = ( HEADER_CONTENT_LEFT + HEADER_CONTENT_RIGHT ) / 2; + + static constexpr int16_t HEADER_TITLE_Y = 18; + static constexpr int16_t HEADER_IP_Y = 41; + + // MAIN status row is split into: + // left = network / Recovery AP status + // right = display-only battery icon + percentage + static constexpr int16_t HEADER_NETWORK_LEFT = 60; + static constexpr int16_t HEADER_NETWORK_RIGHT = 238; + static constexpr int16_t HEADER_NETWORK_CENTER_X = + ( HEADER_NETWORK_LEFT + HEADER_NETWORK_RIGHT ) / 2; + + static constexpr int16_t BATTERY_STATUS_LEFT = 244; + static constexpr int16_t BATTERY_STATUS_RIGHT = 312; + + static constexpr int16_t BATTERY_ICON_X = 246; + static constexpr int16_t BATTERY_ICON_Y = 36; + static constexpr int16_t BATTERY_ICON_W = 18; + static constexpr int16_t BATTERY_ICON_H = 10; + + static constexpr int16_t BATTERY_PERCENT_X = 289; + static constexpr int16_t BATTERY_PERCENT_Y = 41; + + static constexpr int16_t CONTROL_LEFT_X = 16; + static constexpr int16_t CONTROL_RIGHT_X = 240; + + static constexpr int16_t CONTROL_BUTTON_W = 64; + static constexpr int16_t CONTROL_BUTTON_H = 34; + + // ========================================================= + // MAIN layout + // ========================================================= + + static constexpr int16_t BRI_BUTTON_Y = 82; + static constexpr int16_t FX_BUTTON_Y = 138; + + static constexpr int16_t EFFECT_DETAIL_X = 88; + static constexpr int16_t EFFECT_DETAIL_Y = 138; + static constexpr int16_t EFFECT_DETAIL_W = 144; + static constexpr int16_t EFFECT_DETAIL_H = 34; + + // ========================================================= + // MAIN bottom visible buttons + // ========================================================= + + static constexpr int16_t MAIN_BOTTOM_BUTTON_Y = 188; + static constexpr int16_t MAIN_BOTTOM_BUTTON_H = 40; + + static constexpr int16_t COLOR_BUTTON_X = 16; + static constexpr int16_t COLOR_BUTTON_W = 140; + + static constexpr int16_t PRESET_OPEN_BUTTON_X = 164; + static constexpr int16_t PRESET_OPEN_BUTTON_W = 140; + + // ========================================================= + // Back button + // ========================================================= + + static constexpr int16_t BACK_BUTTON_X = 268; + static constexpr int16_t BACK_BUTTON_Y = 8; + static constexpr int16_t BACK_BUTTON_W = 44; + static constexpr int16_t BACK_BUTTON_H = 44; + + // ========================================================= + // COLOR layout + // ========================================================= + + static constexpr int16_t COLOR_SLOT_1_X = 32; + static constexpr int16_t COLOR_SLOT_2_X = 124; + static constexpr int16_t COLOR_SLOT_3_X = 216; + + static constexpr int16_t COLOR_SLOT_Y = 76; + static constexpr int16_t COLOR_SLOT_W = 72; + static constexpr int16_t COLOR_SLOT_H = 28; + + static constexpr int16_t COLOR_SLOT_LABEL_Y = 68; + static constexpr int16_t COLOR_SELECTED_INFO_Y = 121; + + static constexpr int16_t HUE_BUTTON_Y = 151; + + static constexpr int16_t SATURATION_LABEL_Y = 198; + static constexpr int16_t SATURATION_BUTTON_Y = 204; + + // ========================================================= + // EFFECT detail layout + // ========================================================= + + static constexpr int16_t SPEED_BUTTON_Y = 82; + + static constexpr int16_t INTENSITY_BUTTON_Y = 140; + + // ========================================================= + // Palette visible layout + // ========================================================= + + static constexpr int16_t PALETTE_LABEL_Y = 188; + static constexpr int16_t PALETTE_BUTTON_Y = 198; + + // ========================================================= + // PRESET screen layout + // ========================================================= + + static constexpr int16_t PRESET_NAME_Y = 92; + static constexpr int16_t PRESET_ID_Y = 128; + static constexpr int16_t PRESET_STATUS_Y = 154; + + static constexpr int16_t PRESET_NAV_LABEL_Y = 188; + static constexpr int16_t PRESET_NAV_BUTTON_Y = 198; + + // ========================================================= + // PRESET MANAGE button on navigation screen + // ========================================================= + + static constexpr int16_t PRESET_MANAGE_BUTTON_X = 88; + static constexpr int16_t PRESET_MANAGE_BUTTON_Y = 198; + static constexpr int16_t PRESET_MANAGE_BUTTON_W = 144; + static constexpr int16_t PRESET_MANAGE_BUTTON_H = 34; + + // ========================================================= + // PRESET MANAGE screen + // ========================================================= + + static constexpr int16_t PRESET_SAVE_NEW_BUTTON_X = 60; + static constexpr int16_t PRESET_SAVE_NEW_BUTTON_Y = 64; + static constexpr int16_t PRESET_SAVE_NEW_BUTTON_W = 200; + static constexpr int16_t PRESET_SAVE_NEW_BUTTON_H = 34; + + // ========================================================= + // PRESET MANAGE OVERWRITE button + // ========================================================= + + static constexpr int16_t PRESET_OVERWRITE_BUTTON_X = 60; + static constexpr int16_t PRESET_OVERWRITE_BUTTON_Y = 106; + static constexpr int16_t PRESET_OVERWRITE_BUTTON_W = 200; + static constexpr int16_t PRESET_OVERWRITE_BUTTON_H = 34; + + // ========================================================= + // PRESET MANAGE DELETE button + // ========================================================= + + static constexpr int16_t PRESET_DELETE_BUTTON_X = 60; + static constexpr int16_t PRESET_DELETE_BUTTON_Y = 148; + static constexpr int16_t PRESET_DELETE_BUTTON_W = 200; + static constexpr int16_t PRESET_DELETE_BUTTON_H = 34; + + // ========================================================= + // PRESET MANAGE BOOT PRESET button + // ========================================================= + + static constexpr int16_t PRESET_BOOT_BUTTON_X = 60; + static constexpr int16_t PRESET_BOOT_BUTTON_Y = 190; + static constexpr int16_t PRESET_BOOT_BUTTON_W = 200; + static constexpr int16_t PRESET_BOOT_BUTTON_H = 34; + + // ========================================================= + // PRESET SAVE confirmation screen + // ========================================================= + + static constexpr int16_t PRESET_SAVE_HOLD_BUTTON_X = 60; + static constexpr int16_t PRESET_SAVE_HOLD_BUTTON_Y = 180; + static constexpr int16_t PRESET_SAVE_HOLD_BUTTON_W = 200; + static constexpr int16_t PRESET_SAVE_HOLD_BUTTON_H = 44; + + // ========================================================= + // PRESET OVERWRITE selection / confirmation screen + // ========================================================= + + static constexpr int16_t PRESET_OVERWRITE_NAV_BUTTON_Y = 124; + + static constexpr int16_t PRESET_OVERWRITE_HOLD_BUTTON_X = 60; + static constexpr int16_t PRESET_OVERWRITE_HOLD_BUTTON_Y = 194; + static constexpr int16_t PRESET_OVERWRITE_HOLD_BUTTON_W = 200; + static constexpr int16_t PRESET_OVERWRITE_HOLD_BUTTON_H = 40; + + // ========================================================= + // PRESET DELETE selection / confirmation screen + // ========================================================= + + static constexpr int16_t PRESET_DELETE_NAV_BUTTON_Y = 124; + + static constexpr int16_t PRESET_DELETE_HOLD_BUTTON_X = 60; + static constexpr int16_t PRESET_DELETE_HOLD_BUTTON_Y = 194; + static constexpr int16_t PRESET_DELETE_HOLD_BUTTON_W = 200; + static constexpr int16_t PRESET_DELETE_HOLD_BUTTON_H = 40; + + // ========================================================= + // PRESET BOOT selection / confirmation screen + // ========================================================= + + static constexpr int16_t PRESET_BOOT_NAV_BUTTON_Y = 124; + + static constexpr int16_t PRESET_BOOT_HOLD_BUTTON_X = 60; + static constexpr int16_t PRESET_BOOT_HOLD_BUTTON_Y = 194; + static constexpr int16_t PRESET_BOOT_HOLD_BUTTON_W = 200; + static constexpr int16_t PRESET_BOOT_HOLD_BUTTON_H = 40; + + // ========================================================= + // Touch timing + // ========================================================= + + static constexpr unsigned long TOUCH_POLL_MS = 15; + + static constexpr unsigned long TOUCH_RELEASE_CONFIRM_MS = 70; + + static constexpr unsigned long TOUCH_ACTION_COOLDOWN_MS = 250; + + // Recovery AP is intentionally harder to trigger than ordinary UI + // actions because it temporarily exposes a Wi-Fi access point. + static constexpr unsigned long WIFI_RECOVERY_HOLD_MS = 1500; + static constexpr unsigned long WIFI_RECOVERY_REOPEN_MS = 1000; + + // ========================================================= + // COLOR slot behavior + // ========================================================= + + // Deliberately longer than the repeat-control threshold. BLACK is + // reversible, but should still require an intentional hold. + static constexpr unsigned long COLOR_SLOT_LONG_PRESS_MS = 600; + + // ========================================================= + // Brightness behavior + // ========================================================= + + static constexpr unsigned long BRI_LONG_PRESS_MS = 400; + static constexpr unsigned long BRI_REPEAT_MS = 80; + + static constexpr int BRI_SHORT_STEP = 1; + static constexpr int BRI_LONG_STEP = 5; + + // ========================================================= + // Effect behavior + // ========================================================= + + static constexpr unsigned long EFFECT_LONG_PRESS_MS = 400; + static constexpr unsigned long EFFECT_REPEAT_MS = 250; + + // ========================================================= + // Hue behavior + // ========================================================= + + static constexpr unsigned long HUE_LONG_PRESS_MS = 400; + static constexpr unsigned long HUE_REPEAT_MS = 80; + + static constexpr int HUE_SHORT_STEP = 1; + static constexpr int HUE_LONG_STEP = 5; + + // ========================================================= + // Saturation behavior + // ========================================================= + + static constexpr unsigned long SATURATION_LONG_PRESS_MS = 400; + static constexpr unsigned long SATURATION_REPEAT_MS = 80; + + static constexpr int SATURATION_SHORT_STEP = 1; + static constexpr int SATURATION_LONG_STEP = 5; + + // ========================================================= + // Speed behavior + // ========================================================= + + static constexpr unsigned long SPEED_LONG_PRESS_MS = 400; + static constexpr unsigned long SPEED_REPEAT_MS = 80; + + static constexpr int SPEED_SHORT_STEP = 1; + static constexpr int SPEED_LONG_STEP = 5; + + // ========================================================= + // Intensity behavior + // ========================================================= + + static constexpr unsigned long INTENSITY_LONG_PRESS_MS = 400; + static constexpr unsigned long INTENSITY_REPEAT_MS = 80; + + static constexpr int INTENSITY_SHORT_STEP = 1; + static constexpr int INTENSITY_LONG_STEP = 5; + + // ========================================================= + // Palette behavior + // ========================================================= + + static constexpr unsigned long PALETTE_LONG_PRESS_MS = 400; + static constexpr unsigned long PALETTE_REPEAT_MS = 250; + + // ========================================================= + // Preset behavior + // ========================================================= + + static constexpr unsigned long PRESET_LONG_PRESS_MS = 400; + static constexpr unsigned long PRESET_REPEAT_MS = 600; + + // ========================================================= + // Shared long press / repeat timing helpers + // ========================================================= + + + + + // ========================================================= + // Normal LCD brightness + // ========================================================= + + uint8_t getNormalDisplayBrightness() { + return (uint8_t)constrain( (int)lcdBrightness, 1, 255 ); + } + + // ========================================================= + // Sleep timeout + // ========================================================= + + unsigned long getSleepTimeoutMs() { + return (unsigned long)sleepTimeoutSec * 1000UL; + } + + // ========================================================= + // Fade interval calculation + // ========================================================= + + unsigned long getFadeIntervalMs() { + uint16_t normalBrightness = getNormalDisplayBrightness(); + + uint16_t steps = ( normalBrightness + DISPLAY_FADE_STEP - 1 ) / DISPLAY_FADE_STEP; + + if ( steps == 0 ) { + steps = 1; + } + + unsigned long interval = (unsigned long)fadeDurationMs / steps; + + if ( interval < 1 ) { + interval = 1; + } + + return interval; + } + + // ========================================================= + // Hardware backend facade + // + // Keep board-specific implementation behind one backend while + // preserving the existing usermod call sites. This minimizes the + // regression surface for the hardware-verified CoreS3 UI. + // ========================================================= + + const char* getHardwareProbeStateName() { + return hardwareBackend.probeStateName(); + } + + const char* getDetectedPmuName() { + return hardwareBackend.detectedPmuName(); + } + + const char* getDetectedImuName() { + return hardwareBackend.detectedImuName(); + } + + const char* getDetectedVariantName() { + return hardwareBackend.detectedVariantName(); + } + + bool isCore2FamilyProfile() { + return hardwareBackend.isCore2FamilyProfile(); + } + + bool isCore2DiagnosticOnlyMode() { + return hardwareBackend.isCore2DiagnosticOnlyMode(); + } + + const char* getHardwareRuntimeModeName() { + return hardwareBackend.runtimeModeName(); + } + + const char* getHardwarePortStatusName() { + return hardwareBackend.portStatusName(); + } + + const char* getDetectedRevisionName() { + return hardwareBackend.detectedRevisionName(); + } + + void runHardwareDiagnostics() { + hardwareBackend.runDiagnostics(); + } + + const char* getHardwareProfileName() { + return hardwareBackend.profileName(); + } + + const char* getHardwareRevisionName() { + return hardwareBackend.revisionName(); + } + + bool isHardwareDisplayRuntimeEnabled() { + return hardwareBackend.isDisplayRuntimeEnabled(); + } + + bool initializeDisplayHardware() { + return hardwareBackend.initializeDisplay( screenWidth, screenHeight, touchReady ); + } + + bool readDisplayTouch( int16_t& touchX, int16_t& touchY ) { + return hardwareBackend.readTouch( touchX, touchY ); + } + + void writeDisplayBrightness( uint8_t value ) { + hardwareBackend.writeBrightness( value ); + } + + // ========================================================= + // Display brightness + // ========================================================= + + void setDisplayBrightness( uint8_t value ) { + currentDisplayBrightness = value; + + writeDisplayBrightness( value ); + } + + // ========================================================= + // Generic Fade + // ========================================================= + + bool updateFade( uint8_t targetBrightness, unsigned long now, unsigned long& lastFadeStep ) { + if ( currentDisplayBrightness == targetBrightness ) { + return true; + } + + if (!fadeEnabled) { + setDisplayBrightness( targetBrightness ); + + return true; + } + + unsigned long fadeInterval = getFadeIntervalMs(); + + if ( now - lastFadeStep < fadeInterval ) { + return false; + } + + lastFadeStep = now; + + if ( currentDisplayBrightness < targetBrightness ) { + int nextValue = currentDisplayBrightness + DISPLAY_FADE_STEP; + + if ( nextValue > targetBrightness ) { + nextValue = targetBrightness; + } + + setDisplayBrightness( (uint8_t)nextValue ); + } + else { + int nextValue = currentDisplayBrightness - DISPLAY_FADE_STEP; + + if ( nextValue < targetBrightness ) { + nextValue = targetBrightness; + } + + setDisplayBrightness( (uint8_t)nextValue ); + } + + return ( currentDisplayBrightness == targetBrightness ); + } + + // ========================================================= + // Preset cache rebuild start + // ========================================================= + + void startPresetCacheRebuild() { + presetCacheCount = 0; + + presetCacheScanId = 1; + + presetCacheLastScanMs = 0; + + presetCacheReady = false; + + presetCacheBuilding = true; + + presetNoEntries = false; + + presetCacheBuildSourceModifiedTime = presetsModifiedTime; + + Serial.printf( "[CoreS3_Display] " "Preset cache rebuild start " "(modified=%lu)\n", presetCacheBuildSourceModifiedTime ); + } + + // ========================================================= + // Preset cache rebuild complete + // ========================================================= + + void finishPresetCacheRebuild() { + presetCacheBuilding = false; + + presetCacheReady = true; + + presetCacheSourceModifiedTime = presetCacheBuildSourceModifiedTime; + + presetNoEntries = ( presetCacheCount == 0 ); + + normalizePresetNavigationCursor(); + + syncPresetNavigationCursorFromCurrentPreset(); + + Serial.printf( "[CoreS3_Display] " "Preset cache ready: %u preset(s)\n", (unsigned)presetCacheCount ); + + if ( presetsModifiedTime != presetCacheSourceModifiedTime ) { + Serial.println( F( "[CoreS3_Display] " "Preset changed during cache build. Rebuilding." ) ); + + startPresetCacheRebuild(); + + return; + } + + if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_NAV && displayPowerState == DISPLAY_POWER_ACTIVE && touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE ) { + drawPresetDetails( getDisplayedPresetId(), pendingPresetId > 0 ); + + drawPresetNavigation( M5STACK_TOUCH_TARGET_NONE ); + + lastPresetValue = getDisplayedPresetId(); + } + else if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_MANAGE && displayPowerState == DISPLAY_POWER_ACTIVE && touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE ) { + drawPresetManageScreen(); + } + else if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_SAVE && presetSaveOperationState == PRESET_SAVE_OP_IDLE && displayPowerState == DISPLAY_POWER_ACTIVE && touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE ) { + preparePresetSaveCandidate(); + drawPresetSaveScreen(); + } + else if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_OVERWRITE && presetSaveOperationState == PRESET_SAVE_OP_IDLE && displayPowerState == DISPLAY_POWER_ACTIVE && touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE ) { + String refreshedName; + + if ( presetOverwriteTargetId == 0 || !getCachedPresetName( presetOverwriteTargetId, refreshedName ) ) { + preparePresetOverwriteTarget(); + } + else { + presetOverwriteTargetName = refreshedName; + } + + drawPresetOverwriteScreen(); + } + else if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_DELETE && presetDeleteOperationState == PRESET_DELETE_OP_IDLE && displayPowerState == DISPLAY_POWER_ACTIVE && touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE ) { + String refreshedName; + + if ( presetDeleteTargetId == 0 || !getCachedPresetName( presetDeleteTargetId, refreshedName ) ) { + preparePresetDeleteTarget(); + } + else { + presetDeleteTargetName = refreshedName; + } + + drawPresetDeleteScreen(); + } + else if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_BOOT && presetBootOperationState == PRESET_BOOT_OP_IDLE && displayPowerState == DISPLAY_POWER_ACTIVE && touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE ) { + if ( presetBootTargetId > 0 ) { + String refreshedName; + + if ( getCachedPresetName( presetBootTargetId, refreshedName ) ) { + presetBootTargetName = refreshedName; + } + else { + presetBootTargetId = 0; + presetBootTargetName = "NONE"; + } + } + + drawPresetBootScreen(); + } + } + + // ========================================================= + // Background Preset cache service + // ========================================================= + + void servicePresetCache() { + if ( presetCacheReady && !presetCacheBuilding && presetsModifiedTime != presetCacheSourceModifiedTime ) { + startPresetCacheRebuild(); + } + + if (!presetCacheBuilding) { + return; + } + + if ( pendingPresetId > 0 ) { + return; + } + + if ( presetNeedsSaving() ) { + return; + } + + unsigned long now = millis(); + + if ( now - presetCacheLastScanMs < PRESET_CACHE_SCAN_INTERVAL_MS ) { + return; + } + + presetCacheLastScanMs = now; + + if ( presetCacheScanId > 250 ) { + finishPresetCacheRebuild(); + + return; + } + + String presetName; + + uint8_t scanId = (uint8_t)presetCacheScanId; + + if ( getPresetName( scanId, presetName ) ) { + if ( presetCacheCount < 250 ) { + presetCache[ presetCacheCount ].id = scanId; + + strlcpy( presetCache[ presetCacheCount ].name, presetName.c_str(), sizeof( presetCache[ presetCacheCount ].name ) ); + + presetCacheCount++; + } + } + + presetCacheScanId++; + + if ( presetCacheScanId > 250 ) { + finishPresetCacheRebuild(); + } + } + + // ========================================================= + // Find Preset in RAM cache + // ========================================================= + + int findPresetCacheIndex( uint8_t presetId ) { + for ( uint16_t i = 0; i < presetCacheCount; i++ ) { + if ( presetCache[i].id == presetId ) { + return (int)i; + } + } + + return -1; + } + + bool getCachedPresetName( uint8_t presetId, String& name ) { + int index = findPresetCacheIndex( presetId ); + + if ( index < 0 ) { + return false; + } + + name = presetCache[ index ].name; + + return true; + } + + uint8_t findFirstFreePresetId() { + if ( !presetCacheReady || presetCacheBuilding ) { + return 0; + } + + uint16_t expectedId = 1; + + for ( uint16_t i = 0; i < presetCacheCount; i++ ) { + uint8_t cachedId = presetCache[i].id; + + if ( cachedId < expectedId ) { + continue; + } + + if ( cachedId == expectedId ) { + expectedId++; + + if ( expectedId > 250 ) { + return 0; + } + + continue; + } + + break; + } + + if ( expectedId >= 1 && expectedId <= 250 ) { + return (uint8_t)expectedId; + } + + return 0; + } + + bool preparePresetSaveCandidate() { + presetSaveCandidateId = findFirstFreePresetId(); + + presetSaveCandidateName = ""; + + if ( presetSaveCandidateId == 0 ) { + return false; + } + + char presetName[33]; + + snprintf( presetName, sizeof(presetName), "CoreS3 Preset %u", presetSaveCandidateId ); + + presetSaveCandidateName = presetName; + + return true; + } + bool preparePresetOverwriteTarget() { + presetOverwriteTargetId = 0; + + presetOverwriteTargetName = ""; + + if ( !presetCacheReady || presetCacheBuilding || presetCacheCount == 0 ) { + return false; + } + + uint8_t basePresetId = getPresetManagementBaseId(); + + int targetIndex = findPresetCacheIndex( basePresetId ); + + if ( targetIndex < 0 ) { + targetIndex = 0; + } + + presetOverwriteTargetId = presetCache[ targetIndex ].id; + + presetOverwriteTargetName = presetCache[ targetIndex ].name; + + return true; + } + + bool stepPresetOverwriteTarget( int direction ) { + if ( direction == 0 || !presetCacheReady || presetCacheBuilding || presetCacheCount == 0 ) { + return false; + } + + String targetName; + + uint8_t targetId = findAdjacentPreset( presetOverwriteTargetId, direction, &targetName ); + + if ( targetId == 0 ) { + return false; + } + + presetOverwriteTargetId = targetId; + + presetOverwriteTargetName = targetName; + + return true; + } + bool preparePresetDeleteTarget() { + presetDeleteTargetId = 0; + + presetDeleteTargetName = ""; + + if ( !presetCacheReady || presetCacheBuilding || presetCacheCount == 0 ) { + return false; + } + + uint8_t basePresetId = getPresetManagementBaseId(); + + int targetIndex = findPresetCacheIndex( basePresetId ); + + if ( targetIndex < 0 ) { + targetIndex = 0; + } + + presetDeleteTargetId = presetCache[ targetIndex ].id; + + presetDeleteTargetName = presetCache[ targetIndex ].name; + + return true; + } + + bool stepPresetDeleteTarget( int direction ) { + if ( direction == 0 || !presetCacheReady || presetCacheBuilding || presetCacheCount == 0 ) { + return false; + } + + String targetName; + + uint8_t targetId = findAdjacentPreset( presetDeleteTargetId, direction, &targetName ); + + if ( targetId == 0 ) { + return false; + } + + presetDeleteTargetId = targetId; + + presetDeleteTargetName = targetName; + + return true; + } + bool preparePresetBootTarget() { + presetBootTargetId = 0; + presetBootTargetName = "NONE"; + + if ( !presetCacheReady || presetCacheBuilding ) { + return false; + } + + if ( isPresetNavigationCursorValid() ) { + String cursorName; + + if ( getCachedPresetName( presetNavigationCursorId, cursorName ) ) { + presetBootTargetId = presetNavigationCursorId; + presetBootTargetName = cursorName; + + return true; + } + } + + if ( currentPreset > 0 ) { + String currentName; + + if ( getCachedPresetName( currentPreset, currentName ) ) { + presetBootTargetId = currentPreset; + presetBootTargetName = currentName; + + return true; + } + } + + if ( bootPreset > 0 ) { + String bootName; + + if ( getCachedPresetName( bootPreset, bootName ) ) { + presetBootTargetId = bootPreset; + presetBootTargetName = bootName; + } + } + + return true; + } + + bool stepPresetBootTarget( int direction ) { + if ( direction == 0 || !presetCacheReady || presetCacheBuilding || presetCacheCount == 0 ) { + return false; + } + + if ( presetBootTargetId == 0 ) { + uint16_t targetIndex = ( direction > 0 ) ? 0 : ( presetCacheCount - 1 ); + + presetBootTargetId = presetCache[targetIndex].id; + presetBootTargetName = presetCache[targetIndex].name; + + return true; + } + + int currentIndex = findPresetCacheIndex( presetBootTargetId ); + + if ( currentIndex < 0 ) { + presetBootTargetId = 0; + presetBootTargetName = "NONE"; + + return true; + } + + if ( direction > 0 ) { + if ( currentIndex >= (int)presetCacheCount - 1 ) { + presetBootTargetId = 0; + presetBootTargetName = "NONE"; + } + else { + presetBootTargetId = presetCache[currentIndex + 1].id; + presetBootTargetName = presetCache[currentIndex + 1].name; + } + } + else { + if ( currentIndex <= 0 ) { + presetBootTargetId = 0; + presetBootTargetName = "NONE"; + } + else { + presetBootTargetId = presetCache[currentIndex - 1].id; + presetBootTargetName = presetCache[currentIndex - 1].name; + } + } + + return true; + } + + bool isPresetSaveBusy() { + return ( presetSaveOperationState != PRESET_SAVE_OP_IDLE ); + } + + bool isPresetDeleteBusy() { + return ( presetDeleteOperationState != PRESET_DELETE_OP_IDLE ); + } + + bool isPresetBootBusy() { + return ( presetBootOperationState != PRESET_BOOT_OP_IDLE ); + } + + bool requestNewPresetSave() { + presetSaveOperationIsOverwrite = false; + + if ( presetSaveOperationState != PRESET_SAVE_OP_IDLE || !presetCacheReady || presetCacheBuilding || pendingPresetId > 0 || presetSaveCandidateId == 0 || findPresetCacheIndex( presetSaveCandidateId ) >= 0 || presetNeedsSaving() ) { + presetSaveOperationState = PRESET_SAVE_OP_FAILED; + + presetSaveResultStartMs = millis(); + + drawPresetSaveOperationStatus(); + + Serial.println( F( "[CoreS3_Display] " "Preset save request rejected" ) ); + + return false; + } + + savePreset( presetSaveCandidateId, presetSaveCandidateName.c_str() ); + + if ( !presetNeedsSaving() ) { + presetSaveOperationState = PRESET_SAVE_OP_FAILED; + + presetSaveResultStartMs = millis(); + + drawPresetSaveOperationStatus(); + + Serial.println( F( "[CoreS3_Display] " "Preset save could not be queued" ) ); + + return false; + } + + presetSaveOperationState = PRESET_SAVE_OP_WAIT_WLED; + + presetSaveResultStartMs = 0; + + lastUserActivityMs = millis(); + + drawPresetSaveOperationStatus(); + + Serial.printf( "[CoreS3_Display] " "Preset save request: %u (%s)\n", presetSaveCandidateId, presetSaveCandidateName.c_str() ); + + return true; + } + + bool requestPresetOverwrite() { + presetSaveOperationIsOverwrite = true; + + presetSaveCandidateId = presetOverwriteTargetId; + + presetSaveCandidateName = presetOverwriteTargetName; + + if ( presetSaveOperationState != PRESET_SAVE_OP_IDLE || !presetCacheReady || presetCacheBuilding || presetCacheCount == 0 || pendingPresetId > 0 || presetSaveCandidateId == 0 || findPresetCacheIndex( presetSaveCandidateId ) < 0 || presetSaveCandidateName.length() == 0 || presetNeedsSaving() ) { + presetSaveOperationState = PRESET_SAVE_OP_FAILED; + + presetSaveResultStartMs = millis(); + + drawPresetSaveOperationStatus(); + + Serial.println( F( "[CoreS3_Display] " "Preset overwrite request rejected" ) ); + + return false; + } + + savePreset( presetSaveCandidateId, presetSaveCandidateName.c_str() ); + + if ( !presetNeedsSaving() ) { + presetSaveOperationState = PRESET_SAVE_OP_FAILED; + + presetSaveResultStartMs = millis(); + + drawPresetSaveOperationStatus(); + + Serial.println( F( "[CoreS3_Display] " "Preset overwrite could not be queued" ) ); + + return false; + } + + presetSaveOperationState = PRESET_SAVE_OP_WAIT_WLED; + + presetSaveResultStartMs = 0; + + lastUserActivityMs = millis(); + + drawPresetSaveOperationStatus(); + + Serial.printf( "[CoreS3_Display] " "Preset overwrite request: %u (%s)\n", presetSaveCandidateId, presetSaveCandidateName.c_str() ); + + return true; + } + + bool requestPresetDelete() { + if ( presetDeleteOperationState != PRESET_DELETE_OP_IDLE || !presetCacheReady || presetCacheBuilding || presetCacheCount == 0 || pendingPresetId > 0 || presetDeleteTargetId == 0 || findPresetCacheIndex( presetDeleteTargetId ) < 0 || presetDeleteTargetName.length() == 0 || presetNeedsSaving() ) { + presetDeleteOperationState = PRESET_DELETE_OP_FAILED; + + presetDeleteResultStartMs = millis(); + + drawPresetDeleteOperationStatus(); + + Serial.println( F( "[CoreS3_Display] " "Preset delete request rejected" ) ); + + return false; + } + + presetDeleteWasCurrentPreset = ( currentPreset == presetDeleteTargetId ); + presetDeleteWasBootPreset = ( bootPreset == presetDeleteTargetId ); + + deletePreset( presetDeleteTargetId ); + + if ( !presetCacheBuilding ) { + startPresetCacheRebuild(); + } + + presetDeleteOperationState = PRESET_DELETE_OP_WAIT_CACHE; + + presetDeleteResultStartMs = 0; + + lastUserActivityMs = millis(); + + drawPresetDeleteOperationStatus(); + + Serial.printf( "[CoreS3_Display] " "Preset delete request: %u (%s)\n", presetDeleteTargetId, presetDeleteTargetName.c_str() ); + + return true; + } + + bool requestPresetBootSetting() { + bool validTarget = + presetBootTargetId == 0 || + findPresetCacheIndex( presetBootTargetId ) >= 0; + + if ( presetBootOperationState != PRESET_BOOT_OP_IDLE || + !presetCacheReady || + presetCacheBuilding || + pendingPresetId > 0 || + presetNeedsSaving() || + !validTarget || + presetBootTargetId == bootPreset ) { + presetBootOperationState = PRESET_BOOT_OP_FAILED; + presetBootResultStartMs = millis(); + + drawPresetBootOperationStatus(); + + Serial.println( F( "[CoreS3_Display] " "Boot Preset request rejected" ) ); + + return false; + } + + bootPreset = presetBootTargetId; + configNeedsWrite = true; + + presetBootOperationState = PRESET_BOOT_OP_WAIT_CONFIG; + presetBootResultStartMs = 0; + lastUserActivityMs = millis(); + + drawPresetBootOperationStatus(); + + if ( presetBootTargetId == 0 ) { + Serial.println( F( "[CoreS3_Display] " "Boot Preset clear request" ) ); + } + else { + Serial.printf( "[CoreS3_Display] " "Boot Preset request: %u (%s)\n", presetBootTargetId, presetBootTargetName.c_str() ); + } + + return true; + } + + void servicePresetSaveOperation() { + if ( presetSaveOperationState == PRESET_SAVE_OP_IDLE ) { + return; + } + + unsigned long now = millis(); + + if ( presetSaveOperationState == PRESET_SAVE_OP_WAIT_WLED ) { + if ( presetNeedsSaving() ) { + return; + } + + if ( !presetCacheBuilding ) { + startPresetCacheRebuild(); + } + + presetSaveOperationState = PRESET_SAVE_OP_WAIT_CACHE; + + if ( currentPage == SCREEN_PRESET && ( presetSubPage == PRESET_SUBPAGE_SAVE || presetSubPage == PRESET_SUBPAGE_OVERWRITE ) && displayPowerState == DISPLAY_POWER_ACTIVE ) { + drawPresetSaveOperationStatus(); + } + + return; + } + + if ( presetSaveOperationState == PRESET_SAVE_OP_WAIT_CACHE ) { + if ( !presetCacheReady || presetCacheBuilding ) { + return; + } + + bool presetVerified = ( findPresetCacheIndex( presetSaveCandidateId ) >= 0 ); + + if ( presetVerified && presetSaveOperationIsOverwrite ) { + String verifiedName; + + presetVerified = getCachedPresetName( presetSaveCandidateId, verifiedName ) && verifiedName == presetSaveCandidateName; + } + + if (presetVerified) { + presetSaveOperationState = PRESET_SAVE_OP_SUCCESS; + + presetNavigationCursorId = presetSaveCandidateId; + + // The save operation intentionally changes the CoreS3 navigation + // position without necessarily changing WLED's active Preset. + lastObservedCurrentPreset = currentPreset; + + Serial.printf( "[CoreS3_Display] " "%s verified: %u (%s)\n", presetSaveOperationIsOverwrite ? "Preset overwrite" : "Preset save", presetSaveCandidateId, presetSaveCandidateName.c_str() ); + } + else { + presetSaveOperationState = PRESET_SAVE_OP_FAILED; + + Serial.printf( "[CoreS3_Display] " "%s verification failed: %u\n", presetSaveOperationIsOverwrite ? "Preset overwrite" : "Preset save", presetSaveCandidateId ); + } + + presetSaveResultStartMs = now; + + if ( currentPage == SCREEN_PRESET && ( presetSubPage == PRESET_SUBPAGE_SAVE || presetSubPage == PRESET_SUBPAGE_OVERWRITE ) && displayPowerState == DISPLAY_POWER_ACTIVE ) { + drawPresetSaveOperationStatus(); + } + + return; + } + + if ( presetSaveOperationState == PRESET_SAVE_OP_SUCCESS || presetSaveOperationState == PRESET_SAVE_OP_FAILED ) { + if ( now - presetSaveResultStartMs < PRESET_SAVE_RESULT_HOLD_MS ) { + return; + } + + int16_t touchX = -1; + int16_t touchY = -1; + + if ( readDisplayTouch( touchX, touchY ) ) { + return; + } + + bool saveSucceeded = ( presetSaveOperationState == PRESET_SAVE_OP_SUCCESS ); + + bool completedOverwrite = presetSaveOperationIsOverwrite; + + presetSaveOperationState = PRESET_SAVE_OP_IDLE; + + presetSaveOperationIsOverwrite = false; + + presetSaveResultStartMs = 0; + + presetSaveHoldStartTime = 0; + + presetSaveHoldTriggered = false; + + resetTouchGesture(); + + if (completedOverwrite) { + presetOverwriteTargetId = 0; + + presetOverwriteTargetName = ""; + } + + if (saveSucceeded) { + presetSaveCandidateId = 0; + + presetSaveCandidateName = ""; + + drawPresetScreen(); + } + else { + presetSaveCandidateId = 0; + + presetSaveCandidateName = ""; + + drawPresetManageScreen(); + } + } + } + + void servicePresetDeleteOperation() { + if ( presetDeleteOperationState == PRESET_DELETE_OP_IDLE ) { + return; + } + + unsigned long now = millis(); + + if ( presetDeleteOperationState == PRESET_DELETE_OP_WAIT_CACHE ) { + if ( !presetCacheReady || presetCacheBuilding ) { + return; + } + + bool deleteVerified = ( findPresetCacheIndex( presetDeleteTargetId ) < 0 ); + + if (deleteVerified) { + if ( presetDeleteWasCurrentPreset && currentPreset == presetDeleteTargetId ) { + currentPreset = 0; + + lastPresetValue = -1; + } + + if ( presetDeleteWasBootPreset && bootPreset == presetDeleteTargetId ) { + bootPreset = 0; + configNeedsWrite = true; + lastBootPresetValue = -1; + + Serial.println( F( "[CoreS3_Display] " "Deleted Boot Preset cleared" ) ); + } + + presetDeleteOperationState = PRESET_DELETE_OP_SUCCESS; + + Serial.printf( "[CoreS3_Display] " "Preset delete verified: %u (%s)\n", presetDeleteTargetId, presetDeleteTargetName.c_str() ); + } + else { + presetDeleteOperationState = PRESET_DELETE_OP_FAILED; + + Serial.printf( "[CoreS3_Display] " "Preset delete verification failed: %u\n", presetDeleteTargetId ); + } + + presetDeleteResultStartMs = now; + + if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_DELETE && displayPowerState == DISPLAY_POWER_ACTIVE ) { + drawPresetDeleteOperationStatus(); + } + + return; + } + + if ( presetDeleteOperationState == PRESET_DELETE_OP_SUCCESS || presetDeleteOperationState == PRESET_DELETE_OP_FAILED ) { + if ( now - presetDeleteResultStartMs < PRESET_SAVE_RESULT_HOLD_MS ) { + return; + } + + int16_t touchX = -1; + int16_t touchY = -1; + + if ( readDisplayTouch( touchX, touchY ) ) { + return; + } + + bool deleteSucceeded = ( presetDeleteOperationState == PRESET_DELETE_OP_SUCCESS ); + + presetDeleteOperationState = PRESET_DELETE_OP_IDLE; + + presetDeleteResultStartMs = 0; + + presetDeleteWasCurrentPreset = false; + presetDeleteWasBootPreset = false; + + presetSaveHoldStartTime = 0; + + presetSaveHoldTriggered = false; + + resetTouchGesture(); + + presetDeleteTargetId = 0; + + presetDeleteTargetName = ""; + + if (deleteSucceeded) { + drawPresetScreen(); + } + else { + drawPresetManageScreen(); + } + } + } + + void servicePresetBootOperation() { + if ( presetBootOperationState == PRESET_BOOT_OP_IDLE ) { + return; + } + + unsigned long now = millis(); + + if ( presetBootOperationState == PRESET_BOOT_OP_WAIT_CONFIG ) { + if ( configNeedsWrite ) { + return; + } + + if ( bootPreset == presetBootTargetId ) { + presetBootOperationState = PRESET_BOOT_OP_SUCCESS; + + if ( presetBootTargetId == 0 ) { + Serial.println( F( "[CoreS3_Display] " "Boot Preset clear verified" ) ); + } + else { + Serial.printf( "[CoreS3_Display] " "Boot Preset verified: %u (%s)\n", presetBootTargetId, presetBootTargetName.c_str() ); + } + } + else { + presetBootOperationState = PRESET_BOOT_OP_FAILED; + + Serial.printf( "[CoreS3_Display] " "Boot Preset verification failed: target=%u actual=%u\n", presetBootTargetId, bootPreset ); + } + + presetBootResultStartMs = now; + lastBootPresetValue = bootPreset; + + if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_BOOT && displayPowerState == DISPLAY_POWER_ACTIVE ) { + drawPresetBootOperationStatus(); + } + + return; + } + + if ( presetBootOperationState == PRESET_BOOT_OP_SUCCESS || presetBootOperationState == PRESET_BOOT_OP_FAILED ) { + if ( now - presetBootResultStartMs < PRESET_SAVE_RESULT_HOLD_MS ) { + return; + } + + int16_t touchX = -1; + int16_t touchY = -1; + + if ( readDisplayTouch( touchX, touchY ) ) { + return; + } + + presetBootOperationState = PRESET_BOOT_OP_IDLE; + presetBootResultStartMs = 0; + presetSaveHoldStartTime = 0; + presetSaveHoldTriggered = false; + + resetTouchGesture(); + + presetBootTargetId = 0; + presetBootTargetName = "NONE"; + + drawPresetManageScreen(); + } + } + + // ========================================================= + // Network access helpers + // ========================================================= + + NetworkAccessMode getNetworkAccessMode() { + // Prefer the normal station connection when both STA and SoftAP are active. + if ( WiFi.status() == WL_CONNECTED ) { + return NETWORK_ACCESS_STA; + } + + // WLED owns SoftAP lifecycle and exposes apActive through wled.h. + // A running SoftAP is a valid network-ready state even though + // WiFi.status() is not WL_CONNECTED. + if ( apActive ) { + return NETWORK_ACCESS_AP; + } + + return NETWORK_ACCESS_NONE; + } + + String getNetworkIPAddress( NetworkAccessMode mode ) { + if ( mode == NETWORK_ACCESS_STA ) { + return WiFi.localIP().toString(); + } + + if ( mode == NETWORK_ACCESS_AP ) { + return WiFi.softAPIP().toString(); + } + + return ""; + } + + String getNetworkDisplayText( NetworkAccessMode mode ) { + if ( mode == NETWORK_ACCESS_STA ) { + return WiFi.localIP().toString(); + } + + if ( mode == NETWORK_ACCESS_AP ) { + if ( recoveryApSessionActive && recoveryApSSID.length() > 0 ) { + String displaySSID = recoveryApSSID; + + if ( displaySSID.length() > 18 ) { + displaySSID = displaySSID.substring( 0, 18 ); + } + + return displaySSID + " " + WiFi.softAPIP().toString(); + } + + return String( "AP: " ) + WiFi.softAPIP().toString(); + } + + return "Offline - Hold Recovery AP"; + } + + String getCurrentNetworkDisplayText() { + return getNetworkDisplayText( getNetworkAccessMode() ); + } + + bool startWiFiRecoveryAP() { + if ( getNetworkAccessMode() != NETWORK_ACCESS_NONE ) { + return false; + } + + recoveryApLastStartAttempt = millis(); + + // WLED::initAP(true) deliberately uses WLED's compiled recovery/default + // AP credentials and bypasses AP_BEHAVIOR_BUTTON_ONLY. Back up the live + // config variables first, then restore them immediately so this emergency + // session does not modify the user's stored AP configuration. + char savedApSSID[ sizeof(apSSID) ]; + char savedApPass[ sizeof(apPass) ]; + + strlcpy( savedApSSID, apSSID, sizeof(savedApSSID) ); + strlcpy( savedApPass, apPass, sizeof(savedApPass) ); + + WLED::instance().initAP( true ); + + String startedSSID = apSSID; + + strlcpy( apSSID, savedApSSID, sizeof(apSSID) ); + strlcpy( apPass, savedApPass, sizeof(apPass) ); + + if ( !apActive ) { + recoveryApSessionActive = false; + recoveryApSSID = ""; + + return false; + } + + recoveryApSessionActive = true; + recoveryApSSID = startedSSID; + + return true; + } + + void serviceWiFiRecoveryAP() { + if ( !recoveryApSessionActive ) { + return; + } + + if ( WiFi.status() == WL_CONNECTED ) { + recoveryApSessionActive = false; + recoveryApSSID = ""; + + return; + } + + if ( apActive ) { + return; + } + + const unsigned long now = millis(); + + if ( now - recoveryApLastStartAttempt < WIFI_RECOVERY_REOPEN_MS ) { + return; + } + + // WLED's normal reconnect path may temporarily tear down SoftAP while it + // retries STA. During an explicit recovery session, reopen the emergency + // AP so the user retains a path back into the Web UI. + startWiFiRecoveryAP(); + } + + // ========================================================= + // Runtime Health / Error UX + // ========================================================= + + uint8_t getRuntimeHealthWarningMask( RuntimeHealthWarning warning ) const { + switch ( warning ) { + case RUNTIME_HEALTH_WARNING_LED_POWER: + return RUNTIME_HEALTH_SHOWN_LED_POWER; + + case RUNTIME_HEALTH_WARNING_POWER_SAFETY: + return RUNTIME_HEALTH_SHOWN_POWER_SAFETY; + + case RUNTIME_HEALTH_WARNING_TOUCH: + return RUNTIME_HEALTH_SHOWN_TOUCH; + + case RUNTIME_HEALTH_WARNING_NONE: + default: + return 0; + } + } + + RuntimeHealthWarning getNextRuntimeHealthWarning( unsigned long now ) { + const bool graceExpired = + now - runtimeHealthStartMs >= POWER_SAFETY_WARNING_GRACE_MS; + + if ( + !( runtimeHealthWarningsShownMask & RUNTIME_HEALTH_SHOWN_LED_POWER ) && + ( + ( coreS3PowerInitializationComplete() && !coreS3PowerExternal5VReady() ) || + ( graceExpired && !coreS3PowerInitializationComplete() ) + ) + ) { + return RUNTIME_HEALTH_WARNING_LED_POWER; + } + + if ( + !( runtimeHealthWarningsShownMask & RUNTIME_HEALTH_SHOWN_POWER_SAFETY ) && + graceExpired && + !coreS3PowerSafeShutdownMonitorReady() + ) { + return RUNTIME_HEALTH_WARNING_POWER_SAFETY; + } + + if ( + !( runtimeHealthWarningsShownMask & RUNTIME_HEALTH_SHOWN_TOUCH ) && + !touchReady + ) { + return RUNTIME_HEALTH_WARNING_TOUCH; + } + + return RUNTIME_HEALTH_WARNING_NONE; + } + + void drawRuntimeHealthWarning( RuntimeHealthWarning warning ) { + display.fillScreen( TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + + if ( warning == RUNTIME_HEALTH_WARNING_LED_POWER ) { + display.setTextColor( TFT_RED, TFT_BLACK ); + display.setTextSize( 2 ); + display.drawString( "LED POWER ERROR", screenWidth / 2, 82 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + display.setTextSize( 1 ); + display.drawString( "External 5V unavailable", screenWidth / 2, 122 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + display.drawString( "Check power path and reboot", screenWidth / 2, 151 ); + + return; + } + + if ( warning == RUNTIME_HEALTH_WARNING_POWER_SAFETY ) { + display.setTextColor( TFT_YELLOW, TFT_BLACK ); + display.setTextSize( 2 ); + display.drawString( "POWER SAFETY WARNING", screenWidth / 2, 82 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + display.setTextSize( 1 ); + display.drawString( "Safe shutdown unavailable", screenWidth / 2, 122 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + display.drawString( "Use WLED power control", screenWidth / 2, 151 ); + + return; + } + + if ( warning == RUNTIME_HEALTH_WARNING_TOUCH ) { + display.setTextColor( TFT_RED, TFT_BLACK ); + display.setTextSize( 2 ); + display.drawString( "TOUCH ERROR", screenWidth / 2, 82 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + display.setTextSize( 1 ); + display.drawString( "Touch input unavailable", screenWidth / 2, 122 ); + + display.setTextColor( TFT_CYAN, TFT_BLACK ); + display.drawString( "Use WLED Web UI", screenWidth / 2, 151 ); + } + } + + bool serviceRuntimeHealthWarnings() { + if ( + startupState != STARTUP_DONE || + displayPowerState != DISPLAY_POWER_ACTIVE || + currentPage != SCREEN_MAIN + ) { + return false; + } + + const unsigned long now = millis(); + + if ( activeRuntimeHealthWarning != RUNTIME_HEALTH_WARNING_NONE ) { + if ( now - runtimeHealthWarningStartMs < RUNTIME_HEALTH_WARNING_HOLD_MS ) { + return true; + } + + runtimeHealthWarningsShownMask |= + getRuntimeHealthWarningMask( activeRuntimeHealthWarning ); + + activeRuntimeHealthWarning = RUNTIME_HEALTH_WARNING_NONE; + runtimeHealthWarningStartMs = 0; + + drawMainScreen( getCurrentNetworkDisplayText() ); + + lastNetworkAccessMode = getNetworkAccessMode(); + lastNetworkDisplayText = getCurrentNetworkDisplayText(); + lastUserActivityMs = now; + + return true; + } + + if ( touchState.touchTarget != M5STACK_TOUCH_TARGET_NONE ) { + return false; + } + + const RuntimeHealthWarning nextWarning = + getNextRuntimeHealthWarning( now ); + + if ( nextWarning == RUNTIME_HEALTH_WARNING_NONE ) { + return false; + } + + activeRuntimeHealthWarning = nextWarning; + runtimeHealthWarningStartMs = now; + + resetTouchGesture(); + drawRuntimeHealthWarning( nextWarning ); + + Serial.printf( + "[CoreS3_Display] Runtime health warning: %u\n", + (unsigned)nextWarning + ); + + return true; + } + + // ========================================================= + // Startup logo + // ========================================================= + + void drawStartupBase() { + display.fillScreen( TFT_BLACK ); + + bool logoResult = display.drawPng( CORES3_WLED_LOGO_PNG, CORES3_WLED_LOGO_PNG_LEN, 8, 20 ); + + if (!logoResult) { + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "WLED M5Stack CoreS3", screenWidth / 2, 68 ); + + Serial.println( F( "[CoreS3_Display] " "WARNING: startup PNG draw failed" ) ); + } + } + + void drawStartupConnectingStatus() { + display.fillRect( 0, 125, screenWidth, 100, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + display.setTextColor( TFT_WHITE, TFT_BLACK ); + display.setTextSize( 2 ); + + // Reserve the width of all three dots from the beginning so the + // "Wi-Fi Connecting" label never shifts while the dots animate. + const char* label = "Wi-Fi Connecting"; + const int16_t labelWidth = display.textWidth( label ); + const int16_t dotWidth = display.textWidth( "." ); + const int16_t reservedDotsWidth = dotWidth * 3; + + const int16_t labelCenterX = + ( screenWidth / 2 ) - ( reservedDotsWidth / 2 ); + + display.drawString( label, labelCenterX, 153 ); + + const int16_t labelRight = + labelCenterX + ( labelWidth / 2 ); + + for ( uint8_t i = 0; i < startupDotCount && i < 3; i++ ) { + const int16_t dotCenterX = + labelRight + ( dotWidth / 2 ) + ( i * dotWidth ); + + display.drawString( ".", dotCenterX, 153 ); + } + + display.setTextSize( 1 ); + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "Starting WLED...", screenWidth / 2, 185 ); + } + + + void drawStartupConnectedStatus( const String& ipAddress ) { + display.fillRect( 0, 125, screenWidth, 100, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_GREEN, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "Wi-Fi Connected", screenWidth / 2, 150 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( ipAddress, screenWidth / 2, 181 ); + } + + void drawStartupAccessPointStatus( const String& ipAddress ) { + display.fillRect( 0, 125, screenWidth, 100, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + display.setTextColor( TFT_GREEN, TFT_BLACK ); + display.setTextSize( 2 ); + + display.drawString( "Wi-Fi AP Ready", screenWidth / 2, 150 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + display.setTextSize( 1 ); + + display.drawString( ipAddress, screenWidth / 2, 181 ); + } + + void drawStartupLocalControlStatus() { + display.fillRect( 0, 125, screenWidth, 100, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + display.setTextColor( TFT_YELLOW, TFT_BLACK ); + display.setTextSize( 2 ); + + display.drawString( "Local Control Ready", screenWidth / 2, 150 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + display.setTextSize( 1 ); + + display.drawString( "Wi-Fi unavailable", screenWidth / 2, 181 ); + } + + void handleStartupSequence() { + if ( startupState == STARTUP_DONE ) { + return; + } + + unsigned long now = millis(); + + if ( startupState == STARTUP_FADE_IN ) { + if ( updateFade( getNormalDisplayBrightness(), now, startupLastFadeStep ) ) { + startupState = STARTUP_WAIT_WIFI; + + startupStateStart = now; + + startupLastDotsUpdate = now; + } + + return; + } + + if ( startupState == STARTUP_WAIT_WIFI ) { + if ( now - startupLastDotsUpdate >= STARTUP_DOTS_INTERVAL_MS ) { + startupLastDotsUpdate = now; + + startupDotCount++; + + if ( startupDotCount > 3 ) { + startupDotCount = 0; + } + + drawStartupConnectingStatus(); + } + + NetworkAccessMode currentNetworkMode = getNetworkAccessMode(); + + if ( currentNetworkMode != NETWORK_ACCESS_NONE ) { + startupNetworkAccessMode = currentNetworkMode; + startupIPAddress = getNetworkIPAddress( currentNetworkMode ); + + if ( currentNetworkMode == NETWORK_ACCESS_STA ) { + drawStartupConnectedStatus( startupIPAddress ); + } + else { + drawStartupAccessPointStatus( startupIPAddress ); + } + + startupState = STARTUP_READY_HOLD; + startupStateStart = now; + + return; + } + + if ( now - startupStateStart >= STARTUP_NETWORK_WAIT_TIMEOUT_MS ) { + startupNetworkAccessMode = NETWORK_ACCESS_NONE; + startupIPAddress = ""; + + drawStartupLocalControlStatus(); + + startupState = STARTUP_READY_HOLD; + startupStateStart = now; + } + + return; + } + + if ( startupState == STARTUP_READY_HOLD ) { + if ( now - startupStateStart >= STARTUP_READY_HOLD_MS ) { + startupState = STARTUP_FADE_OUT; + + startupLastFadeStep = now; + } + + return; + } + + if ( startupState == STARTUP_FADE_OUT ) { + if ( updateFade( 0, now, startupLastFadeStep ) ) { + drawMainScreen( getNetworkDisplayText( startupNetworkAccessMode ) ); + + setDisplayBrightness( 0 ); + + startupState = STARTUP_MAIN_FADE_IN; + + startupLastFadeStep = now; + } + + return; + } + + if ( startupState == STARTUP_MAIN_FADE_IN ) { + if ( updateFade( getNormalDisplayBrightness(), now, startupLastFadeStep ) ) { + startupState = STARTUP_DONE; + + lastNetworkAccessMode = getNetworkAccessMode(); + lastNetworkDisplayText = getNetworkDisplayText( lastNetworkAccessMode ); + + readyScreenShown = true; + + connectingScreenShown = false; + + lastUserActivityMs = now; + + displayPowerState = DISPLAY_POWER_ACTIVE; + + Serial.println( F( "[CoreS3_Display] " "Startup animation complete" ) ); + } + + return; + } + } + + bool pollWakeTouch( unsigned long now ) { + if ( now - touchState.wakeTouchLastPoll < TOUCH_POLL_MS ) { + return touchState.wakeTouchState; + } + + touchState.wakeTouchLastPoll = now; + + int16_t x = -1; + int16_t y = -1; + + touchState.wakeTouchState = ( readDisplayTouch( x, y ) ); + + return touchState.wakeTouchState; + } + + bool settlePendingPreset() { + if ( pendingPresetId == 0 ) { + return false; + } + + bool completed = ( currentPreset == pendingPresetId ); + + bool timedOut = ( millis() - pendingPresetRequestMs >= PRESET_APPLY_PENDING_MS ); + + if ( !completed && !timedOut ) { + return false; + } + + pendingPresetId = 0; + + pendingPresetName = ""; + + pendingPresetRequestMs = 0; + + return true; + } + uint8_t getDisplayedPresetId() { + if ( pendingPresetId > 0 ) { + if ( millis() - pendingPresetRequestMs < PRESET_APPLY_PENDING_MS ) { + return pendingPresetId; + } + } + + if ( isPresetNavigationCursorValid() ) { + return presetNavigationCursorId; + } + + if ( + currentPreset > 0 && + presetCacheReady && + !presetCacheBuilding && + findPresetCacheIndex( currentPreset ) >= 0 + ) { + return currentPreset; + } + + return 0; + } + + void redrawCurrentPageForWake() { + settlePendingPreset(); + + if ( currentPage == SCREEN_COLOR ) { + drawColorScreen(); + + return; + } + + if ( currentPage == SCREEN_EFFECT ) { + drawEffectDetailScreen(); + + return; + } + + if ( currentPage == SCREEN_PRESET ) { + if ( presetSubPage == PRESET_SUBPAGE_MANAGE ) { + drawPresetManageScreen(); + } + else if ( presetSubPage == PRESET_SUBPAGE_SAVE ) { + drawPresetSaveScreen(); + } + else if ( presetSubPage == PRESET_SUBPAGE_OVERWRITE ) { + drawPresetOverwriteScreen(); + } + else if ( presetSubPage == PRESET_SUBPAGE_DELETE ) { + drawPresetDeleteScreen(); + } + else if ( presetSubPage == PRESET_SUBPAGE_BOOT ) { + drawPresetBootScreen(); + } + else { + drawPresetScreen(); + } + + return; + } + + drawMainScreen( getCurrentNetworkDisplayText() ); + } + + void beginDisplaySleep( unsigned long now ) { + resetTouchGesture(); + + displayPowerState = DISPLAY_POWER_SLEEP_FADE_OUT; + + displayFadeLastStep = now; + + touchState.wakeTouchLastPoll = 0; + + touchState.wakeTouchState = false; + + touchState.wakeReleaseCandidate = 0; + } + + void beginDisplayWake( unsigned long now ) { + resetTouchGesture(); + + setDisplayBrightness( 0 ); + + redrawCurrentPageForWake(); + + setDisplayBrightness( 0 ); + + displayPowerState = DISPLAY_POWER_WAKE_FADE_IN; + + displayFadeLastStep = now; + + touchState.wakeReleaseCandidate = 0; + + lastUserActivityMs = now; + } + + bool handleDisplayPowerManagement() { + unsigned long now = millis(); + + if ( displayPowerState == DISPLAY_POWER_ACTIVE ) { + if ( sleepTimeoutSec > 0 && !touchState.touchActive && now - lastUserActivityMs >= getSleepTimeoutMs() ) { + beginDisplaySleep( now ); + + return true; + } + + return false; + } + + if ( displayPowerState == DISPLAY_POWER_SLEEP_FADE_OUT ) { + if ( pollWakeTouch( now ) ) { + beginDisplayWake( now ); + + return true; + } + + if ( updateFade( 0, now, displayFadeLastStep ) ) { + displayPowerState = DISPLAY_POWER_SLEEPING; + + setDisplayBrightness( 0 ); + + Serial.println( F( "[CoreS3_Display] " "Display sleeping" ) ); + } + + return true; + } + + if ( displayPowerState == DISPLAY_POWER_SLEEPING ) { + if ( pollWakeTouch( now ) ) { + beginDisplayWake( now ); + } + + return true; + } + + if ( displayPowerState == DISPLAY_POWER_WAKE_FADE_IN ) { + pollWakeTouch( now ); + + if ( updateFade( getNormalDisplayBrightness(), now, displayFadeLastStep ) ) { + displayPowerState = DISPLAY_POWER_WAKE_WAIT_RELEASE; + + touchState.wakeReleaseCandidate = 0; + + } + + return true; + } + + if ( displayPowerState == DISPLAY_POWER_WAKE_WAIT_RELEASE ) { + bool touching = pollWakeTouch( now ); + + if (touching) { + touchState.wakeReleaseCandidate = 0; + + return true; + } + + if ( touchState.wakeReleaseCandidate == 0 ) { + touchState.wakeReleaseCandidate = now; + + return true; + } + + if ( now - touchState.wakeReleaseCandidate >= TOUCH_RELEASE_CONFIRM_MS ) { + displayPowerState = DISPLAY_POWER_ACTIVE; + + lastUserActivityMs = now; + + touchState.wakeReleaseCandidate = 0; + + touchState.wakeTouchState = false; + + resetTouchGesture(); + + Serial.println( F( "[CoreS3_Display] " "Display active" ) ); + } + + return true; + } + + return false; + } + + uint16_t rgbTo565( uint8_t r, uint8_t g, uint8_t b ) { + return ( ((uint16_t)(r & 0xF8) << 8) | ((uint16_t)(g & 0xFC) << 3) | ((uint16_t)b >> 3) ); + } + + struct M5StackEffectColorCapabilities { + bool color1 = true; + bool color2 = true; + bool color3 = true; + }; + + uint32_t getPrimaryColor() { + if ( strip.getSegmentsNum() > 0 ) { + return strip.getMainSegment().colors[0]; + } + + return 0; + } + + uint32_t getColorSlotValue( uint8_t colorSlot ) { + if ( strip.getSegmentsNum() == 0 || colorSlot > 2 ) { + return 0; + } + + return strip.getMainSegment().colors[ colorSlot ]; + } + + uint32_t getSelectedColor() { + return getColorSlotValue( selectedColorSlot ); + } + + bool isEffectColorSlotEnabled( + const M5StackEffectColorCapabilities& capability, + uint8_t colorSlot + ) { + if ( colorSlot == 0 ) { + return capability.color1; + } + + if ( colorSlot == 1 ) { + return capability.color2; + } + + if ( colorSlot == 2 ) { + return capability.color3; + } + + return false; + } + + uint8_t getFirstEnabledColorSlot( + const M5StackEffectColorCapabilities& capability + ) { + if ( capability.color1 ) { + return 0; + } + + if ( capability.color2 ) { + return 1; + } + + if ( capability.color3 ) { + return 2; + } + + return 0; + } + + bool normalizeSelectedColorSlot( uint8_t effectMode ) { + const M5StackEffectColorCapabilities capability = + getEffectColorCapabilities( effectMode ); + + if ( !effectUsesAnyColor( capability ) ) { + selectedColorSlot = 0; + return false; + } + + if ( !isEffectColorSlotEnabled( capability, selectedColorSlot ) ) { + selectedColorSlot = getFirstEnabledColorSlot( capability ); + } + + return true; + } + + void cacheCurrentColorSlots() { + if ( strip.getSegmentsNum() == 0 ) { + lastColorSlotsValid = false; + return; + } + + Segment& mainSegment = strip.getMainSegment(); + + for ( uint8_t colorSlot = 0; colorSlot < 3; colorSlot++ ) { + const uint32_t slotColor = mainSegment.colors[ colorSlot ]; + + lastColorSlots[ colorSlot ] = slotColor; + + if ( slotColor != 0 ) { + lastNonBlackColorSlots[ colorSlot ] = slotColor; + lastNonBlackColorSlotValid[ colorSlot ] = true; + } + } + + lastColorSlotsValid = true; + } + + bool currentColorSlotsChanged() { + if ( strip.getSegmentsNum() == 0 ) { + return lastColorSlotsValid; + } + + if ( !lastColorSlotsValid ) { + return true; + } + + Segment& mainSegment = strip.getMainSegment(); + + for ( uint8_t colorSlot = 0; colorSlot < 3; colorSlot++ ) { + if ( mainSegment.colors[ colorSlot ] != lastColorSlots[ colorSlot ] ) { + return true; + } + } + + return false; + } + + void selectColorSlot( uint8_t colorSlot ) { + const uint8_t effectMode = getCurrentEffectMode(); + + const M5StackEffectColorCapabilities capability = + getEffectColorCapabilities( effectMode ); + + if ( !isEffectColorSlotEnabled( capability, colorSlot ) ) { + return; + } + + selectedColorSlot = colorSlot; + + hueEditValid = false; + saturationEditValid = false; + + const uint32_t selectedColor = getSelectedColor(); + + syncLogicalColorFromRgb( selectedColor ); + + lastSelectedColor = selectedColor; + lastSelectedColorValid = true; + + cacheCurrentColorSlots(); + + if ( currentPage == SCREEN_COLOR ) { + drawColorDetails( selectedColor ); + + drawHue( logicalHueValue, M5STACK_TOUCH_TARGET_NONE ); + + drawSaturation( logicalSaturationValue, M5STACK_TOUCH_TARGET_NONE ); + } + } + + bool toggleColorSlotBlack( uint8_t colorSlot ) { + if ( strip.getSegmentsNum() == 0 || colorSlot > 2 ) { + return false; + } + + const M5StackEffectColorCapabilities capability = + getEffectColorCapabilities( getCurrentEffectMode() ); + + if ( !isEffectColorSlotEnabled( capability, colorSlot ) ) { + return false; + } + + Segment& mainSegment = strip.getMainSegment(); + + const uint32_t currentColor = mainSegment.colors[ colorSlot ]; + + uint32_t newColor = 0; + + if ( currentColor != 0 ) { + lastNonBlackColorSlots[ colorSlot ] = currentColor; + lastNonBlackColorSlotValid[ colorSlot ] = true; + + newColor = 0; + } + else { + // If this slot has never had a non-black color during this runtime, + // restore to RGB white. The user can then immediately tune Hue/Sat. + newColor = + lastNonBlackColorSlotValid[ colorSlot ] + ? lastNonBlackColorSlots[ colorSlot ] + : 0x00FFFFFF; + } + + selectedColorSlot = colorSlot; + + hueEditValid = false; + saturationEditValid = false; + + if ( newColor != currentColor ) { + mainSegment.setColor( colorSlot, newColor ); + + stateUpdated( CALL_MODE_BUTTON ); + } + + syncLogicalColorFromRgb( newColor ); + + lastSelectedColor = newColor; + lastSelectedColorValid = true; + + if ( colorSlot == 0 ) { + lastPrimaryColor = newColor; + lastPrimaryColorValid = true; + } + + cacheCurrentColorSlots(); + + lastHueValue = logicalHueValue; + lastSaturationValue = logicalSaturationValue; + + if ( currentPage == SCREEN_COLOR ) { + drawColorDetails( newColor ); + + drawHue( logicalHueValue, M5STACK_TOUCH_TARGET_NONE ); + + drawSaturation( logicalSaturationValue, M5STACK_TOUCH_TARGET_NONE ); + } + + return true; + } + + uint8_t getCurrentEffectMode() { + if ( strip.getSegmentsNum() > 0 ) { + return strip.getMainSegment().mode; + } + + return 0; + } + + uint8_t getCurrentSpeed() { + if ( strip.getSegmentsNum() > 0 ) { + return strip.getMainSegment().speed; + } + + return 0; + } + + uint8_t getCurrentIntensity() { + if ( strip.getSegmentsNum() > 0 ) { + return strip.getMainSegment().intensity; + } + + return 0; + } + + uint8_t getCurrentPalette() { + if ( strip.getSegmentsNum() > 0 ) { + return strip.getMainSegment().palette; + } + + return 0; + } + + size_t getSelectablePaletteCount() { + return FIXED_PALETTE_COUNT + customPalettes.size() + usermodPalettes.size(); + } + + uint8_t paletteIdFromSequenceIndex( size_t sequenceIndex ) { + if ( sequenceIndex < FIXED_PALETTE_COUNT ) { + return (uint8_t)sequenceIndex; + } + + sequenceIndex -= FIXED_PALETTE_COUNT; + + if ( sequenceIndex < customPalettes.size() ) { + return (uint8_t)( WLED_CUSTOM_PALETTE_ID_BASE - sequenceIndex ); + } + + sequenceIndex -= customPalettes.size(); + + if ( sequenceIndex < usermodPalettes.size() ) { + return (uint8_t)( WLED_USERMOD_PALETTE_ID_BASE - sequenceIndex ); + } + + return 0; + } + + int findPaletteSequenceIndex( uint8_t paletteId ) { + if ( paletteId < FIXED_PALETTE_COUNT ) { + return paletteId; + } + + if ( paletteId > WLED_CUSTOM_PALETTE_ID_BASE ) { + size_t usermodIndex = WLED_USERMOD_PALETTE_ID_BASE - paletteId; + + if ( usermodIndex < usermodPalettes.size() ) { + return (int)( FIXED_PALETTE_COUNT + customPalettes.size() + usermodIndex ); + } + + return -1; + } + + if ( paletteId >= FIXED_PALETTE_COUNT && paletteId <= WLED_CUSTOM_PALETTE_ID_BASE ) { + size_t customIndex = WLED_CUSTOM_PALETTE_ID_BASE - paletteId; + + if ( customIndex < customPalettes.size() ) { + return (int)( FIXED_PALETTE_COUNT + customIndex ); + } + + return -1; + } + + return -1; + } + + void getPaletteName( uint8_t paletteId, char* paletteName, size_t paletteNameSize ) { + if ( paletteName == nullptr || paletteNameSize == 0 ) { + return; + } + + paletteName[0] = '\0'; + + extractModeName( paletteId, JSON_palette_names, paletteName, paletteNameSize - 1 ); + + if ( strlen(paletteName) == 0 ) { + snprintf( paletteName, paletteNameSize, "Palette %u", paletteId ); + } + } + + uint8_t findAdjacentPreset( uint8_t startPreset, int direction, String* foundName = nullptr ) { + if ( direction == 0 ) { + return 0; + } + + if ( !presetCacheReady || presetCacheCount == 0 ) { + return 0; + } + + int currentIndex = findPresetCacheIndex( startPreset ); + + int newIndex; + + if ( currentIndex < 0 ) { + if ( direction > 0 ) { + newIndex = 0; + } + else { + newIndex = presetCacheCount - 1; + } + } + else { + newIndex = currentIndex + ( direction > 0 ? 1 : -1 ); + + if ( newIndex >= presetCacheCount ) { + newIndex = 0; + } + + if ( newIndex < 0 ) { + newIndex = presetCacheCount - 1; + } + } + + if ( foundName != nullptr ) { + *foundName = presetCache[ newIndex ].name; + } + + return presetCache[ newIndex ].id; + } + + bool isPresetNavigationCursorValid() { + return ( + presetNavigationCursorId > 0 && + presetCacheReady && + !presetCacheBuilding && + findPresetCacheIndex( presetNavigationCursorId ) >= 0 + ); + } + + void normalizePresetNavigationCursor() { + if ( !presetCacheReady || presetCacheBuilding ) { + return; + } + + if ( presetCacheCount == 0 ) { + presetNavigationCursorId = 0; + return; + } + + if ( isPresetNavigationCursorValid() ) { + return; + } + + if ( presetNavigationCursorId == 0 ) { + if ( currentPreset > 0 && findPresetCacheIndex( currentPreset ) >= 0 ) { + presetNavigationCursorId = currentPreset; + } + + return; + } + + const uint8_t previousCursor = presetNavigationCursorId; + + // If the cursor Preset was deleted, prefer the next higher saved ID. + // If there is no higher ID, fall back to the final saved Preset. + for ( uint16_t index = 0; index < presetCacheCount; index++ ) { + if ( presetCache[ index ].id > previousCursor ) { + presetNavigationCursorId = presetCache[ index ].id; + return; + } + } + + presetNavigationCursorId = presetCache[ presetCacheCount - 1 ].id; + } + + void syncPresetNavigationCursorFromCurrentPreset() { + if ( currentPreset == lastObservedCurrentPreset ) { + return; + } + + if ( currentPreset == 0 ) { + // WLED entered Custom State. Keep the CoreS3 navigation cursor where + // the user last selected a Preset. + lastObservedCurrentPreset = 0; + return; + } + + if ( !presetCacheReady || presetCacheBuilding ) { + // Retry once the cache is available. Do not mark this value observed yet. + return; + } + + if ( findPresetCacheIndex( currentPreset ) < 0 ) { + // Retry if the Preset cache is in transition. + return; + } + + presetNavigationCursorId = currentPreset; + lastObservedCurrentPreset = currentPreset; + } + + uint8_t getPresetManagementBaseId() { + if ( isPresetNavigationCursorValid() ) { + return presetNavigationCursorId; + } + + if ( + currentPreset > 0 && + presetCacheReady && + !presetCacheBuilding && + findPresetCacheIndex( currentPreset ) >= 0 + ) { + return currentPreset; + } + + if ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 ) { + return presetCache[ 0 ].id; + } + + return 0; + } + uint8_t getPresetNavigationBaseId() { + if ( pendingPresetId > 0 && millis() - pendingPresetRequestMs < PRESET_APPLY_PENDING_MS ) { + return pendingPresetId; + } + + if ( isPresetNavigationCursorValid() ) { + return presetNavigationCursorId; + } + + if ( + currentPreset > 0 && + presetCacheReady && + !presetCacheBuilding && + findPresetCacheIndex( currentPreset ) >= 0 + ) { + return currentPreset; + } + + return 0; + } + + uint8_t getHueFromColor( uint32_t color ) { + CRGBW rgb( color ); + + CHSV32 hsv; + + rgb2hsv( rgb, hsv ); + + return (uint8_t)( hsv.h >> 8 ); + } + + uint8_t getSaturationFromColor( uint32_t color ) { + CRGBW rgb( color ); + + CHSV32 hsv; + + rgb2hsv( rgb, hsv ); + + return hsv.s; + } + + void syncLogicalColorFromRgb( uint32_t color ) { + CRGBW rgb( color ); + + rgb2hsv( rgb, logicalColorHsv ); + + logicalHueValue = (uint8_t)( logicalColorHsv.h >> 8 ); + + logicalSaturationValue = logicalColorHsv.s; + + logicalWhiteValue = rgb.w; + + logicalColorHsvValid = true; + + lastHueValue = logicalHueValue; + + lastSaturationValue = logicalSaturationValue; + } + + uint8_t getDisplayedHue() { + uint32_t currentColor = getSelectedColor(); + + if ( logicalColorHsvValid && lastSelectedColorValid && currentColor == lastSelectedColor ) { + return logicalHueValue; + } + + return getHueFromColor( currentColor ); + } + + uint8_t getDisplayedSaturation() { + uint32_t currentColor = getSelectedColor(); + + if ( logicalColorHsvValid && lastSelectedColorValid && currentColor == lastSelectedColor ) { + return logicalSaturationValue; + } + + return getSaturationFromColor( currentColor ); + } + + // ========================================================= + // Common centered text / standard page header helpers + // ========================================================= + + void setCenteredTextStyle( uint16_t color, uint8_t size, uint16_t background = TFT_BLACK ) { + display.setTextDatum( textdatum_t::middle_center ); + display.setTextColor( color, background ); + display.setTextSize( size ); + } + + void drawStandardPageHeader( const char* title, const char* subtitle, uint16_t titleColor = TFT_WHITE ) { + setCenteredTextStyle( titleColor, 2 ); + display.drawString( title, screenWidth / 2, 18 ); + + setCenteredTextStyle( TFT_WHITE, 1 ); + display.drawString( subtitle, screenWidth / 2, 41 ); + + display.drawFastHLine( 8, 58, screenWidth - 16, TFT_DARKGREY ); + } + + void drawPowerIcon( int16_t centerX, int16_t centerY, uint16_t iconColor, uint16_t backgroundColor ) { + display.drawCircle( centerX, centerY + 2, 11, iconColor ); + + display.drawCircle( centerX, centerY + 2, 10, iconColor ); + + display.fillRect( centerX - 4, centerY - 11, 9, 8, backgroundColor ); + + display.drawFastVLine( centerX - 1, centerY - 14, 12, iconColor ); + + display.drawFastVLine( centerX, centerY - 14, 12, iconColor ); + + display.drawFastVLine( centerX + 1, centerY - 14, 12, iconColor ); + } + + void drawPowerButton( bool ledOn, bool pressed ) { + uint16_t stateColor = ledOn ? TFT_GREEN : TFT_RED; + + uint16_t backgroundColor = pressed ? stateColor : TFT_BLACK; + + uint16_t iconColor = pressed ? TFT_BLACK : stateColor; + + display.fillRect( POWER_BUTTON_X - 2, POWER_BUTTON_Y - 2, POWER_BUTTON_W + 4, POWER_BUTTON_H + 4, TFT_BLACK ); + + display.fillRect( POWER_BUTTON_X, POWER_BUTTON_Y, POWER_BUTTON_W, POWER_BUTTON_H, backgroundColor ); + + display.drawRect( POWER_BUTTON_X, POWER_BUTTON_Y, POWER_BUTTON_W, POWER_BUTTON_H, stateColor ); + + display.drawRect( POWER_BUTTON_X + 1, POWER_BUTTON_Y + 1, POWER_BUTTON_W - 2, POWER_BUTTON_H - 2, stateColor ); + + int16_t centerX = POWER_BUTTON_X + (POWER_BUTTON_W / 2); + + int16_t centerY = POWER_BUTTON_Y + (POWER_BUTTON_H / 2); + + drawPowerIcon( centerX, centerY, iconColor, backgroundColor ); + + touchState.powerButtonVisualPressed = pressed; + } + + void drawTriangleButton( int16_t x, int16_t y, bool pointRight, bool pressed ) { + const uint16_t buttonColor = TFT_CYAN; + + const int16_t w = CONTROL_BUTTON_W; + + const int16_t h = CONTROL_BUTTON_H; + + display.fillRect( x - 2, y - 2, w + 4, h + 4, TFT_BLACK ); + + if (pressed) { + display.fillRect( x, y, w, h, buttonColor ); + } + else { + display.fillRect( x, y, w, h, TFT_BLACK ); + + display.drawRect( x, y, w, h, buttonColor ); + + display.drawRect( x + 1, y + 1, w - 2, h - 2, buttonColor ); + } + + int16_t centerX = x + (w / 2); + + int16_t centerY = y + (h / 2); + + uint16_t triangleColor = pressed ? TFT_BLACK : buttonColor; + + if (pointRight) { + display.fillTriangle( centerX + 10, centerY, centerX - 7, centerY - 9, centerX - 7, centerY + 9, triangleColor ); + } + else { + display.fillTriangle( centerX - 10, centerY, centerX + 7, centerY - 9, centerX + 7, centerY + 9, triangleColor ); + } + } + + // ========================================================= + // Shared numeric control drawing + // ========================================================= + + void drawNumericControl( int16_t clearY, int16_t clearH, const char* label, int16_t labelY, int16_t buttonY, int16_t valueY, const char* valueText, M5StackTouchTarget pressedTarget, M5StackTouchTarget downTarget, M5StackTouchTarget upTarget ) { + display.fillRect( 0, clearY, screenWidth, clearH, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( label, screenWidth / 2, labelY ); + + drawTriangleButton( CONTROL_LEFT_X, buttonY, false, pressedTarget == downTarget ); + + drawTriangleButton( CONTROL_RIGHT_X, buttonY, true, pressedTarget == upTarget ); + + display.setTextSize( 2 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.drawString( valueText, screenWidth / 2, valueY ); + } + + void drawBrightness( int brightnessValue, M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + brightnessValue = constrain( brightnessValue, 0, 255 ); + + int brightnessPercent = 0; + + if ( brightnessValue >= 255 ) { + brightnessPercent = 100; + } + else if ( brightnessValue > 0 ) { + brightnessPercent = max( 1, ( brightnessValue * 100 ) / 255 ); + } + + char valueText[16]; + + snprintf( + valueText, + sizeof(valueText), + "%d %d%%", + brightnessValue, + brightnessPercent + ); + + drawNumericControl( + 62, + 58, + "LED Brightness", + 70, + BRI_BUTTON_Y, + 99, + valueText, + pressedTarget, + M5STACK_TOUCH_TARGET_BRIGHTNESS_DOWN, + M5STACK_TOUCH_TARGET_BRIGHTNESS_UP + ); + } + + struct M5StackEffectCapabilities { + bool supports1D = true; + bool supports2D = false; + bool supports3D = false; + bool audioVolume = false; + bool audioFrequency = false; + }; + + M5StackEffectCapabilities getEffectCapabilities( uint8_t effectMode ) { + M5StackEffectCapabilities capability; + + const char* modeData = strip.getModeData( effectMode ); + + if ( modeData == nullptr ) { + return capability; + } + + const char* metadataStart = strchr( modeData, '@' ); + + if ( metadataStart == nullptr ) { + // WLED metadata specification defaults missing flags to 1D. + return capability; + } + + const char* flagsStart = metadataStart + 1; + + // Metadata sections: + // parameters ; colors ; palette ; flags ; defaults + for ( uint8_t section = 0; section < 3; section++ ) { + flagsStart = strchr( flagsStart, ';' ); + + if ( flagsStart == nullptr ) { + return capability; + } + + flagsStart++; + } + + const char* flagsEnd = strchr( flagsStart, ';' ); + + if ( flagsEnd == nullptr ) { + flagsEnd = flagsStart + strlen( flagsStart ); + } + + if ( flagsStart == flagsEnd ) { + return capability; + } + + bool dimensionFlagFound = false; + + capability.supports1D = false; + + for ( const char* flag = flagsStart; flag < flagsEnd; flag++ ) { + switch ( *flag ) { + case '0': + // Flag 0 means the effect also works well on a single LED. + // Treat it as 1D-capable for the compact CoreS3 display. + capability.supports1D = true; + dimensionFlagFound = true; + break; + + case '1': + capability.supports1D = true; + dimensionFlagFound = true; + break; + + case '2': + capability.supports2D = true; + dimensionFlagFound = true; + break; + + case '3': + capability.supports3D = true; + dimensionFlagFound = true; + break; + + case 'v': + capability.audioVolume = true; + break; + + case 'f': + capability.audioFrequency = true; + break; + + default: + break; + } + } + + if ( !dimensionFlagFound ) { + // WLED metadata specification: missing dimension flags fall back to 1D. + capability.supports1D = true; + } + + return capability; + } + + bool effectUsesAudioReactive( + const M5StackEffectCapabilities& capability + ) const { + return capability.audioVolume || capability.audioFrequency; + } + + bool isCoreS3AudioUnavailable() const { +#if defined(WLED_M5STACK_CORES3_AUDIO) + return + coreS3AudioInitializationFinished() && + !coreS3AudioCodecReady(); +#else + return false; +#endif + } + + bool isAudioUnavailableForEffect( uint8_t effectMode ) { + const M5StackEffectCapabilities capability = + getEffectCapabilities( effectMode ); + + return + effectUsesAudioReactive( capability ) && + isCoreS3AudioUnavailable(); + } + + bool effectRequires2D( const M5StackEffectCapabilities& capability ) { + return capability.supports2D && !capability.supports1D; + } + + bool currentMainSegmentIs2D() { + if ( strip.getSegmentsNum() == 0 ) { + return false; + } + + return strip.getMainSegment().is2D(); + } + + void getEffectDimensionText( + const M5StackEffectCapabilities& capability, + char* text, + size_t textSize + ) { + if ( text == nullptr || textSize == 0 ) { + return; + } + + text[0] = '\0'; + + if ( capability.supports1D && capability.supports2D && capability.supports3D ) { + strncpy( text, "1D/2D/3D", textSize - 1 ); + } + else if ( capability.supports1D && capability.supports2D ) { + strncpy( text, "1D/2D", textSize - 1 ); + } + else if ( capability.supports1D && capability.supports3D ) { + strncpy( text, "1D/3D", textSize - 1 ); + } + else if ( capability.supports2D && capability.supports3D ) { + strncpy( text, "2D/3D", textSize - 1 ); + } + else if ( capability.supports2D ) { + strncpy( text, "2D", textSize - 1 ); + } + else if ( capability.supports3D ) { + strncpy( text, "3D", textSize - 1 ); + } + else { + strncpy( text, "1D", textSize - 1 ); + } + + text[ textSize - 1 ] = '\0'; + } + + void getEffectAudioText( + const M5StackEffectCapabilities& capability, + char* text, + size_t textSize + ) { + if ( text == nullptr || textSize == 0 ) { + return; + } + + text[0] = '\0'; + + if ( capability.audioVolume && capability.audioFrequency ) { + strncpy( text, "AUDIO", textSize - 1 ); + } + else if ( capability.audioFrequency ) { + strncpy( text, "FFT", textSize - 1 ); + } + else if ( capability.audioVolume ) { + strncpy( text, "VOL", textSize - 1 ); + } + + text[ textSize - 1 ] = '\0'; + } + + void getEffectCapabilityText( + uint8_t effectMode, + char* text, + size_t textSize, + bool& incompatibleWithCurrentSegment + ) { + if ( text == nullptr || textSize == 0 ) { + incompatibleWithCurrentSegment = false; + return; + } + + const M5StackEffectCapabilities capability = getEffectCapabilities( effectMode ); + + incompatibleWithCurrentSegment = + effectRequires2D( capability ) && !currentMainSegmentIs2D(); + + const bool audioUnavailable = + effectUsesAudioReactive( capability ) && + isCoreS3AudioUnavailable(); + + char dimensionText[16]; + char audioText[8]; + + getEffectDimensionText( capability, dimensionText, sizeof(dimensionText) ); + getEffectAudioText( capability, audioText, sizeof(audioText) ); + + if ( incompatibleWithCurrentSegment ) { + if ( audioUnavailable ) { + snprintf( text, textSize, "2D REQ | AUDIO UNAVAILABLE" ); + } + else if ( audioText[0] != '\0' ) { + snprintf( text, textSize, "2D REQUIRED | %s", audioText ); + } + else { + snprintf( text, textSize, "2D REQUIRED" ); + } + + return; + } + + if ( audioUnavailable ) { + snprintf( text, textSize, "AUDIO UNAVAILABLE" ); + return; + } + + if ( audioText[0] != '\0' ) { + snprintf( text, textSize, "%s | %s", dimensionText, audioText ); + } + else { + snprintf( text, textSize, "%s", dimensionText ); + } + } + + bool getEffectMetadataSection( + uint8_t effectMode, + uint8_t sectionIndex, + const char*& sectionStart, + const char*& sectionEnd + ) { + sectionStart = nullptr; + sectionEnd = nullptr; + + const char* modeData = strip.getModeData( effectMode ); + + if ( modeData == nullptr ) { + return false; + } + + const char* metadataStart = strchr( modeData, '@' ); + + if ( metadataStart == nullptr ) { + return false; + } + + sectionStart = metadataStart + 1; + + for ( uint8_t section = 0; section < sectionIndex; section++ ) { + const char* separator = strchr( sectionStart, ';' ); + + if ( separator == nullptr ) { + sectionStart = nullptr; + return false; + } + + sectionStart = separator + 1; + } + + sectionEnd = strchr( sectionStart, ';' ); + + if ( sectionEnd == nullptr ) { + sectionEnd = sectionStart + strlen( sectionStart ); + } + + return true; + } + + void copyEffectMetadataLabel( + const char* fieldStart, + const char* fieldEnd, + const char* defaultLabel, + char* label, + size_t labelSize + ) { + if ( label == nullptr || labelSize == 0 ) { + return; + } + + label[0] = '\0'; + + if ( fieldStart == nullptr || fieldEnd == nullptr || fieldStart >= fieldEnd ) { + return; + } + + if ( fieldEnd - fieldStart == 1 && *fieldStart == '!' ) { + strncpy( label, defaultLabel, labelSize - 1 ); + label[ labelSize - 1 ] = '\0'; + return; + } + + size_t copyLength = (size_t)( fieldEnd - fieldStart ); + + if ( copyLength >= labelSize ) { + copyLength = labelSize - 1; + } + + memcpy( label, fieldStart, copyLength ); + label[ copyLength ] = '\0'; + } + + bool getEffectSliderMetadata( + uint8_t effectMode, + uint8_t sliderIndex, + const char* defaultLabel, + char* label, + size_t labelSize + ) { + if ( label == nullptr || labelSize == 0 ) { + return false; + } + + label[0] = '\0'; + + const char* sectionStart = nullptr; + const char* sectionEnd = nullptr; + + if ( !getEffectMetadataSection( effectMode, 0, sectionStart, sectionEnd ) ) { + // WLED metadata fallback: missing parameter section means the + // standard Speed + Intensity sliders are available. + if ( sliderIndex <= 1 ) { + strncpy( label, defaultLabel, labelSize - 1 ); + label[ labelSize - 1 ] = '\0'; + return true; + } + + return false; + } + + const char* fieldStart = sectionStart; + + for ( uint8_t fieldIndex = 0; fieldIndex < sliderIndex; fieldIndex++ ) { + const char* comma = nullptr; + + for ( const char* cursor = fieldStart; cursor < sectionEnd; cursor++ ) { + if ( *cursor == ',' ) { + comma = cursor; + break; + } + } + + if ( comma == nullptr ) { + // Explicit metadata section exists but this field is missing. + // WLED treats a missing/empty label as a disabled control. + return false; + } + + fieldStart = comma + 1; + } + + const char* fieldEnd = sectionEnd; + + for ( const char* cursor = fieldStart; cursor < sectionEnd; cursor++ ) { + if ( *cursor == ',' ) { + fieldEnd = cursor; + break; + } + } + + if ( fieldStart >= fieldEnd ) { + return false; + } + + copyEffectMetadataLabel( + fieldStart, + fieldEnd, + defaultLabel, + label, + labelSize + ); + + // Keep labels compact for the 320 px CoreS3 display. + if ( strlen(label) > 24 ) { + label[24] = '\0'; + } + + return label[0] != '\0'; + } + + bool getEffectSpeedControlMetadata( + uint8_t effectMode, + char* label, + size_t labelSize + ) { + return getEffectSliderMetadata( + effectMode, + 0, + "Speed", + label, + labelSize + ); + } + + bool getEffectIntensityControlMetadata( + uint8_t effectMode, + char* label, + size_t labelSize + ) { + return getEffectSliderMetadata( + effectMode, + 1, + "Intensity", + label, + labelSize + ); + } + + bool isEffectSpeedControlVisible( uint8_t effectMode ) { + char label[32]; + + return getEffectSpeedControlMetadata( + effectMode, + label, + sizeof(label) + ); + } + + bool isEffectIntensityControlVisible( uint8_t effectMode ) { + char label[32]; + + return getEffectIntensityControlMetadata( + effectMode, + label, + sizeof(label) + ); + } + + bool getEffectColorSlotCustomLabel( + uint8_t effectMode, + uint8_t colorSlot, + char* label, + size_t labelSize + ) { + if ( label == nullptr || labelSize == 0 || colorSlot > 2 ) { + return false; + } + + label[0] = '\0'; + + const char* sectionStart = nullptr; + const char* sectionEnd = nullptr; + + if ( !getEffectMetadataSection( effectMode, 1, sectionStart, sectionEnd ) ) { + // Missing Colors metadata uses WLED's default Fx/Bg/Cs labels. + // Keep CoreS3's stable C1/C2/C3 naming without an extra comment. + return false; + } + + const char* fieldStart = sectionStart; + + for ( uint8_t fieldIndex = 0; fieldIndex < colorSlot; fieldIndex++ ) { + const char* comma = nullptr; + + for ( const char* cursor = fieldStart; cursor < sectionEnd; cursor++ ) { + if ( *cursor == ',' ) { + comma = cursor; + break; + } + } + + if ( comma == nullptr ) { + return false; + } + + fieldStart = comma + 1; + } + + const char* fieldEnd = sectionEnd; + + for ( const char* cursor = fieldStart; cursor < sectionEnd; cursor++ ) { + if ( *cursor == ',' ) { + fieldEnd = cursor; + break; + } + } + + if ( fieldStart >= fieldEnd ) { + return false; + } + + // "!" means WLED's default Fx/Bg/Cs label. It is intentionally not + // repeated because CoreS3 keeps C1/C2/C3 as the primary slot names. + if ( fieldEnd - fieldStart == 1 && *fieldStart == '!' ) { + return false; + } + + size_t copyLength = (size_t)( fieldEnd - fieldStart ); + + // Keep the supplemental line compact on the 320 px display. + if ( copyLength > 24 ) { + copyLength = 24; + } + + if ( copyLength >= labelSize ) { + copyLength = labelSize - 1; + } + + memcpy( label, fieldStart, copyLength ); + label[ copyLength ] = '\0'; + + return label[0] != '\0'; + } + + M5StackEffectColorCapabilities getEffectColorCapabilities( uint8_t effectMode ) { + M5StackEffectColorCapabilities capability; + + const char* sectionStart = nullptr; + const char* sectionEnd = nullptr; + + if ( !getEffectMetadataSection( effectMode, 1, sectionStart, sectionEnd ) ) { + // WLED metadata fallback: missing Colors section means all three + // color slots (Fx/Bg/Cs) are available. + return capability; + } + + capability.color1 = false; + capability.color2 = false; + capability.color3 = false; + + if ( sectionStart >= sectionEnd ) { + // Explicit empty Colors section means the Effect uses no color slots. + return capability; + } + + const char* fieldStart = sectionStart; + + for ( uint8_t colorIndex = 0; colorIndex < 3; colorIndex++ ) { + const char* fieldEnd = sectionEnd; + + for ( const char* cursor = fieldStart; cursor < sectionEnd; cursor++ ) { + if ( *cursor == ',' ) { + fieldEnd = cursor; + break; + } + } + + const bool enabled = fieldStart < fieldEnd; + + if ( colorIndex == 0 ) { + capability.color1 = enabled; + } + else if ( colorIndex == 1 ) { + capability.color2 = enabled; + } + else { + capability.color3 = enabled; + } + + if ( fieldEnd >= sectionEnd ) { + break; + } + + fieldStart = fieldEnd + 1; + } + + return capability; + } + + bool effectUsesPrimaryColor( uint8_t effectMode ) { + return getEffectColorCapabilities( effectMode ).color1; + } + + bool effectUsesAnyColor( const M5StackEffectColorCapabilities& capability ) { + return capability.color1 || capability.color2 || capability.color3; + } + + void getEffectColorCapabilityText( + uint8_t effectMode, + char* text, + size_t textSize + ) { + if ( text == nullptr || textSize == 0 ) { + return; + } + + const M5StackEffectColorCapabilities capability = + getEffectColorCapabilities( effectMode ); + + const char* capabilityText = "NONE"; + + if ( capability.color1 && capability.color2 && capability.color3 ) { + capabilityText = "C1/C2/C3"; + } + else if ( capability.color1 && capability.color2 ) { + capabilityText = "C1/C2"; + } + else if ( capability.color1 && capability.color3 ) { + capabilityText = "C1/C3"; + } + else if ( capability.color2 && capability.color3 ) { + capabilityText = "C2/C3"; + } + else if ( capability.color1 ) { + capabilityText = "C1"; + } + else if ( capability.color2 ) { + capabilityText = "C2"; + } + else if ( capability.color3 ) { + capabilityText = "C3"; + } + + strncpy( text, capabilityText, textSize - 1 ); + text[ textSize - 1 ] = '\0'; + } + + bool isEffectPaletteControlVisible( uint8_t effectMode ) { + const char* sectionStart = nullptr; + const char* sectionEnd = nullptr; + + if ( !getEffectMetadataSection( effectMode, 2, sectionStart, sectionEnd ) ) { + // WLED metadata fallback: missing Palette section means enabled. + return true; + } + + // Explicit empty Palette section means this Effect does not use palettes. + return sectionStart < sectionEnd; + } + + void getEffectName( uint8_t effectMode, char* effectName, size_t effectNameSize ) { + if ( effectName == nullptr || effectNameSize == 0 ) { + return; + } + + effectName[0] = '\0'; + + extractModeName( effectMode, nullptr, effectName, effectNameSize - 1 ); + + if ( strlen(effectName) == 0 ) { + strncpy( effectName, "Unknown", effectNameSize - 1 ); + + effectName[ effectNameSize - 1 ] = '\0'; + } + + if ( strlen(effectName) > 22 ) { + effectName[22] = '\0'; + } + } + + void drawEffectDetailButton( uint8_t effectMode, bool pressed ) { + const uint16_t buttonColor = TFT_CYAN; + + uint16_t backgroundColor = pressed ? buttonColor : TFT_BLACK; + + uint16_t textColor = pressed ? TFT_BLACK : TFT_WHITE; + + display.fillRect( EFFECT_DETAIL_X - 2, EFFECT_DETAIL_Y - 2, EFFECT_DETAIL_W + 4, EFFECT_DETAIL_H + 4, TFT_BLACK ); + + display.fillRect( EFFECT_DETAIL_X, EFFECT_DETAIL_Y, EFFECT_DETAIL_W, EFFECT_DETAIL_H, backgroundColor ); + + display.drawRect( EFFECT_DETAIL_X, EFFECT_DETAIL_Y, EFFECT_DETAIL_W, EFFECT_DETAIL_H, buttonColor ); + + display.drawRect( EFFECT_DETAIL_X + 1, EFFECT_DETAIL_Y + 1, EFFECT_DETAIL_W - 2, EFFECT_DETAIL_H - 2, buttonColor ); + + char effectName[64]; + + getEffectName( effectMode, effectName, sizeof(effectName) ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( textColor, backgroundColor ); + + if ( strlen(effectName) <= 10 ) { + display.setTextSize( 2 ); + } + else { + display.setTextSize( 1 ); + } + + display.drawString( effectName, EFFECT_DETAIL_X + (EFFECT_DETAIL_W / 2), EFFECT_DETAIL_Y + (EFFECT_DETAIL_H / 2) ); + + touchState.effectDetailVisualPressed = pressed; + } + + void drawEffect( uint8_t effectMode, M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + display.fillRect( 0, 120, screenWidth, 58, TFT_BLACK ); + + char capabilityText[40]; + char effectLabel[56]; + bool incompatibleWithCurrentSegment = false; + + getEffectCapabilityText( + effectMode, + capabilityText, + sizeof(capabilityText), + incompatibleWithCurrentSegment + ); + + snprintf( effectLabel, sizeof(effectLabel), "Effect %s", capabilityText ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( + ( incompatibleWithCurrentSegment || isAudioUnavailableForEffect( effectMode ) ) + ? TFT_YELLOW + : TFT_WHITE, + TFT_BLACK + ); + + display.setTextSize( 1 ); + + display.drawString( effectLabel, screenWidth / 2, 128 ); + + drawTriangleButton( CONTROL_LEFT_X, FX_BUTTON_Y, false, pressedTarget == M5STACK_TOUCH_TARGET_EFFECT_PREV ); + + drawEffectDetailButton( effectMode, pressedTarget == M5STACK_TOUCH_TARGET_EFFECT_DETAIL ); + + drawTriangleButton( CONTROL_RIGHT_X, FX_BUTTON_Y, true, pressedTarget == M5STACK_TOUCH_TARGET_EFFECT_NEXT ); + } + + void drawColorButton( uint32_t color, bool pressed ) { + const uint16_t buttonColor = TFT_CYAN; + + const uint8_t effectMode = getCurrentEffectMode(); + + const M5StackEffectColorCapabilities colorCapability = + getEffectColorCapabilities( effectMode ); + + const bool primaryColorUsed = colorCapability.color1; + + const bool anyColorUsed = effectUsesAnyColor( colorCapability ); + + uint16_t backgroundColor = pressed ? buttonColor : TFT_BLACK; + + uint16_t titleColor = pressed ? TFT_BLACK : TFT_WHITE; + + uint16_t capabilityColor = + pressed ? TFT_BLACK : + ( primaryColorUsed ? TFT_CYAN : ( anyColorUsed ? TFT_YELLOW : TFT_DARKGREY ) ); + + uint16_t previewColor = rgbTo565( R(color), G(color), B(color) ); + + display.fillRect( COLOR_BUTTON_X - 2, MAIN_BOTTOM_BUTTON_Y - 2, COLOR_BUTTON_W + 4, MAIN_BOTTOM_BUTTON_H + 4, TFT_BLACK ); + + display.fillRect( COLOR_BUTTON_X, MAIN_BOTTOM_BUTTON_Y, COLOR_BUTTON_W, MAIN_BOTTOM_BUTTON_H, backgroundColor ); + + display.drawRect( COLOR_BUTTON_X, MAIN_BOTTOM_BUTTON_Y, COLOR_BUTTON_W, MAIN_BOTTOM_BUTTON_H, buttonColor ); + + display.drawRect( COLOR_BUTTON_X + 1, MAIN_BOTTOM_BUTTON_Y + 1, COLOR_BUTTON_W - 2, MAIN_BOTTOM_BUTTON_H - 2, buttonColor ); + + static constexpr int16_t SWATCH_X = 28; + static constexpr int16_t SWATCH_W = 24; + static constexpr int16_t SWATCH_H = 24; + + int16_t swatchY = MAIN_BOTTOM_BUTTON_Y + ( ( MAIN_BOTTOM_BUTTON_H - SWATCH_H ) / 2 ); + + if ( primaryColorUsed ) { + display.fillRect( SWATCH_X, swatchY, SWATCH_W, SWATCH_H, previewColor ); + + display.drawRect( SWATCH_X, swatchY, SWATCH_W, SWATCH_H, TFT_WHITE ); + } + else { + display.fillRect( SWATCH_X, swatchY, SWATCH_W, SWATCH_H, TFT_BLACK ); + + display.drawRect( SWATCH_X, swatchY, SWATCH_W, SWATCH_H, TFT_DARKGREY ); + + display.drawLine( SWATCH_X + 4, swatchY + 4, SWATCH_X + SWATCH_W - 5, swatchY + SWATCH_H - 5, TFT_DARKGREY ); + + display.drawLine( SWATCH_X + SWATCH_W - 5, swatchY + 4, SWATCH_X + 4, swatchY + SWATCH_H - 5, TFT_DARKGREY ); + } + + char capabilityText[16]; + + getEffectColorCapabilityText( + effectMode, + capabilityText, + sizeof(capabilityText) + ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( titleColor, backgroundColor ); + + display.setTextSize( 2 ); + + display.drawString( "COLOR", 105, MAIN_BOTTOM_BUTTON_Y + 12 ); + + display.setTextColor( capabilityColor, backgroundColor ); + + display.setTextSize( 1 ); + + display.drawString( capabilityText, 105, MAIN_BOTTOM_BUTTON_Y + 29 ); + + touchState.colorButtonVisualPressed = pressed; + } + + void drawPresetOpenButton( bool pressed ) { + const uint16_t buttonColor = TFT_CYAN; + + uint16_t backgroundColor = pressed ? buttonColor : TFT_BLACK; + + uint16_t textColor = pressed ? TFT_BLACK : TFT_WHITE; + + display.fillRect( PRESET_OPEN_BUTTON_X - 2, MAIN_BOTTOM_BUTTON_Y - 2, PRESET_OPEN_BUTTON_W + 4, MAIN_BOTTOM_BUTTON_H + 4, TFT_BLACK ); + + display.fillRect( PRESET_OPEN_BUTTON_X, MAIN_BOTTOM_BUTTON_Y, PRESET_OPEN_BUTTON_W, MAIN_BOTTOM_BUTTON_H, backgroundColor ); + + display.drawRect( PRESET_OPEN_BUTTON_X, MAIN_BOTTOM_BUTTON_Y, PRESET_OPEN_BUTTON_W, MAIN_BOTTOM_BUTTON_H, buttonColor ); + + display.drawRect( PRESET_OPEN_BUTTON_X + 1, MAIN_BOTTOM_BUTTON_Y + 1, PRESET_OPEN_BUTTON_W - 2, MAIN_BOTTOM_BUTTON_H - 2, buttonColor ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( textColor, backgroundColor ); + + display.setTextSize( 2 ); + + display.drawString( "PRESET", PRESET_OPEN_BUTTON_X + (PRESET_OPEN_BUTTON_W / 2), MAIN_BOTTOM_BUTTON_Y + (MAIN_BOTTOM_BUTTON_H / 2) ); + + touchState.presetOpenButtonVisualPressed = pressed; + } + + void drawBackButton( bool pressed ) { + const uint16_t buttonColor = TFT_CYAN; + + uint16_t backgroundColor = pressed ? buttonColor : TFT_BLACK; + + uint16_t iconColor = pressed ? TFT_BLACK : buttonColor; + + display.fillRect( BACK_BUTTON_X - 2, BACK_BUTTON_Y - 2, BACK_BUTTON_W + 4, BACK_BUTTON_H + 4, TFT_BLACK ); + + display.fillRect( BACK_BUTTON_X, BACK_BUTTON_Y, BACK_BUTTON_W, BACK_BUTTON_H, backgroundColor ); + + display.drawRect( BACK_BUTTON_X, BACK_BUTTON_Y, BACK_BUTTON_W, BACK_BUTTON_H, buttonColor ); + + display.drawRect( BACK_BUTTON_X + 1, BACK_BUTTON_Y + 1, BACK_BUTTON_W - 2, BACK_BUTTON_H - 2, buttonColor ); + + int16_t centerX = BACK_BUTTON_X + (BACK_BUTTON_W / 2); + + int16_t centerY = BACK_BUTTON_Y + (BACK_BUTTON_H / 2); + + display.fillTriangle( centerX - 11, centerY, centerX - 1, centerY - 9, centerX - 1, centerY + 9, iconColor ); + + display.fillRect( centerX - 1, centerY - 2, 13, 5, iconColor ); + + touchState.backButtonVisualPressed = pressed; + } + + void drawColorDetails( uint32_t color ) { + display.fillRect( 0, 60, screenWidth, 78, TFT_BLACK ); + + const M5StackEffectColorCapabilities capability = + getEffectColorCapabilities( getCurrentEffectMode() ); + + const int16_t slotX[3] = { + COLOR_SLOT_1_X, + COLOR_SLOT_2_X, + COLOR_SLOT_3_X + }; + + Segment& mainSegment = strip.getMainSegment(); + + for ( uint8_t colorSlot = 0; colorSlot < 3; colorSlot++ ) { + const bool enabled = + isEffectColorSlotEnabled( capability, colorSlot ); + + const bool selected = + enabled && colorSlot == selectedColorSlot; + + char slotLabel[4]; + + snprintf( slotLabel, sizeof(slotLabel), "C%u", colorSlot + 1 ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( + selected ? TFT_CYAN : ( enabled ? TFT_WHITE : TFT_DARKGREY ), + TFT_BLACK + ); + + display.setTextSize( 1 ); + + display.drawString( + slotLabel, + slotX[ colorSlot ] + ( COLOR_SLOT_W / 2 ), + COLOR_SLOT_LABEL_Y + ); + + if ( enabled ) { + const uint32_t slotColor = mainSegment.colors[ colorSlot ]; + + const uint16_t previewColor = + rgbTo565( R(slotColor), G(slotColor), B(slotColor) ); + + display.fillRect( + slotX[ colorSlot ], + COLOR_SLOT_Y, + COLOR_SLOT_W, + COLOR_SLOT_H, + previewColor + ); + + display.drawRect( + slotX[ colorSlot ], + COLOR_SLOT_Y, + COLOR_SLOT_W, + COLOR_SLOT_H, + selected ? TFT_CYAN : TFT_DARKGREY + ); + + if ( selected ) { + display.drawRect( + slotX[ colorSlot ] + 1, + COLOR_SLOT_Y + 1, + COLOR_SLOT_W - 2, + COLOR_SLOT_H - 2, + TFT_WHITE + ); + } + } + else { + display.fillRect( + slotX[ colorSlot ], + COLOR_SLOT_Y, + COLOR_SLOT_W, + COLOR_SLOT_H, + TFT_BLACK + ); + + display.drawRect( + slotX[ colorSlot ], + COLOR_SLOT_Y, + COLOR_SLOT_W, + COLOR_SLOT_H, + TFT_DARKGREY + ); + + display.drawLine( + slotX[ colorSlot ] + 8, + COLOR_SLOT_Y + 5, + slotX[ colorSlot ] + COLOR_SLOT_W - 9, + COLOR_SLOT_Y + COLOR_SLOT_H - 6, + TFT_DARKGREY + ); + + display.drawLine( + slotX[ colorSlot ] + COLOR_SLOT_W - 9, + COLOR_SLOT_Y + 5, + slotX[ colorSlot ] + 8, + COLOR_SLOT_Y + COLOR_SLOT_H - 6, + TFT_DARKGREY + ); + } + } + + char selectedText[24]; + char customColorLabel[32]; + + const bool hasCustomColorLabel = + getEffectColorSlotCustomLabel( + getCurrentEffectMode(), + selectedColorSlot, + customColorLabel, + sizeof(customColorLabel) + ); + + snprintf( + selectedText, + sizeof(selectedText), + "C%u #%02X%02X%02X", + selectedColorSlot + 1, + R(color), + G(color), + B(color) + ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( + selectedText, + screenWidth / 2, + 116 + ); + + char slotHintText[56]; + + if ( hasCustomColorLabel ) { + snprintf( + slotHintText, + sizeof(slotHintText), + "Role: %s Hold: %s", + customColorLabel, + color == 0 ? "RESTORE" : "BLACK" + ); + + display.setTextColor( TFT_CYAN, TFT_BLACK ); + } + else { + snprintf( + slotHintText, + sizeof(slotHintText), + "Hold: %s", + color == 0 ? "RESTORE" : "BLACK" + ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + } + + display.setTextSize( 1 ); + + display.drawString( + slotHintText, + screenWidth / 2, + 130 + ); + + display.drawFastHLine( 32, 136, screenWidth - 64, TFT_DARKGREY ); + } + + void drawHue( uint8_t hueValue, M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + char valueText[8]; + + snprintf( valueText, sizeof(valueText), "%u", hueValue ); + + drawNumericControl( 138, 56, "Hue", 144, HUE_BUTTON_Y, HUE_BUTTON_Y + (CONTROL_BUTTON_H / 2), valueText, pressedTarget, M5STACK_TOUCH_TARGET_HUE_DOWN, M5STACK_TOUCH_TARGET_HUE_UP ); + } + + void drawSaturation( uint8_t saturationValue, M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + char valueText[8]; + + snprintf( valueText, sizeof(valueText), "%u", saturationValue ); + + drawNumericControl( 194, 46, "Saturation", SATURATION_LABEL_Y, SATURATION_BUTTON_Y, SATURATION_BUTTON_Y + (CONTROL_BUTTON_H / 2), valueText, pressedTarget, M5STACK_TOUCH_TARGET_SATURATION_DOWN, M5STACK_TOUCH_TARGET_SATURATION_UP ); + } + + void drawSpeed( uint8_t speedValue, M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + char label[32]; + + if ( !getEffectSpeedControlMetadata( getCurrentEffectMode(), label, sizeof(label) ) ) { + display.fillRect( 0, 62, screenWidth, 58, TFT_BLACK ); + return; + } + + char valueText[8]; + + snprintf( valueText, sizeof(valueText), "%u", speedValue ); + + drawNumericControl( 62, 58, label, 70, SPEED_BUTTON_Y, 99, valueText, pressedTarget, M5STACK_TOUCH_TARGET_SPEED_DOWN, M5STACK_TOUCH_TARGET_SPEED_UP ); + } + + void drawIntensity( uint8_t intensityValue, M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + char label[32]; + + if ( !getEffectIntensityControlMetadata( getCurrentEffectMode(), label, sizeof(label) ) ) { + display.fillRect( 0, 120, screenWidth, 60, TFT_BLACK ); + return; + } + + char valueText[8]; + + snprintf( valueText, sizeof(valueText), "%u", intensityValue ); + + drawNumericControl( 120, 60, label, 128, INTENSITY_BUTTON_Y, 157, valueText, pressedTarget, M5STACK_TOUCH_TARGET_INTENSITY_DOWN, M5STACK_TOUCH_TARGET_INTENSITY_UP ); + } + + void drawPalette( uint8_t paletteId, M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + display.fillRect( 0, 180, screenWidth, 60, TFT_BLACK ); + + if ( !isEffectPaletteControlVisible( getCurrentEffectMode() ) ) { + return; + } + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "Palette", screenWidth / 2, PALETTE_LABEL_Y ); + + drawTriangleButton( CONTROL_LEFT_X, PALETTE_BUTTON_Y, false, pressedTarget == M5STACK_TOUCH_TARGET_PALETTE_PREV ); + + drawTriangleButton( CONTROL_RIGHT_X, PALETTE_BUTTON_Y, true, pressedTarget == M5STACK_TOUCH_TARGET_PALETTE_NEXT ); + + char paletteName[64]; + + getPaletteName( paletteId, paletteName, sizeof(paletteName) ); + + if ( strlen(paletteName) > 22 ) { + paletteName[22] = '\0'; + } + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + if ( strlen(paletteName) <= 10 ) { + display.setTextSize( 2 ); + } + else { + display.setTextSize( 1 ); + } + + display.drawString( paletteName, screenWidth / 2, PALETTE_BUTTON_Y + (CONTROL_BUTTON_H / 2) ); + } + + void drawEffectPageName( uint8_t effectMode ) { + display.fillRect( 40, 30, 240, 26, TFT_BLACK ); + + char effectName[64]; + char capabilityText[40]; + bool incompatibleWithCurrentSegment = false; + + getEffectName( effectMode, effectName, sizeof(effectName) ); + + getEffectCapabilityText( + effectMode, + capabilityText, + sizeof(capabilityText), + incompatibleWithCurrentSegment + ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( effectName, screenWidth / 2, 36 ); + + display.setTextColor( + ( incompatibleWithCurrentSegment || isAudioUnavailableForEffect( effectMode ) ) + ? TFT_YELLOW + : TFT_CYAN, + TFT_BLACK + ); + + display.drawString( capabilityText, screenWidth / 2, 49 ); + } + + String getPresetDisplayName( uint8_t presetId ) { + if ( presetId == 0 ) { + return "Custom State"; + } + + if ( pendingPresetId == presetId && pendingPresetName.length() > 0 ) { + return pendingPresetName; + } + + String name; + + if ( getCachedPresetName( presetId, name ) ) { + return name; + } + + char fallback[24]; + + snprintf( fallback, sizeof(fallback), "Preset %u", presetId ); + + return String(fallback); + } + + void drawPresetDetails( uint8_t presetId, bool applying = false ) { + display.fillRect( 0, 60, screenWidth, 118, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + + // ------------------------------------------------------- + // Internal Preset ID scan number is intentionally hidden. + // ------------------------------------------------------- + + if ( !presetCacheReady ) { + display.setTextColor( TFT_YELLOW, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "Loading Presets", screenWidth / 2, PRESET_NAME_Y ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "Updating Preset List", screenWidth / 2, PRESET_ID_Y ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "WLED remains active", screenWidth / 2, PRESET_STATUS_Y ); + + return; + } + + if ( presetNoEntries ) { + display.setTextColor( TFT_RED, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "No Presets", screenWidth / 2, PRESET_NAME_Y ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "No saved preset found", screenWidth / 2, PRESET_ID_Y ); + + display.drawString( "Use MANAGE to create", screenWidth / 2, PRESET_STATUS_Y ); + + return; + } + + if ( presetId == 0 ) { + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "Custom State", screenWidth / 2, PRESET_NAME_Y ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "No active preset", screenWidth / 2, PRESET_ID_Y ); + + display.drawString( "Use arrows to apply", screenWidth / 2, PRESET_STATUS_Y ); + + return; + } + + String presetName = getPresetDisplayName( presetId ); + + if ( presetName.length() > 28 ) { + presetName = presetName.substring( 0, 25 ) + "..."; + } + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + if ( presetName.length() <= 12 ) { + display.setTextSize( 2 ); + } + else { + display.setTextSize( 1 ); + } + + display.drawString( presetName, screenWidth / 2, PRESET_NAME_Y ); + + char idText[24]; + + snprintf( idText, sizeof(idText), "Preset ID: %u", presetId ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( idText, screenWidth / 2, PRESET_ID_Y ); + + if (applying) { + display.setTextColor( TFT_YELLOW, TFT_BLACK ); + + display.drawString( "Applying...", screenWidth / 2, PRESET_STATUS_Y ); + } + else if ( currentPreset == presetId ) { + display.setTextColor( TFT_GREEN, TFT_BLACK ); + + display.drawString( "Active Preset", screenWidth / 2, PRESET_STATUS_Y ); + } + else { + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "Saved WLED Preset", screenWidth / 2, PRESET_STATUS_Y ); + } + } + + void drawPresetTextButton( int16_t x, int16_t y, int16_t w, int16_t h, const char* label, bool enabled, bool pressed, uint8_t textSize ) { + uint16_t buttonColor = enabled ? TFT_CYAN : TFT_DARKGREY; + + uint16_t backgroundColor = ( enabled && pressed ) ? buttonColor : TFT_BLACK; + + uint16_t textColor = ( enabled && pressed ) ? TFT_BLACK : ( enabled ? TFT_WHITE : TFT_DARKGREY ); + + display.fillRect( x - 2, y - 2, w + 4, h + 4, TFT_BLACK ); + + display.fillRect( x, y, w, h, backgroundColor ); + + display.drawRect( x, y, w, h, buttonColor ); + + display.drawRect( x + 1, y + 1, w - 2, h - 2, buttonColor ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( textColor, backgroundColor ); + + display.setTextSize( textSize ); + + display.drawString( label, x + (w / 2), y + (h / 2) ); + } + + void drawPresetDangerTextButton( int16_t x, int16_t y, int16_t w, int16_t h, const char* label, bool enabled, bool pressed, uint8_t textSize ) { + uint16_t buttonColor = enabled ? TFT_RED : TFT_DARKGREY; + + uint16_t backgroundColor = ( enabled && pressed ) ? buttonColor : TFT_BLACK; + + uint16_t textColor = ( enabled && pressed ) ? TFT_BLACK : ( enabled ? TFT_RED : TFT_DARKGREY ); + + display.fillRect( x - 2, y - 2, w + 4, h + 4, TFT_BLACK ); + + display.fillRect( x, y, w, h, backgroundColor ); + + display.drawRect( x, y, w, h, buttonColor ); + + display.drawRect( x + 1, y + 1, w - 2, h - 2, buttonColor ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( textColor, backgroundColor ); + + display.setTextSize( textSize ); + + display.drawString( label, x + (w / 2), y + (h / 2) ); + } + + void drawPresetManageButton( bool pressed ) { + bool enabled = ( presetCacheReady && !presetCacheBuilding && pendingPresetId == 0 ); + + const char* label = presetCacheReady ? "MANAGE" : "LOADING"; + + drawPresetTextButton( PRESET_MANAGE_BUTTON_X, PRESET_MANAGE_BUTTON_Y, PRESET_MANAGE_BUTTON_W, PRESET_MANAGE_BUTTON_H, label, enabled, pressed && enabled, 1 ); + + touchState.presetManageButtonVisualPressed = ( pressed && enabled ); + } + + void drawPresetNavigation( M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + display.fillRect( 0, 178, screenWidth, 62, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "Preset", screenWidth / 2, PRESET_NAV_LABEL_Y ); + + drawTriangleButton( CONTROL_LEFT_X, PRESET_NAV_BUTTON_Y, false, pressedTarget == M5STACK_TOUCH_TARGET_PRESET_PREV ); + + drawTriangleButton( CONTROL_RIGHT_X, PRESET_NAV_BUTTON_Y, true, pressedTarget == M5STACK_TOUCH_TARGET_PRESET_NEXT ); + + drawPresetManageButton( pressedTarget == M5STACK_TOUCH_TARGET_PRESET_MANAGE ); + } + + uint16_t getBatteryStatusColor() const { + if ( !batteryStatus.available || !batteryStatus.present ) { + return TFT_DARKGREY; + } + + if ( batteryStatus.level <= 15 ) { + return TFT_RED; + } + + if ( batteryStatus.level <= 35 ) { + return TFT_YELLOW; + } + + return TFT_GREEN; + } + + bool sampleBatteryStatus( bool forceRead = false ) { + const unsigned long now = millis(); + + if ( + !forceRead && + batteryStatusInitialized && + now - lastBatteryStatusRead < BATTERY_STATUS_UPDATE_MS + ) { + return false; + } + + lastBatteryStatusRead = now; + + M5StackBatteryStatus newStatus; + hardwareBackend.readBatteryStatus( newStatus ); + + const bool changed = + !batteryStatusInitialized || + newStatus.available != batteryStatus.available || + newStatus.present != batteryStatus.present || + newStatus.charging != batteryStatus.charging || + newStatus.level != batteryStatus.level; + + batteryStatus = newStatus; + batteryStatusInitialized = true; + + return changed; + } + + void drawMainBatteryStatus() { + display.fillRect( + BATTERY_STATUS_LEFT, + 28, + BATTERY_STATUS_RIGHT - BATTERY_STATUS_LEFT, + 28, + TFT_BLACK + ); + + const uint16_t batteryColor = getBatteryStatusColor(); + + // Battery body + positive terminal. + display.drawRect( + BATTERY_ICON_X, + BATTERY_ICON_Y, + BATTERY_ICON_W, + BATTERY_ICON_H, + batteryColor + ); + + display.fillRect( + BATTERY_ICON_X + BATTERY_ICON_W, + BATTERY_ICON_Y + 3, + 2, + 4, + batteryColor + ); + + if ( batteryStatus.available && batteryStatus.present ) { + const int16_t interiorW = BATTERY_ICON_W - 4; + int16_t fillW = + ( interiorW * batteryStatus.level + 99 ) / 100; + + fillW = constrain( fillW, 0, interiorW ); + + if ( fillW > 0 ) { + display.fillRect( + BATTERY_ICON_X + 2, + BATTERY_ICON_Y + 2, + fillW, + BATTERY_ICON_H - 4, + batteryColor + ); + } + } + + char batteryText[8]; + + if ( batteryStatus.available && batteryStatus.present ) { + snprintf( + batteryText, + sizeof(batteryText), + "%u%%", + batteryStatus.level + ); + } + else { + strlcpy( + batteryText, + "--%", + sizeof(batteryText) + ); + } + + display.setTextDatum( textdatum_t::middle_center ); + display.setTextColor( batteryColor, TFT_BLACK ); + display.setTextSize( 1 ); + + display.drawString( + batteryText, + BATTERY_PERCENT_X, + BATTERY_PERCENT_Y + ); + } + + void drawMainNetworkStatusLine( + const String& networkStatusText, + uint16_t textColor = TFT_WHITE + ) { + display.fillRect( + HEADER_NETWORK_LEFT, + 28, + HEADER_NETWORK_RIGHT - HEADER_NETWORK_LEFT, + 28, + TFT_BLACK + ); + + display.setTextDatum( textdatum_t::middle_center ); + display.setTextColor( textColor, TFT_BLACK ); + display.setTextSize( 1 ); + + display.drawString( + networkStatusText, + HEADER_NETWORK_CENTER_X, + HEADER_IP_Y + ); + } + + void drawMainScreen( const String& networkStatusText ) { + display.fillScreen( TFT_BLACK ); + + currentPage = SCREEN_MAIN; + + readyScreenShown = true; + + connectingScreenShown = false; + + resetTouchGesture(); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "WLED M5Stack CoreS3", HEADER_CENTER_X, HEADER_TITLE_Y ); + + drawMainNetworkStatusLine( networkStatusText ); + + sampleBatteryStatus( true ); + drawMainBatteryStatus(); + + display.drawFastHLine( 8, 58, screenWidth - 16, TFT_DARKGREY ); + + drawPowerButton( bri > 0, false ); + + drawBrightness( bri, M5STACK_TOUCH_TARGET_NONE ); + + uint8_t effectMode = getCurrentEffectMode(); + + drawEffect( effectMode, M5STACK_TOUCH_TARGET_NONE ); + + uint32_t primaryColor = getPrimaryColor(); + + drawColorButton( primaryColor, false ); + + drawPresetOpenButton( false ); + + syncLogicalColorFromRgb( primaryColor ); + + lastLedState = bri > 0 ? 1 : 0; + + lastBrightnessValue = bri; + + lastEffectMode = effectMode; + + lastSpeedValue = getCurrentSpeed(); + + lastIntensityValue = getCurrentIntensity(); + + lastPaletteValue = getCurrentPalette(); + + lastPresetValue = currentPreset; + + lastPrimaryColor = primaryColor; + + lastPrimaryColorValid = true; + } + + void drawColorScreen() { + display.fillScreen( TFT_BLACK ); + + currentPage = SCREEN_COLOR; + + readyScreenShown = true; + + connectingScreenShown = false; + + resetTouchGesture(); + + const uint8_t effectMode = getCurrentEffectMode(); + + const M5StackEffectColorCapabilities colorCapability = + getEffectColorCapabilities( effectMode ); + + char capabilityText[16]; + char subtitleText[32]; + + getEffectColorCapabilityText( + effectMode, + capabilityText, + sizeof(capabilityText) + ); + + snprintf( + subtitleText, + sizeof(subtitleText), + "Effect uses %s", + capabilityText + ); + + const bool anyColorUsed = + effectUsesAnyColor( colorCapability ); + + drawStandardPageHeader( + "COLOR", + subtitleText, + anyColorUsed ? TFT_WHITE : TFT_YELLOW + ); + + drawPowerButton( bri > 0, false ); + + drawBackButton( false ); + + uint32_t primaryColor = getPrimaryColor(); + + if ( !anyColorUsed ) { + selectedColorSlot = 0; + + lastSelectedColorValid = false; + + display.fillRect( 0, 60, screenWidth, 180, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_YELLOW, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "COLOR NOT USED", screenWidth / 2, 104 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( + "This effect does not use color slots.", + screenWidth / 2, + 139 + ); + } + else { + normalizeSelectedColorSlot( effectMode ); + + const uint32_t selectedColor = getSelectedColor(); + + syncLogicalColorFromRgb( selectedColor ); + + lastSelectedColor = selectedColor; + lastSelectedColorValid = true; + + drawColorDetails( selectedColor ); + + drawHue( logicalHueValue, M5STACK_TOUCH_TARGET_NONE ); + + drawSaturation( logicalSaturationValue, M5STACK_TOUCH_TARGET_NONE ); + } + + cacheCurrentColorSlots(); + + lastLedState = bri > 0 ? 1 : 0; + + lastEffectMode = effectMode; + + lastPrimaryColor = primaryColor; + + lastPrimaryColorValid = true; + + lastHueValue = logicalHueValue; + + lastSaturationValue = logicalSaturationValue; + } + + void drawEffectDetailScreen() { + display.fillScreen( TFT_BLACK ); + + currentPage = SCREEN_EFFECT; + + readyScreenShown = true; + + connectingScreenShown = false; + + resetTouchGesture(); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "EFFECT", screenWidth / 2, 18 ); + + uint8_t effectMode = getCurrentEffectMode(); + + drawEffectPageName( effectMode ); + + display.drawFastHLine( 8, 58, screenWidth - 16, TFT_DARKGREY ); + + drawPowerButton( bri > 0, false ); + + drawBackButton( false ); + + uint8_t speedValue = getCurrentSpeed(); + + uint8_t intensityValue = getCurrentIntensity(); + + uint8_t paletteValue = getCurrentPalette(); + + drawSpeed( speedValue, M5STACK_TOUCH_TARGET_NONE ); + + drawIntensity( intensityValue, M5STACK_TOUCH_TARGET_NONE ); + + drawPalette( paletteValue, M5STACK_TOUCH_TARGET_NONE ); + + lastLedState = bri > 0 ? 1 : 0; + + lastEffectMode = effectMode; + + lastSpeedValue = speedValue; + + lastIntensityValue = intensityValue; + + lastPaletteValue = paletteValue; + } + + void drawPresetScreen() { + syncPresetNavigationCursorFromCurrentPreset(); + + normalizePresetNavigationCursor(); + + display.fillScreen( TFT_BLACK ); + + currentPage = SCREEN_PRESET; + + presetSubPage = PRESET_SUBPAGE_NAV; + + presetSaveOperationState = PRESET_SAVE_OP_IDLE; + + presetSaveOperationIsOverwrite = false; + + presetSaveCandidateId = 0; + + presetSaveCandidateName = ""; + + presetOverwriteTargetId = 0; + + presetOverwriteTargetName = ""; + + presetDeleteOperationState = PRESET_DELETE_OP_IDLE; + + presetDeleteTargetId = 0; + + presetDeleteTargetName = ""; + + presetDeleteWasCurrentPreset = false; + presetDeleteWasBootPreset = false; + + presetDeleteResultStartMs = 0; + + presetBootOperationState = PRESET_BOOT_OP_IDLE; + presetBootTargetId = 0; + presetBootTargetName = "NONE"; + presetBootResultStartMs = 0; + + presetSaveHoldStartTime = 0; + + presetSaveHoldTriggered = false; + + presetSaveResultStartMs = 0; + + readyScreenShown = true; + + connectingScreenShown = false; + + resetTouchGesture(); + + drawStandardPageHeader( "PRESET", "Saved WLED Preset" ); + + drawPowerButton( bri > 0, false ); + + drawBackButton( false ); + + uint8_t presetId = getDisplayedPresetId(); + + bool applying = ( pendingPresetId > 0 ); + + drawPresetDetails( presetId, applying ); + + drawPresetNavigation( M5STACK_TOUCH_TARGET_NONE ); + + lastLedState = bri > 0 ? 1 : 0; + + lastPresetValue = presetId; + lastBootPresetValue = bootPreset; + + lastPresetsModifiedTime = presetsModifiedTime; + } + + void drawPresetSaveNewButton( bool pressed ) { + uint8_t freePresetId = findFirstFreePresetId(); + + bool enabled = ( presetCacheReady && !presetCacheBuilding && freePresetId > 0 && pendingPresetId == 0 && !presetNeedsSaving() ); + + char buttonLabel[24]; + + if ( !presetCacheReady || presetCacheBuilding ) { + strlcpy( buttonLabel, "SAVE NEW", sizeof(buttonLabel) ); + } + else if ( freePresetId == 0 ) { + strlcpy( buttonLabel, "SAVE NEW FULL", sizeof(buttonLabel) ); + } + else { + snprintf( + buttonLabel, + sizeof(buttonLabel), + "SAVE NEW #%u", + freePresetId + ); + } + + drawPresetTextButton( + PRESET_SAVE_NEW_BUTTON_X, + PRESET_SAVE_NEW_BUTTON_Y, + PRESET_SAVE_NEW_BUTTON_W, + PRESET_SAVE_NEW_BUTTON_H, + buttonLabel, + enabled, + pressed && enabled, + 2 + ); + + touchState.presetSaveNewButtonVisualPressed = ( pressed && enabled ); + } + + void drawPresetOverwriteOpenButton( bool pressed ) { + bool enabled = ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 && pendingPresetId == 0 && !presetNeedsSaving() ); + + drawPresetTextButton( PRESET_OVERWRITE_BUTTON_X, PRESET_OVERWRITE_BUTTON_Y, PRESET_OVERWRITE_BUTTON_W, PRESET_OVERWRITE_BUTTON_H, "OVERWRITE", enabled, pressed && enabled, 2 ); + + touchState.presetOverwriteOpenButtonVisualPressed = ( pressed && enabled ); + } + + void drawPresetDeleteOpenButton( bool pressed ) { + bool enabled = ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 && pendingPresetId == 0 && !presetNeedsSaving() ); + + drawPresetDangerTextButton( PRESET_DELETE_BUTTON_X, PRESET_DELETE_BUTTON_Y, PRESET_DELETE_BUTTON_W, PRESET_DELETE_BUTTON_H, "DELETE", enabled, pressed && enabled, 2 ); + + touchState.presetDeleteOpenButtonVisualPressed = ( pressed && enabled ); + } + + void drawPresetBootOpenButton( bool pressed ) { + bool enabled = ( presetCacheReady && !presetCacheBuilding && pendingPresetId == 0 && !presetNeedsSaving() ); + + drawPresetTextButton( PRESET_BOOT_BUTTON_X, PRESET_BOOT_BUTTON_Y, PRESET_BOOT_BUTTON_W, PRESET_BOOT_BUTTON_H, "BOOT PRESET", enabled, pressed && enabled, 2 ); + + touchState.presetBootOpenButtonVisualPressed = ( pressed && enabled ); + } + + void drawPresetSaveHoldButton( bool pressed ) { + bool enabled = ( presetSaveOperationState == PRESET_SAVE_OP_IDLE && presetCacheReady && !presetCacheBuilding && presetSaveCandidateId > 0 && findPresetCacheIndex( presetSaveCandidateId ) < 0 && pendingPresetId == 0 && !presetNeedsSaving() ); + + drawPresetTextButton( PRESET_SAVE_HOLD_BUTTON_X, PRESET_SAVE_HOLD_BUTTON_Y, PRESET_SAVE_HOLD_BUTTON_W, PRESET_SAVE_HOLD_BUTTON_H, "HOLD TO SAVE", enabled, pressed && enabled, 2 ); + + touchState.presetSaveHoldButtonVisualPressed = ( pressed && enabled ); + } + + void getPresetManageCurrentStateText( + char* text, + size_t textSize + ) { + if ( text == nullptr || textSize == 0 ) { + return; + } + + text[0] = '\0'; + + if ( pendingPresetId > 0 ) { + snprintf( + text, + textSize, + "Current: Applying Preset #%u", + pendingPresetId + ); + + return; + } + + if ( currentPreset == 0 ) { + strlcpy( + text, + "Current: Custom State", + textSize + ); + + return; + } + + snprintf( + text, + textSize, + "Current: Active Preset #%u", + currentPreset + ); + } + + void drawPresetManageScreen() { + display.fillScreen( TFT_BLACK ); + + currentPage = SCREEN_PRESET; + + presetSubPage = PRESET_SUBPAGE_MANAGE; + + presetSaveOperationState = PRESET_SAVE_OP_IDLE; + + presetSaveOperationIsOverwrite = false; + + presetSaveCandidateId = 0; + + presetSaveCandidateName = ""; + + presetOverwriteTargetId = 0; + + presetOverwriteTargetName = ""; + + presetDeleteOperationState = PRESET_DELETE_OP_IDLE; + + presetDeleteTargetId = 0; + + presetDeleteTargetName = ""; + + presetDeleteWasCurrentPreset = false; + presetDeleteWasBootPreset = false; + + presetDeleteResultStartMs = 0; + + presetBootOperationState = PRESET_BOOT_OP_IDLE; + presetBootTargetId = 0; + presetBootTargetName = "NONE"; + presetBootResultStartMs = 0; + + presetSaveHoldStartTime = 0; + + presetSaveHoldTriggered = false; + + presetSaveResultStartMs = 0; + + readyScreenShown = true; + + connectingScreenShown = false; + + resetTouchGesture(); + + char manageSubtitle[40]; + + getPresetManageCurrentStateText( + manageSubtitle, + sizeof(manageSubtitle) + ); + + drawStandardPageHeader( "PRESET MANAGE", manageSubtitle ); + + drawPowerButton( bri > 0, false ); + + drawBackButton( false ); + + drawPresetSaveNewButton( false ); + + drawPresetOverwriteOpenButton( false ); + + drawPresetDeleteOpenButton( false ); + + drawPresetBootOpenButton( false ); + + lastLedState = bri > 0 ? 1 : 0; + + lastPresetValue = currentPreset; + + lastPresetsModifiedTime = presetsModifiedTime; + } + + // ========================================================= + // SAVE NEW / OVERWRITE operation status + // + // Internal 1..250 cache scan position is intentionally + // hidden from the user. + // ========================================================= + + void drawPresetSaveOperationStatus() { + if ( currentPage != SCREEN_PRESET || ( presetSubPage != PRESET_SUBPAGE_SAVE && presetSubPage != PRESET_SUBPAGE_OVERWRITE ) ) { + return; + } + + display.fillRect( 0, 60, screenWidth, 180, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + + if ( presetSaveOperationState == PRESET_SAVE_OP_WAIT_WLED ) { + display.setTextColor( TFT_YELLOW, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( presetSaveOperationIsOverwrite ? "Overwriting..." : "Saving...", screenWidth / 2, 110 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( presetSaveCandidateName, screenWidth / 2, 142 ); + + char idText[24]; + + snprintf( idText, sizeof(idText), "Preset ID: %u", presetSaveCandidateId ); + + display.drawString( idText, screenWidth / 2, 163 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "Writing WLED Preset", screenWidth / 2, 188 ); + + return; + } + + if ( presetSaveOperationState == PRESET_SAVE_OP_WAIT_CACHE ) { + display.setTextColor( TFT_YELLOW, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "Updating Preset List", screenWidth / 2, 110 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( presetSaveOperationIsOverwrite ? "Verifying Overwrite..." : "Verifying New Preset...", screenWidth / 2, 158 ); + + return; + } + + if ( presetSaveOperationState == PRESET_SAVE_OP_SUCCESS ) { + display.setTextColor( TFT_GREEN, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( presetSaveOperationIsOverwrite ? "OVERWRITTEN" : "SAVED", screenWidth / 2, 104 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( presetSaveCandidateName, screenWidth / 2, 140 ); + + char idText[24]; + + snprintf( idText, sizeof(idText), "Preset ID: %u", presetSaveCandidateId ); + + display.drawString( idText, screenWidth / 2, 165 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "Preset cache verified", screenWidth / 2, 192 ); + + return; + } + + if ( presetSaveOperationState == PRESET_SAVE_OP_FAILED ) { + display.setTextColor( TFT_RED, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( presetSaveOperationIsOverwrite ? "OVERWRITE FAILED" : "SAVE FAILED", screenWidth / 2, 108 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "Preset was not verified", screenWidth / 2, 150 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "Returning to PRESET MANAGE", screenWidth / 2, 180 ); + } + } + + void drawPresetSaveScreen() { + display.fillScreen( TFT_BLACK ); + + currentPage = SCREEN_PRESET; + + presetSubPage = PRESET_SUBPAGE_SAVE; + + if ( presetSaveOperationState == PRESET_SAVE_OP_IDLE ) { + presetSaveOperationIsOverwrite = false; + } + + readyScreenShown = true; + + connectingScreenShown = false; + + resetTouchGesture(); + + drawStandardPageHeader( "SAVE PRESET", "Current WLED State" ); + + drawPowerButton( bri > 0, false ); + + drawBackButton( false ); + + if ( presetSaveOperationState != PRESET_SAVE_OP_IDLE ) { + drawPresetSaveOperationStatus(); + return; + } + + if ( presetSaveCandidateId == 0 ) { + display.setTextColor( TFT_RED, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "No Free ID", screenWidth / 2, 110 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "Preset IDs 1-250 are unavailable", screenWidth / 2, 150 ); + + return; + } + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + if ( presetSaveCandidateName.length() <= 18 ) { + display.setTextSize( 2 ); + } + else { + display.setTextSize( 1 ); + } + + display.drawString( presetSaveCandidateName, screenWidth / 2, 88 ); + + char idText[24]; + + snprintf( idText, sizeof(idText), "Preset ID: %u", presetSaveCandidateId ); + + display.setTextSize( 1 ); + + display.drawString( idText, screenWidth / 2, 120 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "Hold 1 second to save", screenWidth / 2, 151 ); + + drawPresetSaveHoldButton( false ); + + lastLedState = bri > 0 ? 1 : 0; + } + + void drawPresetOverwriteNavigation( M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + display.fillRect( 0, 112, screenWidth, 58, TFT_BLACK ); + + drawTriangleButton( CONTROL_LEFT_X, PRESET_OVERWRITE_NAV_BUTTON_Y, false, pressedTarget == M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_PREV ); + + drawTriangleButton( CONTROL_RIGHT_X, PRESET_OVERWRITE_NAV_BUTTON_Y, true, pressedTarget == M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_NEXT ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "TARGET", screenWidth / 2, PRESET_OVERWRITE_NAV_BUTTON_Y + (CONTROL_BUTTON_H / 2) ); + } + + void drawPresetOverwriteHoldButton( bool pressed ) { + bool enabled = ( presetSaveOperationState == PRESET_SAVE_OP_IDLE && presetCacheReady && !presetCacheBuilding && presetOverwriteTargetId > 0 && findPresetCacheIndex( presetOverwriteTargetId ) >= 0 && pendingPresetId == 0 && !presetNeedsSaving() ); + + drawPresetTextButton( PRESET_OVERWRITE_HOLD_BUTTON_X, PRESET_OVERWRITE_HOLD_BUTTON_Y, PRESET_OVERWRITE_HOLD_BUTTON_W, PRESET_OVERWRITE_HOLD_BUTTON_H, "HOLD TO OVERWRITE", enabled, pressed && enabled, 1 ); + + touchState.presetOverwriteHoldButtonVisualPressed = ( pressed && enabled ); + } + + void drawPresetOverwriteScreen() { + display.fillScreen( TFT_BLACK ); + + currentPage = SCREEN_PRESET; + + presetSubPage = PRESET_SUBPAGE_OVERWRITE; + + readyScreenShown = true; + + connectingScreenShown = false; + + resetTouchGesture(); + + drawStandardPageHeader( "OVERWRITE PRESET", "Select destination only" ); + + drawPowerButton( bri > 0, false ); + + drawBackButton( false ); + + if ( presetSaveOperationState != PRESET_SAVE_OP_IDLE ) { + drawPresetSaveOperationStatus(); + return; + } + + if ( presetOverwriteTargetId == 0 || findPresetCacheIndex( presetOverwriteTargetId ) < 0 ) { + display.setTextColor( TFT_RED, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "No Preset", screenWidth / 2, 104 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "Nothing can be overwritten", screenWidth / 2, 145 ); + + return; + } + + String displayName = presetOverwriteTargetName; + + if ( displayName.length() > 28 ) { + displayName = displayName.substring( 0, 25 ) + "..."; + } + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + if ( displayName.length() <= 12 ) { + display.setTextSize( 2 ); + } + else { + display.setTextSize( 1 ); + } + + display.drawString( displayName, screenWidth / 2, 82 ); + + char idText[24]; + + snprintf( idText, sizeof(idText), "Preset ID: %u", presetOverwriteTargetId ); + + display.setTextSize( 1 ); + + display.drawString( idText, screenWidth / 2, 105 ); + + drawPresetOverwriteNavigation( M5STACK_TOUCH_TARGET_NONE ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "Current WLED State", screenWidth / 2, 174 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "will replace this Preset", screenWidth / 2, 187 ); + + drawPresetOverwriteHoldButton( false ); + + lastLedState = bri > 0 ? 1 : 0; + } + + void drawPresetDeleteNavigation( M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + display.fillRect( 0, 112, screenWidth, 58, TFT_BLACK ); + + drawTriangleButton( CONTROL_LEFT_X, PRESET_DELETE_NAV_BUTTON_Y, false, pressedTarget == M5STACK_TOUCH_TARGET_PRESET_DELETE_PREV ); + + drawTriangleButton( CONTROL_RIGHT_X, PRESET_DELETE_NAV_BUTTON_Y, true, pressedTarget == M5STACK_TOUCH_TARGET_PRESET_DELETE_NEXT ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_RED, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "TARGET", screenWidth / 2, PRESET_DELETE_NAV_BUTTON_Y + (CONTROL_BUTTON_H / 2) ); + } + + void drawPresetDeleteHoldButton( bool pressed ) { + bool enabled = ( presetDeleteOperationState == PRESET_DELETE_OP_IDLE && presetCacheReady && !presetCacheBuilding && presetDeleteTargetId > 0 && findPresetCacheIndex( presetDeleteTargetId ) >= 0 && pendingPresetId == 0 && !presetNeedsSaving() ); + + drawPresetDangerTextButton( PRESET_DELETE_HOLD_BUTTON_X, PRESET_DELETE_HOLD_BUTTON_Y, PRESET_DELETE_HOLD_BUTTON_W, PRESET_DELETE_HOLD_BUTTON_H, "HOLD TO DELETE", enabled, pressed && enabled, 1 ); + + touchState.presetDeleteHoldButtonVisualPressed = ( pressed && enabled ); + } + + // ========================================================= + // DELETE operation status + // + // Internal cache scan position is not displayed. + // ========================================================= + + void drawPresetDeleteOperationStatus() { + if ( currentPage != SCREEN_PRESET || presetSubPage != PRESET_SUBPAGE_DELETE ) { + return; + } + + display.fillRect( 0, 60, screenWidth, 180, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + + if ( presetDeleteOperationState == PRESET_DELETE_OP_WAIT_CACHE ) { + display.setTextColor( TFT_YELLOW, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "Updating Preset List", screenWidth / 2, 105 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( presetDeleteTargetName, screenWidth / 2, 145 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "Verifying Deletion...", screenWidth / 2, 175 ); + + return; + } + + if ( presetDeleteOperationState == PRESET_DELETE_OP_SUCCESS ) { + display.setTextColor( TFT_GREEN, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "DELETED", screenWidth / 2, 104 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( presetDeleteTargetName, screenWidth / 2, 140 ); + + char idText[24]; + + snprintf( idText, sizeof(idText), "Preset ID: %u", presetDeleteTargetId ); + + display.drawString( idText, screenWidth / 2, 165 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "Preset cache verified", screenWidth / 2, 192 ); + + return; + } + + if ( presetDeleteOperationState == PRESET_DELETE_OP_FAILED ) { + display.setTextColor( TFT_RED, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "DELETE FAILED", screenWidth / 2, 108 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "Preset still exists", screenWidth / 2, 150 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "Returning to PRESET MANAGE", screenWidth / 2, 180 ); + } + } + + void drawPresetDeleteScreen() { + display.fillScreen( TFT_BLACK ); + + currentPage = SCREEN_PRESET; + + presetSubPage = PRESET_SUBPAGE_DELETE; + + readyScreenShown = true; + + connectingScreenShown = false; + + resetTouchGesture(); + + drawStandardPageHeader( "DELETE PRESET", "Select target only", TFT_RED ); + + drawPowerButton( bri > 0, false ); + + drawBackButton( false ); + + if ( presetDeleteOperationState != PRESET_DELETE_OP_IDLE ) { + drawPresetDeleteOperationStatus(); + return; + } + + if ( presetDeleteTargetId == 0 || findPresetCacheIndex( presetDeleteTargetId ) < 0 ) { + display.setTextColor( TFT_RED, TFT_BLACK ); + + display.setTextSize( 2 ); + + display.drawString( "No Preset", screenWidth / 2, 104 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "Nothing can be deleted", screenWidth / 2, 145 ); + + return; + } + + String displayName = presetDeleteTargetName; + + if ( displayName.length() > 28 ) { + displayName = displayName.substring( 0, 25 ) + "..."; + } + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + + if ( displayName.length() <= 12 ) { + display.setTextSize( 2 ); + } + else { + display.setTextSize( 1 ); + } + + display.drawString( displayName, screenWidth / 2, 82 ); + + char idText[24]; + + snprintf( idText, sizeof(idText), "Preset ID: %u", presetDeleteTargetId ); + + display.setTextSize( 1 ); + + display.drawString( idText, screenWidth / 2, 105 ); + + drawPresetDeleteNavigation( M5STACK_TOUCH_TARGET_NONE ); + + display.setTextColor( TFT_RED, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "Delete this Preset", screenWidth / 2, 174 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + + display.drawString( "Current LED state is kept", screenWidth / 2, 187 ); + + drawPresetDeleteHoldButton( false ); + + lastLedState = bri > 0 ? 1 : 0; + } + + void drawPresetBootNavigation( M5StackTouchTarget pressedTarget = M5STACK_TOUCH_TARGET_NONE ) { + display.fillRect( 0, 112, screenWidth, 58, TFT_BLACK ); + + drawTriangleButton( CONTROL_LEFT_X, PRESET_BOOT_NAV_BUTTON_Y, false, pressedTarget == M5STACK_TOUCH_TARGET_PRESET_BOOT_PREV ); + + drawTriangleButton( CONTROL_RIGHT_X, PRESET_BOOT_NAV_BUTTON_Y, true, pressedTarget == M5STACK_TOUCH_TARGET_PRESET_BOOT_NEXT ); + + display.setTextDatum( textdatum_t::middle_center ); + + display.setTextColor( TFT_CYAN, TFT_BLACK ); + + display.setTextSize( 1 ); + + display.drawString( "BOOT TARGET", screenWidth / 2, PRESET_BOOT_NAV_BUTTON_Y + (CONTROL_BUTTON_H / 2) ); + } + + bool isPresetBootTargetValid() { + return presetBootTargetId == 0 || findPresetCacheIndex( presetBootTargetId ) >= 0; + } + + bool isPresetBootTargetCurrent() { + return isPresetBootTargetValid() && presetBootTargetId == bootPreset; + } + + void drawPresetBootHoldButton( bool pressed ) { + bool enabled = ( presetBootOperationState == PRESET_BOOT_OP_IDLE && presetCacheReady && !presetCacheBuilding && pendingPresetId == 0 && !presetNeedsSaving() && isPresetBootTargetValid() && !isPresetBootTargetCurrent() ); + + const char* label; + + if ( isPresetBootTargetCurrent() ) { + label = ( presetBootTargetId == 0 ) ? "BOOT ALREADY NONE" : "CURRENT BOOT PRESET"; + } + else { + label = ( presetBootTargetId == 0 ) ? "HOLD TO CLEAR BOOT" : "HOLD TO SET BOOT"; + } + + drawPresetTextButton( PRESET_BOOT_HOLD_BUTTON_X, PRESET_BOOT_HOLD_BUTTON_Y, PRESET_BOOT_HOLD_BUTTON_W, PRESET_BOOT_HOLD_BUTTON_H, label, enabled, pressed && enabled, 1 ); + + touchState.presetBootHoldButtonVisualPressed = ( pressed && enabled ); + } + + void drawPresetBootOperationStatus() { + if ( currentPage != SCREEN_PRESET || presetSubPage != PRESET_SUBPAGE_BOOT ) { + return; + } + + display.fillRect( 0, 60, screenWidth, 180, TFT_BLACK ); + + display.setTextDatum( textdatum_t::middle_center ); + + if ( presetBootOperationState == PRESET_BOOT_OP_WAIT_CONFIG ) { + display.setTextColor( TFT_YELLOW, TFT_BLACK ); + display.setTextSize( 2 ); + display.drawString( "Saving Boot Setting", screenWidth / 2, 105 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + display.setTextSize( 1 ); + display.drawString( presetBootTargetId == 0 ? "NONE" : presetBootTargetName, screenWidth / 2, 145 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + display.drawString( "Writing WLED Config...", screenWidth / 2, 175 ); + + return; + } + + if ( presetBootOperationState == PRESET_BOOT_OP_SUCCESS ) { + display.setTextColor( TFT_GREEN, TFT_BLACK ); + display.setTextSize( 2 ); + display.drawString( presetBootTargetId == 0 ? "BOOT CLEARED" : "BOOT PRESET SET", screenWidth / 2, 104 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + display.setTextSize( 1 ); + display.drawString( presetBootTargetId == 0 ? "NONE" : presetBootTargetName, screenWidth / 2, 140 ); + + if ( presetBootTargetId > 0 ) { + char idText[24]; + snprintf( idText, sizeof(idText), "Preset ID: %u", presetBootTargetId ); + display.drawString( idText, screenWidth / 2, 165 ); + } + else { + display.drawString( "No startup Preset", screenWidth / 2, 165 ); + } + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + display.drawString( "WLED config saved", screenWidth / 2, 192 ); + + return; + } + + if ( presetBootOperationState == PRESET_BOOT_OP_FAILED ) { + display.setTextColor( TFT_RED, TFT_BLACK ); + display.setTextSize( 2 ); + display.drawString( "BOOT SET FAILED", screenWidth / 2, 108 ); + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + display.setTextSize( 1 ); + display.drawString( "Setting was not verified", screenWidth / 2, 150 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + display.drawString( "Returning to PRESET MANAGE", screenWidth / 2, 180 ); + } + } + + void drawPresetBootScreen() { + display.fillScreen( TFT_BLACK ); + + currentPage = SCREEN_PRESET; + presetSubPage = PRESET_SUBPAGE_BOOT; + readyScreenShown = true; + connectingScreenShown = false; + + resetTouchGesture(); + + drawStandardPageHeader( "BOOT PRESET", "Startup Preset" ); + + drawPowerButton( bri > 0, false ); + drawBackButton( false ); + + if ( presetBootOperationState != PRESET_BOOT_OP_IDLE ) { + drawPresetBootOperationStatus(); + return; + } + + if ( !presetCacheReady || presetCacheBuilding ) { + display.setTextColor( TFT_YELLOW, TFT_BLACK ); + display.setTextSize( 2 ); + display.drawString( "Loading Presets", screenWidth / 2, 104 ); + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + display.setTextSize( 1 ); + display.drawString( "Updating Preset List", screenWidth / 2, 145 ); + return; + } + + if ( presetBootTargetId > 0 && findPresetCacheIndex( presetBootTargetId ) < 0 ) { + presetBootTargetId = 0; + presetBootTargetName = "NONE"; + } + + String displayName = ( presetBootTargetId == 0 ) ? String("NONE") : presetBootTargetName; + + if ( displayName.length() > 28 ) { + displayName = displayName.substring( 0, 25 ) + "..."; + } + + display.setTextColor( TFT_WHITE, TFT_BLACK ); + display.setTextSize( displayName.length() <= 12 ? 2 : 1 ); + display.drawString( displayName, screenWidth / 2, 82 ); + + display.setTextSize( 1 ); + + if ( presetBootTargetId == 0 ) { + display.drawString( "No startup Preset", screenWidth / 2, 105 ); + } + else { + char idText[24]; + snprintf( idText, sizeof(idText), "Preset ID: %u", presetBootTargetId ); + display.drawString( idText, screenWidth / 2, 105 ); + } + + drawPresetBootNavigation( M5STACK_TOUCH_TARGET_NONE ); + + if ( presetBootTargetId == bootPreset && ( bootPreset == 0 || findPresetCacheIndex( bootPreset ) >= 0 ) ) { + display.setTextColor( TFT_GREEN, TFT_BLACK ); + display.drawString( bootPreset == 0 ? "Boot Preset Disabled" : "Current Boot Preset", screenWidth / 2, 174 ); + } + else if ( bootPreset > 0 && findPresetCacheIndex( bootPreset ) < 0 && presetBootTargetId == 0 ) { + char missingText[40]; + snprintf( missingText, sizeof(missingText), "Configured Boot ID %u missing", bootPreset ); + display.setTextColor( TFT_RED, TFT_BLACK ); + display.drawString( missingText, screenWidth / 2, 174 ); + } + else { + display.setTextColor( TFT_WHITE, TFT_BLACK ); + display.drawString( "Set this at startup", screenWidth / 2, 174 ); + } + + display.setTextColor( TFT_DARKGREY, TFT_BLACK ); + display.drawString( "Current LED state is kept", screenWidth / 2, 187 ); + + drawPresetBootHoldButton( false ); + + lastBootPresetValue = bootPreset; + lastLedState = bri > 0 ? 1 : 0; + } + + + void beginHueEdit() { + uint32_t currentColor = getSelectedColor(); + + if ( !logicalColorHsvValid || !lastSelectedColorValid || currentColor != lastSelectedColor ) { + syncLogicalColorFromRgb( currentColor ); + + lastSelectedColor = currentColor; + + lastSelectedColorValid = true; + } + + hueEditHsv = logicalColorHsv; + + // A true black WLED color converts to HSV with V=0. CoreS3 intentionally + // has no per-color Value control, so changing Hue alone could never leave + // black. Once the user starts editing a black slot, bootstrap the edit + // state to a visible fully-saturated color. The stored WLED color is not + // changed until the first actual Hue step is applied. + if ( currentColor == 0 ) { + hueEditHsv.s = 255; + hueEditHsv.v = 255; + } + + hueEditValue = logicalHueValue; + + hueEditWhite = logicalWhiteValue; + + hueEditValid = true; + } + + void beginSaturationEdit() { + uint32_t currentColor = getSelectedColor(); + + if ( !logicalColorHsvValid || !lastSelectedColorValid || currentColor != lastSelectedColor ) { + syncLogicalColorFromRgb( currentColor ); + + lastSelectedColor = currentColor; + + lastSelectedColorValid = true; + } + + saturationEditHsv = logicalColorHsv; + + // Saturation also cannot make a true black color visible while HSV V is + // zero. Bootstrap only the edit Value; the requested Saturation remains + // under direct user control. + if ( currentColor == 0 ) { + saturationEditHsv.v = 255; + } + + saturationEditValue = logicalSaturationValue; + + saturationEditWhite = logicalWhiteValue; + + saturationEditValid = true; + } + + void resetTouchGesture() { + touchState.touchActive = false; + + touchState.touchTarget = M5STACK_TOUCH_TARGET_NONE; + + touchState.lastTouchInsidePower = false; + + touchState.lastTouchInsideWiFiRecovery = false; + + touchState.lastTouchInsideBrightness = false; + + touchState.lastTouchInsideEffect = false; + + touchState.lastTouchInsideEffectDetail = false; + + touchState.lastTouchInsideColor = false; + + touchState.lastTouchInsidePresetOpen = false; + + touchState.lastTouchInsideBack = false; + + touchState.lastTouchInsideColorSlot = false; + + touchState.lastTouchInsideHue = false; + + touchState.lastTouchInsideSaturation = false; + + touchState.lastTouchInsideSpeed = false; + + touchState.lastTouchInsideIntensity = false; + + touchState.lastTouchInsidePalette = false; + + touchState.lastTouchInsidePresetNav = false; + + touchState.lastTouchInsidePresetManage = false; + + touchState.lastTouchInsidePresetSaveNew = false; + + touchState.lastTouchInsidePresetSaveHold = false; + + touchState.lastTouchInsidePresetOverwriteOpen = false; + + touchState.lastTouchInsidePresetOverwriteNav = false; + + touchState.lastTouchInsidePresetOverwriteHold = false; + + touchState.lastTouchInsidePresetDeleteOpen = false; + + touchState.lastTouchInsidePresetDeleteNav = false; + + touchState.lastTouchInsidePresetDeleteHold = false; + + touchState.lastTouchInsidePresetBootOpen = false; + touchState.lastTouchInsidePresetBootNav = false; + touchState.lastTouchInsidePresetBootHold = false; + + touchState.powerButtonVisualPressed = false; + + touchState.wifiRecoveryVisualPressed = false; + + touchState.brightnessButtonVisualPressed = false; + + touchState.effectButtonVisualPressed = false; + + touchState.effectDetailVisualPressed = false; + + touchState.colorButtonVisualPressed = false; + + touchState.presetOpenButtonVisualPressed = false; + + touchState.backButtonVisualPressed = false; + + touchState.hueButtonVisualPressed = false; + + touchState.saturationButtonVisualPressed = false; + + touchState.speedButtonVisualPressed = false; + + touchState.intensityButtonVisualPressed = false; + + touchState.paletteButtonVisualPressed = false; + + touchState.presetNavButtonVisualPressed = false; + + touchState.presetManageButtonVisualPressed = false; + + touchState.presetSaveNewButtonVisualPressed = false; + + touchState.presetSaveHoldButtonVisualPressed = false; + + touchState.presetOverwriteOpenButtonVisualPressed = false; + + touchState.presetOverwriteNavButtonVisualPressed = false; + + touchState.presetOverwriteHoldButtonVisualPressed = false; + + touchState.presetDeleteOpenButtonVisualPressed = false; + + touchState.presetDeleteNavButtonVisualPressed = false; + + touchState.presetDeleteHoldButtonVisualPressed = false; + + touchState.presetBootOpenButtonVisualPressed = false; + touchState.presetBootNavButtonVisualPressed = false; + touchState.presetBootHoldButtonVisualPressed = false; + + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.wifiRecoveryHoldState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.brightnessRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.effectRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.colorSlotHoldState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.hueRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.saturationRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.speedRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.intensityRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.paletteRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.presetRepeatState ); + + hueEditValid = false; + + saturationEditValid = false; + + touchState.touchReleaseCandidate = 0; + + presetSaveHoldStartTime = 0; + + presetSaveHoldTriggered = false; + + touchState.lastTouchX = -1; + + touchState.lastTouchY = -1; + } + + void toggleLedPowerFromTouch() { + toggleOnOff(); + + stateUpdated( CALL_MODE_BUTTON ); + + lastLedState = -1; + + lastBrightnessValue = -1; + } + + bool applyBrightnessValue( int newValue ) { + newValue = constrain( newValue, 0, 255 ); + + if ( newValue == bri ) { + return false; + } + + if ( newValue == 0 ) { + if ( bri > 0 ) { + briLast = bri; + + bri = 0; + } + } + else { + if ( bri == 0 ) { + strip.restartRuntime(); + } + + bri = (uint8_t)newValue; + } + + stateUpdated( CALL_MODE_BUTTON ); + + lastLedState = -1; + + return true; + } + + void applyBrightnessStep( int step ) { + int newValue = constrain( (int)bri + step, 0, 255 ); + + if ( applyBrightnessValue( newValue ) ) { + if ( currentPage == SCREEN_MAIN ) { + drawBrightness( bri, touchState.touchTarget ); + } + + lastBrightnessValue = bri; + } + } + + void brightnessShortPress( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_BRIGHTNESS_DOWN ) { + applyBrightnessStep( -BRI_SHORT_STEP ); + } + else if ( target == M5STACK_TOUCH_TARGET_BRIGHTNESS_UP ) { + applyBrightnessStep( BRI_SHORT_STEP ); + } + } + + void brightnessLongPressStep( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_BRIGHTNESS_DOWN ) { + applyBrightnessStep( -BRI_LONG_STEP ); + } + else if ( target == M5STACK_TOUCH_TARGET_BRIGHTNESS_UP ) { + applyBrightnessStep( BRI_LONG_STEP ); + } + } + + void applyEffectStep( int step ) { + uint8_t modeCount = strip.getModeCount(); + + if ( modeCount == 0 || step == 0 ) { + return; + } + + Segment& mainSegment = strip.getMainSegment(); + + const int direction = ( step < 0 ) ? -1 : 1; + + int newMode = mainSegment.mode; + + bool validModeFound = false; + + for ( uint16_t attempt = 0; attempt < modeCount; attempt++ ) { + newMode += direction; + + if ( newMode < 0 ) { + newMode = modeCount - 1; + } + + if ( newMode >= modeCount ) { + newMode = 0; + } + + const char* modeData = strip.getModeData( (uint8_t)newMode ); + + if ( modeData != nullptr && strncmp_P( "RSVD", modeData, 4 ) != 0 ) { + validModeFound = true; + break; + } + } + + if ( !validModeFound || newMode == mainSegment.mode ) { + return; + } + + mainSegment.setMode( (uint8_t)newMode ); + + stateUpdated( CALL_MODE_BUTTON ); + + if ( currentPage == SCREEN_MAIN ) { + drawEffect( mainSegment.mode, touchState.touchTarget ); + + drawColorButton( getPrimaryColor(), false ); + } + + lastEffectMode = mainSegment.mode; + + lastSpeedValue = mainSegment.speed; + + lastIntensityValue = mainSegment.intensity; + + lastPaletteValue = mainSegment.palette; + } + + void effectLongPressStep( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_EFFECT_PREV ) { + applyEffectStep( -1 ); + } + else if ( target == M5STACK_TOUCH_TARGET_EFFECT_NEXT ) { + applyEffectStep( 1 ); + } + } + + bool applySpeedValue( int newValue ) { + if ( strip.getSegmentsNum() == 0 ) { + return false; + } + + newValue = constrain( newValue, 0, 255 ); + + Segment& mainSegment = strip.getMainSegment(); + + if ( newValue == mainSegment.speed ) { + return false; + } + + mainSegment.speed = (uint8_t)newValue; + + stateUpdated( CALL_MODE_BUTTON ); + + if ( currentPage == SCREEN_EFFECT ) { + drawSpeed( mainSegment.speed, touchState.touchTarget ); + } + + lastSpeedValue = mainSegment.speed; + + return true; + } + + void applySpeedStep( int step ) { + int newValue = constrain( (int)getCurrentSpeed() + step, 0, 255 ); + + applySpeedValue( newValue ); + } + + void speedShortPress( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_SPEED_DOWN ) { + applySpeedStep( -SPEED_SHORT_STEP ); + } + else if ( target == M5STACK_TOUCH_TARGET_SPEED_UP ) { + applySpeedStep( SPEED_SHORT_STEP ); + } + } + + void speedLongPressStep( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_SPEED_DOWN ) { + applySpeedStep( -SPEED_LONG_STEP ); + } + else if ( target == M5STACK_TOUCH_TARGET_SPEED_UP ) { + applySpeedStep( SPEED_LONG_STEP ); + } + } + + bool applyIntensityValue( int newValue ) { + if ( strip.getSegmentsNum() == 0 ) { + return false; + } + + newValue = constrain( newValue, 0, 255 ); + + Segment& mainSegment = strip.getMainSegment(); + + if ( newValue == mainSegment.intensity ) { + return false; + } + + mainSegment.intensity = (uint8_t)newValue; + + stateUpdated( CALL_MODE_BUTTON ); + + if ( currentPage == SCREEN_EFFECT ) { + drawIntensity( mainSegment.intensity, touchState.touchTarget ); + } + + lastIntensityValue = mainSegment.intensity; + + return true; + } + + void applyIntensityStep( int step ) { + int newValue = constrain( (int)getCurrentIntensity() + step, 0, 255 ); + + applyIntensityValue( newValue ); + } + + void intensityShortPress( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_INTENSITY_DOWN ) { + applyIntensityStep( -INTENSITY_SHORT_STEP ); + } + else if ( target == M5STACK_TOUCH_TARGET_INTENSITY_UP ) { + applyIntensityStep( INTENSITY_SHORT_STEP ); + } + } + + void intensityLongPressStep( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_INTENSITY_DOWN ) { + applyIntensityStep( -INTENSITY_LONG_STEP ); + } + else if ( target == M5STACK_TOUCH_TARGET_INTENSITY_UP ) { + applyIntensityStep( INTENSITY_LONG_STEP ); + } + } + + bool applyPaletteValue( uint8_t newPalette ) { + if ( strip.getSegmentsNum() == 0 ) { + return false; + } + + Segment& mainSegment = strip.getMainSegment(); + + if ( newPalette == mainSegment.palette ) { + return false; + } + + mainSegment.setPalette( newPalette ); + + stateUpdated( CALL_MODE_BUTTON ); + + if ( currentPage == SCREEN_EFFECT ) { + drawPalette( mainSegment.palette, touchState.touchTarget ); + } + + lastPaletteValue = mainSegment.palette; + + return true; + } + + void applyPaletteStep( int step ) { + size_t paletteCount = getSelectablePaletteCount(); + + if ( paletteCount == 0 ) { + return; + } + + uint8_t currentPalette = getCurrentPalette(); + + int currentIndex = findPaletteSequenceIndex( currentPalette ); + + if ( currentIndex < 0 ) { + currentIndex = 0; + } + + int newIndex = currentIndex + step; + + while ( newIndex < 0 ) { + newIndex += (int)paletteCount; + } + + while ( newIndex >= (int)paletteCount ) { + newIndex -= (int)paletteCount; + } + + uint8_t newPalette = paletteIdFromSequenceIndex( (size_t)newIndex ); + + applyPaletteValue( newPalette ); + } + + void paletteShortPress( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_PALETTE_PREV ) { + applyPaletteStep( -1 ); + } + else if ( target == M5STACK_TOUCH_TARGET_PALETTE_NEXT ) { + applyPaletteStep( 1 ); + } + } + + void paletteLongPressStep( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_PALETTE_PREV ) { + applyPaletteStep( -1 ); + } + else if ( target == M5STACK_TOUCH_TARGET_PALETTE_NEXT ) { + applyPaletteStep( 1 ); + } + } + + bool applyPresetStep( int direction ) { + if ( direction == 0 ) { + return false; + } + + if ( !presetCacheReady ) { + if ( currentPage == SCREEN_PRESET ) { + drawPresetDetails( getDisplayedPresetId(), false ); + + drawPresetNavigation( M5STACK_TOUCH_TARGET_NONE ); + } + + Serial.println( F( "[CoreS3_Display] " "Preset cache not ready" ) ); + + return false; + } + + uint8_t basePreset = getPresetNavigationBaseId(); + + String presetName; + + uint8_t newPreset = findAdjacentPreset( basePreset, direction, &presetName ); + + if ( newPreset == 0 ) { + presetNoEntries = true; + + pendingPresetId = 0; + + pendingPresetName = ""; + + pendingPresetRequestMs = 0; + + if ( currentPage == SCREEN_PRESET ) { + drawPresetDetails( 0, false ); + + drawPresetNavigation( touchState.touchTarget ); + } + + lastPresetValue = 0; + + return false; + } + + presetNoEntries = false; + + // Remember the CoreS3 navigation position immediately. This keeps the + // selected Preset as the UI starting point even if WLED later enters + // Custom State after a Color/Effect adjustment. + presetNavigationCursorId = newPreset; + + pendingPresetId = newPreset; + + pendingPresetName = presetName; + + pendingPresetRequestMs = millis(); + + applyPreset( newPreset, CALL_MODE_BUTTON_PRESET ); + + if ( currentPage == SCREEN_PRESET ) { + drawPresetDetails( newPreset, true ); + + drawPresetNavigation( touchState.touchTarget ); + } + + lastPresetValue = newPreset; + + Serial.printf( "[CoreS3_Display] " "Preset cache request: %u (%s)\n", newPreset, presetName.c_str() ); + + return true; + } + + void presetShortPress( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_PRESET_PREV ) { + applyPresetStep( -1 ); + } + else if ( target == M5STACK_TOUCH_TARGET_PRESET_NEXT ) { + applyPresetStep( 1 ); + } + } + + void presetLongPressStep( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_PRESET_PREV ) { + applyPresetStep( -1 ); + } + else if ( target == M5STACK_TOUCH_TARGET_PRESET_NEXT ) { + applyPresetStep( 1 ); + } + } + + bool applyHueValue( uint8_t newHue ) { + if ( strip.getSegmentsNum() == 0 ) { + return false; + } + + if (!hueEditValid) { + beginHueEdit(); + } + + if (!hueEditValid) { + return false; + } + + hueEditValue = newHue; + + hueEditHsv.h = ((uint16_t)newHue) << 8; + + logicalHueValue = newHue; + + logicalColorHsv = hueEditHsv; + + logicalSaturationValue = logicalColorHsv.s; + + logicalWhiteValue = hueEditWhite; + + logicalColorHsvValid = true; + + CRGBW newRgb; + + hsv2rgb_spectrum( logicalColorHsv, newRgb ); + + newRgb.w = logicalWhiteValue; + + uint32_t newColor = newRgb.color32; + + Segment& mainSegment = strip.getMainSegment(); + + if ( newColor != mainSegment.colors[ selectedColorSlot ] ) { + mainSegment.setColor( selectedColorSlot, newColor ); + + stateUpdated( CALL_MODE_BUTTON ); + } + + if ( currentPage == SCREEN_COLOR ) { + drawColorDetails( newColor ); + + drawHue( logicalHueValue, touchState.touchTarget ); + + drawSaturation( logicalSaturationValue, M5STACK_TOUCH_TARGET_NONE ); + } + + lastSelectedColor = newColor; + lastSelectedColorValid = true; + + if ( selectedColorSlot == 0 ) { + lastPrimaryColor = newColor; + lastPrimaryColorValid = true; + } + + cacheCurrentColorSlots(); + + lastHueValue = logicalHueValue; + + lastSaturationValue = logicalSaturationValue; + + return true; + } + + void applyHueStep( int step ) { + if (!hueEditValid) { + beginHueEdit(); + } + + if (!hueEditValid) { + return; + } + + int newValue = (int)logicalHueValue + step; + + while ( newValue < 0 ) { + newValue += 256; + } + + while ( newValue > 255 ) { + newValue -= 256; + } + + applyHueValue( (uint8_t)newValue ); + } + + void hueShortPress( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_HUE_DOWN ) { + applyHueStep( -HUE_SHORT_STEP ); + } + else if ( target == M5STACK_TOUCH_TARGET_HUE_UP ) { + applyHueStep( HUE_SHORT_STEP ); + } + } + + void hueLongPressStep( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_HUE_DOWN ) { + applyHueStep( -HUE_LONG_STEP ); + } + else if ( target == M5STACK_TOUCH_TARGET_HUE_UP ) { + applyHueStep( HUE_LONG_STEP ); + } + } + + bool applySaturationValue( uint8_t newSaturation ) { + if ( strip.getSegmentsNum() == 0 ) { + return false; + } + + if (!saturationEditValid) { + beginSaturationEdit(); + } + + if (!saturationEditValid) { + return false; + } + + saturationEditValue = newSaturation; + + saturationEditHsv.s = newSaturation; + + logicalSaturationValue = newSaturation; + + logicalColorHsv = saturationEditHsv; + + logicalHueValue = (uint8_t)( logicalColorHsv.h >> 8 ); + + logicalWhiteValue = saturationEditWhite; + + logicalColorHsvValid = true; + + CRGBW newRgb; + + hsv2rgb_spectrum( logicalColorHsv, newRgb ); + + newRgb.w = logicalWhiteValue; + + uint32_t newColor = newRgb.color32; + + Segment& mainSegment = strip.getMainSegment(); + + if ( newColor != mainSegment.colors[ selectedColorSlot ] ) { + mainSegment.setColor( selectedColorSlot, newColor ); + + stateUpdated( CALL_MODE_BUTTON ); + } + + if ( currentPage == SCREEN_COLOR ) { + drawColorDetails( newColor ); + + drawHue( logicalHueValue, M5STACK_TOUCH_TARGET_NONE ); + + drawSaturation( logicalSaturationValue, touchState.touchTarget ); + } + + lastSelectedColor = newColor; + lastSelectedColorValid = true; + + if ( selectedColorSlot == 0 ) { + lastPrimaryColor = newColor; + lastPrimaryColorValid = true; + } + + cacheCurrentColorSlots(); + + lastHueValue = logicalHueValue; + + lastSaturationValue = logicalSaturationValue; + + return true; + } + + void applySaturationStep( int step ) { + if (!saturationEditValid) { + beginSaturationEdit(); + } + + if (!saturationEditValid) { + return; + } + + int newValue = constrain( (int)logicalSaturationValue + step, 0, 255 ); + + if ( newValue == logicalSaturationValue ) { + return; + } + + applySaturationValue( (uint8_t)newValue ); + } + + void saturationShortPress( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_SATURATION_DOWN ) { + applySaturationStep( -SATURATION_SHORT_STEP ); + } + else if ( target == M5STACK_TOUCH_TARGET_SATURATION_UP ) { + applySaturationStep( SATURATION_SHORT_STEP ); + } + } + + void saturationLongPressStep( M5StackTouchTarget target ) { + if ( target == M5STACK_TOUCH_TARGET_SATURATION_DOWN ) { + applySaturationStep( -SATURATION_LONG_STEP ); + } + else if ( target == M5STACK_TOUCH_TARGET_SATURATION_UP ) { + applySaturationStep( SATURATION_LONG_STEP ); + } + } + + #include "M5StackDisplayTouchStateMachine.inc" + + void serviceAudioHealthPresentation( uint8_t effectMode ) { + const bool audioUnavailable = isCoreS3AudioUnavailable(); + + if ( audioUnavailable == lastAudioUnavailable ) { + return; + } + + const bool relevantVisiblePage = + currentPage == SCREEN_MAIN || currentPage == SCREEN_EFFECT; + + const bool currentEffectUsesAudio = + effectUsesAudioReactive( getEffectCapabilities( effectMode ) ); + + // Avoid changing effect visuals in the middle of a touch gesture. Keep the + // previous cached state so the change is retried after release. + if ( + relevantVisiblePage && + currentEffectUsesAudio && + touchState.touchTarget != M5STACK_TOUCH_TARGET_NONE + ) { + return; + } + + lastAudioUnavailable = audioUnavailable; + + if ( !currentEffectUsesAudio ) { + return; + } + + if ( currentPage == SCREEN_MAIN ) { + drawEffect( effectMode, M5STACK_TOUCH_TARGET_NONE ); + return; + } + + if ( currentPage == SCREEN_EFFECT ) { + drawEffectPageName( effectMode ); + } + } + + // ========================================================= + // Page-specific runtime display synchronization + // + // These helpers contain only UI/WLED state synchronization. + // Hardware-specific display/touch access remains outside this layer + // so the same page logic can be reused for Core2 variants later. + // ========================================================= + + bool updateMainPageState( uint8_t effectMode, uint8_t currentSpeed, uint8_t currentIntensity, uint8_t currentPalette, uint32_t primaryColor, bool primaryColorChanged ) { + bool brightnessTouchActive = ( touchState.touchTarget == M5STACK_TOUCH_TARGET_BRIGHTNESS_DOWN || touchState.touchTarget == M5STACK_TOUCH_TARGET_BRIGHTNESS_UP ); + + if ( !brightnessTouchActive && (int)bri != lastBrightnessValue ) { + drawBrightness( bri, M5STACK_TOUCH_TARGET_NONE ); + + lastBrightnessValue = bri; + } + + bool effectTouchActive = ( touchState.touchTarget == M5STACK_TOUCH_TARGET_EFFECT_PREV || touchState.touchTarget == M5STACK_TOUCH_TARGET_EFFECT_DETAIL || touchState.touchTarget == M5STACK_TOUCH_TARGET_EFFECT_NEXT ); + + if ( !effectTouchActive && (int)effectMode != lastEffectMode ) { + drawEffect( effectMode, M5STACK_TOUCH_TARGET_NONE ); + + drawColorButton( primaryColor, false ); + + lastEffectMode = effectMode; + + lastSpeedValue = currentSpeed; + + lastIntensityValue = currentIntensity; + + lastPaletteValue = currentPalette; + } + + if ( (int)currentPalette != lastPaletteValue ) { + lastPaletteValue = currentPalette; + } + + if ( (int)currentPreset != lastPresetValue ) { + lastPresetValue = currentPreset; + } + + if ( primaryColorChanged && touchState.touchTarget != M5STACK_TOUCH_TARGET_COLOR_OPEN ) { + syncLogicalColorFromRgb( primaryColor ); + + drawColorButton( primaryColor, false ); + + return true; + } + + return false; + } + + bool updateColorPageState( + uint8_t effectMode, + uint32_t primaryColor, + bool primaryColorChanged, + bool colorControlTouchActive + ) { + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE && (int)effectMode != lastEffectMode ) { + drawColorScreen(); + + return true; + } + + const M5StackEffectColorCapabilities capability = + getEffectColorCapabilities( effectMode ); + + if ( !effectUsesAnyColor( capability ) ) { + if ( currentColorSlotsChanged() ) { + cacheCurrentColorSlots(); + } + + return primaryColorChanged; + } + + normalizeSelectedColorSlot( effectMode ); + + if ( currentColorSlotsChanged() && !colorControlTouchActive ) { + const uint32_t selectedColor = getSelectedColor(); + + syncLogicalColorFromRgb( selectedColor ); + + lastSelectedColor = selectedColor; + lastSelectedColorValid = true; + + drawColorDetails( selectedColor ); + + drawHue( logicalHueValue, M5STACK_TOUCH_TARGET_NONE ); + + drawSaturation( logicalSaturationValue, M5STACK_TOUCH_TARGET_NONE ); + + cacheCurrentColorSlots(); + + lastHueValue = logicalHueValue; + + lastSaturationValue = logicalSaturationValue; + + return primaryColorChanged; + } + + return false; + } + + bool updateEffectPageState( uint8_t effectMode, uint8_t currentSpeed, uint8_t currentIntensity, uint8_t currentPalette ) { + bool speedTouchActive = ( touchState.touchTarget == M5STACK_TOUCH_TARGET_SPEED_DOWN || touchState.touchTarget == M5STACK_TOUCH_TARGET_SPEED_UP ); + + bool intensityTouchActive = ( touchState.touchTarget == M5STACK_TOUCH_TARGET_INTENSITY_DOWN || touchState.touchTarget == M5STACK_TOUCH_TARGET_INTENSITY_UP ); + + bool paletteTouchActive = ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PALETTE_PREV || touchState.touchTarget == M5STACK_TOUCH_TARGET_PALETTE_NEXT ); + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE && (int)effectMode != lastEffectMode ) { + drawEffectDetailScreen(); + + return true; + } + + if ( !speedTouchActive && (int)currentSpeed != lastSpeedValue ) { + drawSpeed( currentSpeed, M5STACK_TOUCH_TARGET_NONE ); + + lastSpeedValue = currentSpeed; + } + + if ( !intensityTouchActive && (int)currentIntensity != lastIntensityValue ) { + drawIntensity( currentIntensity, M5STACK_TOUCH_TARGET_NONE ); + + lastIntensityValue = currentIntensity; + } + + if ( !paletteTouchActive && (int)currentPalette != lastPaletteValue ) { + drawPalette( currentPalette, M5STACK_TOUCH_TARGET_NONE ); + + lastPaletteValue = currentPalette; + } + + return false; + } + + void updatePresetPageState( uint8_t displayedPreset, bool presetPendingSettled ) { + if ( presetSubPage == PRESET_SUBPAGE_MANAGE ) { + const bool presetFileChanged = + ( presetsModifiedTime != lastPresetsModifiedTime ); + + const bool activePresetChanged = + ( (int)currentPreset != lastPresetValue ); + + if ( + touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE && + ( activePresetChanged || presetFileChanged || presetPendingSettled ) + ) { + drawPresetManageScreen(); + } + + return; + } + + if ( presetSubPage == PRESET_SUBPAGE_NAV ) { + bool presetTouchActive = ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_PREV || touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_NEXT ); + + bool presetFileChanged = ( presetsModifiedTime != lastPresetsModifiedTime ); + + if (presetFileChanged) { + lastPresetsModifiedTime = presetsModifiedTime; + + presetNoEntries = false; + } + + if ( !presetTouchActive && ( (int)displayedPreset != lastPresetValue || presetPendingSettled || presetFileChanged ) ) { + drawPresetDetails( displayedPreset, pendingPresetId > 0 ); + + drawPresetNavigation( M5STACK_TOUCH_TARGET_NONE ); + + lastPresetValue = displayedPreset; + } + + return; + } + + if ( presetSubPage == PRESET_SUBPAGE_BOOT ) { + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE && (int)bootPreset != lastBootPresetValue ) { + drawPresetBootScreen(); + lastBootPresetValue = bootPreset; + } + } + } + + static void writeBmp16( uint8_t* destination, uint16_t value ) { + destination[0] = (uint8_t)( value & 0xFF ); + destination[1] = (uint8_t)( ( value >> 8 ) & 0xFF ); + } + + static void writeBmp32( uint8_t* destination, uint32_t value ) { + destination[0] = (uint8_t)( value & 0xFF ); + destination[1] = (uint8_t)( ( value >> 8 ) & 0xFF ); + destination[2] = (uint8_t)( ( value >> 16 ) & 0xFF ); + destination[3] = (uint8_t)( ( value >> 24 ) & 0xFF ); + } + + bool buildScreenshotBmp( + uint8_t*& bmpData, + size_t& bmpSize + ) { + bmpData = nullptr; + bmpSize = 0; + + if ( !displayReady || screenWidth <= 0 || screenHeight <= 0 ) { + return false; + } + + const size_t width = (size_t)screenWidth; + const size_t height = (size_t)screenHeight; + const size_t pixelCount = width * height; + const size_t frameBytes = pixelCount * sizeof(uint16_t); + + // 24-bit BMP rows are aligned to 4-byte boundaries. + const size_t rowBytes = width * 3; + const size_t rowStride = ( rowBytes + 3 ) & ~((size_t)3); + const size_t imageBytes = rowStride * height; + const size_t totalBytes = SCREENSHOT_BMP_HEADER_SIZE + imageBytes; + + uint16_t* frameBuffer = static_cast( + heap_caps_malloc( + frameBytes, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT + ) + ); + + if ( frameBuffer == nullptr ) { + return false; + } + + uint8_t* outputBuffer = static_cast( + heap_caps_malloc( + totalBytes, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT + ) + ); + + if ( outputBuffer == nullptr ) { + heap_caps_free( frameBuffer ); + return false; + } + + if ( + !hardwareBackend.readDisplayRgb565( + frameBuffer, + screenWidth, + screenHeight + ) + ) { + heap_caps_free( outputBuffer ); + heap_caps_free( frameBuffer ); + + return false; + } + + memset( outputBuffer, 0, totalBytes ); + + // ------------------------------------------------------- + // BITMAPFILEHEADER (14 bytes) + // ------------------------------------------------------- + + outputBuffer[0] = 'B'; + outputBuffer[1] = 'M'; + + writeBmp32( outputBuffer + 2, (uint32_t)totalBytes ); + writeBmp32( outputBuffer + 10, (uint32_t)SCREENSHOT_BMP_HEADER_SIZE ); + + // ------------------------------------------------------- + // BITMAPINFOHEADER (40 bytes) + // + // Negative height selects top-down row order. This lets us + // preserve the LCD's natural 0..height-1 scan order without + // reversing the captured frame. + // ------------------------------------------------------- + + writeBmp32( outputBuffer + 14, 40 ); + writeBmp32( outputBuffer + 18, (uint32_t)screenWidth ); + writeBmp32( + outputBuffer + 22, + (uint32_t)(int32_t)( -screenHeight ) + ); + + writeBmp16( outputBuffer + 26, 1 ); + writeBmp16( outputBuffer + 28, 24 ); + + writeBmp32( outputBuffer + 30, 0 ); + writeBmp32( outputBuffer + 34, (uint32_t)imageBytes ); + + // 72 DPI, expressed as pixels per metre. + writeBmp32( outputBuffer + 38, 2835 ); + writeBmp32( outputBuffer + 42, 2835 ); + + for ( size_t y = 0; y < height; y++ ) { + const uint16_t* sourceRow = frameBuffer + ( y * width ); + uint8_t* destinationRow = + outputBuffer + SCREENSHOT_BMP_HEADER_SIZE + ( y * rowStride ); + + for ( size_t x = 0; x < width; x++ ) { + const uint16_t rawPixel = sourceRow[x]; + + // M5GFX readRect() returns the CoreS3 panel RGB565 word with + // the two bytes swapped relative to the logical TFT color value. + // + // Example: + // TFT_CYAN = 0x07FF + // readRect = 0xFF07 + // + // Restore the logical RGB565 word before expanding to RGB888. + const uint16_t pixel = (uint16_t)( + ( rawPixel >> 8 ) | + ( rawPixel << 8 ) + ); + + const uint8_t red5 = (uint8_t)( ( pixel >> 11 ) & 0x1F ); + const uint8_t green6 = (uint8_t)( ( pixel >> 5 ) & 0x3F ); + const uint8_t blue5 = (uint8_t)( pixel & 0x1F ); + + const uint8_t red8 = (uint8_t)( ( red5 << 3 ) | ( red5 >> 2 ) ); + const uint8_t green8 = (uint8_t)( ( green6 << 2 ) | ( green6 >> 4 ) ); + const uint8_t blue8 = (uint8_t)( ( blue5 << 3 ) | ( blue5 >> 2 ) ); + + uint8_t* destinationPixel = destinationRow + ( x * 3 ); + + // BMP 24-bit pixel byte order is B, G, R. + destinationPixel[0] = blue8; + destinationPixel[1] = green8; + destinationPixel[2] = red8; + } + } + + heap_caps_free( frameBuffer ); + + bmpData = outputBuffer; + bmpSize = totalBytes; + + return true; + } + + void handleScreenshotRequest( AsyncWebServerRequest* request ) { + if ( request == nullptr ) { + return; + } + + if ( + isCore2DiagnosticOnlyMode() || + !displayReady || + screenWidth <= 0 || + screenHeight <= 0 + ) { + request->send( + 503, + F("text/plain"), + F("CoreS3 display screenshot is unavailable.\n") + ); + + return; + } + + // Keep screenshot memory use bounded and avoid overlapping LCD reads. + if ( screenshotCaptureInProgress ) { + request->send( + 429, + F("text/plain"), + F("A CoreS3 screenshot request is already in progress.\n") + ); + + return; + } + + screenshotCaptureInProgress = true; + + uint8_t* bmpData = nullptr; + size_t bmpSize = 0; + + if ( !buildScreenshotBmp( bmpData, bmpSize ) ) { + screenshotCaptureInProgress = false; + + Serial.println( + F( "[CoreS3_Display] Screenshot: capture/buffer allocation failed" ) + ); + + request->send( + 503, + F("text/plain"), + F("CoreS3 screenshot capture failed. PSRAM may be unavailable.\n") + ); + + return; + } + + // The async response outlives this request handler. Keep the BMP buffer + // alive with shared ownership until the response itself is destroyed. + std::shared_ptr bmpOwner( + bmpData, + [this]( uint8_t* pointer ) { + if ( pointer != nullptr ) { + heap_caps_free( pointer ); + } + + screenshotCaptureInProgress = false; + } + ); + + const size_t responseSize = bmpSize; + + AsyncWebServerResponse* response = request->beginResponse( + F("image/bmp"), + responseSize, + [bmpOwner, responseSize]( + uint8_t* buffer, + size_t maxLength, + size_t index + ) -> size_t { + if ( buffer == nullptr || index >= responseSize ) { + return 0; + } + + const size_t remaining = responseSize - index; + const size_t copyLength = + ( maxLength < remaining ) ? maxLength : remaining; + + memcpy( + buffer, + bmpOwner.get() + index, + copyLength + ); + + return copyLength; + } + ); + + if ( response == nullptr ) { + screenshotCaptureInProgress = false; + + request->send( + 503, + F("text/plain"), + F("CoreS3 screenshot response could not be created.\n") + ); + + return; + } + + response->addHeader( + F("Cache-Control"), + F("no-store, no-cache, must-revalidate, max-age=0") + ); + + response->addHeader( F("Pragma"), F("no-cache") ); + response->addHeader( F("Expires"), F("0") ); + + response->addHeader( + F("Content-Disposition"), + F("inline; filename=\"cores3-screen.bmp\"") + ); + + request->send( response ); + + Serial.printf( + "[CoreS3_Display] Screenshot: %d x %d BMP (%u bytes)\n", + screenWidth, + screenHeight, + (unsigned)responseSize + ); + } + + void registerScreenshotEndpoint() { + server.on( + F("/cores3/screenshot.bmp"), + HTTP_GET, + [this]( AsyncWebServerRequest* request ) { + handleScreenshotRequest( request ); + } + ); + + Serial.println( + F( "[CoreS3_Display] Screenshot endpoint: /cores3/screenshot.bmp" ) + ); + } + + public: + + CoreS3DisplayUsermod() + : hardwareBackend( display ) { + } + + void addToConfig( JsonObject& root ) override { + JsonObject top = root.createNestedObject( FPSTR( CORES3_DISPLAY_CONFIG_NAME ) ); + + top[ F("sleep-timeout") ] = sleepTimeoutSec; + + top[ F("lcd-brightness") ] = lcdBrightness; + + top[ F("fade") ] = fadeEnabled; + + top[ F("fade-duration") ] = fadeDurationMs; + } + + bool readFromConfig( JsonObject& root ) override { + JsonObject top = root[ FPSTR( CORES3_DISPLAY_CONFIG_NAME ) ]; + + if ( top.isNull() ) { + Serial.println( F( "[CoreS3_Display] " "No display config found. Using defaults." ) ); + + return false; + } + + bool configComplete = true; + + int newSleepTimeout = sleepTimeoutSec; + + int newLcdBrightness = lcdBrightness; + + bool newFadeEnabled = fadeEnabled; + + int newFadeDuration = fadeDurationMs; + + configComplete &= getJsonValue( top[ F("sleep-timeout") ], newSleepTimeout, 30 ); + + configComplete &= getJsonValue( top[ F("lcd-brightness") ], newLcdBrightness, 128 ); + + configComplete &= getJsonValue( top[ F("fade") ], newFadeEnabled, true ); + + configComplete &= getJsonValue( top[ F("fade-duration") ], newFadeDuration, 250 ); + + newSleepTimeout = constrain( newSleepTimeout, 0, 3600 ); + + newLcdBrightness = constrain( newLcdBrightness, 1, 255 ); + + newFadeDuration = constrain( newFadeDuration, 50, 2000 ); + + sleepTimeoutSec = (uint16_t)newSleepTimeout; + + lcdBrightness = (uint16_t)newLcdBrightness; + + fadeEnabled = newFadeEnabled; + + fadeDurationMs = (uint16_t)newFadeDuration; + + if ( initDone ) { + Serial.printf( + "[CoreS3_Display] " + "Config updated: " + "Sleep=%u sec, " + "LCD=%u, " + "Fade=%s, " + "FadeDuration=%u ms\n", + sleepTimeoutSec, + lcdBrightness, + fadeEnabled ? "ON" : "OFF", + fadeDurationMs + ); + } + + if ( initDone && displayReady && displayPowerState == DISPLAY_POWER_ACTIVE ) { + setDisplayBrightness( getNormalDisplayBrightness() ); + } + + return configComplete; + } + + void appendConfigData( Print& settingsScript ) override { + settingsScript.print( F( "cs3st=addDropdown(" "'CoreS3_Display'," "'sleep-timeout'" ");" ) ); + + settingsScript.print( F( "addOption(" "cs3st," "'Never'," "0" ");" ) ); + + settingsScript.print( F( "addOption(" "cs3st," "'15 sec'," "15" ");" ) ); + + settingsScript.print( F( "addOption(" "cs3st," "'30 sec'," "30" ");" ) ); + + settingsScript.print( F( "addOption(" "cs3st," "'60 sec'," "60" ");" ) ); + + settingsScript.print( F( "addOption(" "cs3st," "'120 sec'," "120" ");" ) ); + + settingsScript.print( F( "addInfo(" "'CoreS3_Display:lcd-brightness'," "1," "'1-255'" ");" ) ); + + settingsScript.print( F( "cs3fd=addDropdown(" "'CoreS3_Display'," "'fade-duration'" ");" ) ); + + settingsScript.print( F( "addOption(" "cs3fd," "'Fast - 150 ms'," "150" ");" ) ); + + settingsScript.print( F( "addOption(" "cs3fd," "'Normal - 250 ms'," "250" ");" ) ); + + settingsScript.print( F( "addOption(" "cs3fd," "'Slow - 400 ms'," "400" ");" ) ); + + settingsScript.print( F( "addOption(" "cs3fd," "'Very Slow - 600 ms'," "600" ");" ) ); + } + + void setup() override { + Serial.println(); + + Serial.println( F( "[CoreS3_Display][BUILD] CoreS3 Display v0.1.0" ) ); + Serial.println( F( "[CoreS3_Display] Initialization start" ) ); + + registerScreenshotEndpoint(); + + Serial.printf( + "[CoreS3_Display] " "Hardware: %s, Revision=%s, Runtime=%s\n", + getHardwareProfileName(), + getHardwareRevisionName(), + getHardwareRuntimeModeName() + ); + + Serial.printf( + "[CoreS3_Display] " "Port status: %s\n", + getHardwarePortStatusName() + ); + + Serial.printf( "[CoreS3_Display] " "Settings: " "Sleep=%u sec, " "LCD=%u, " "Fade=%s, " "FadeDuration=%u ms\n", sleepTimeoutSec, lcdBrightness, fadeEnabled ? "ON" : "OFF", fadeDurationMs ); + + runHardwareDiagnostics(); + + if ( isCore2DiagnosticOnlyMode() ) { + initDone = true; + + Serial.println( F( "[CoreS3_Display] Core2 diagnostic-only runtime complete" ) ); + Serial.println( F( "[CoreS3_Display] Display/Touch/LCD brightness initialization intentionally skipped" ) ); + Serial.println( F( "[CoreS3_Display] Capture the hardware probe result before enabling Core2 UI/Power support" ) ); + Serial.println(); + + return; + } + + if ( !initializeDisplayHardware() ) { + return; + } + + setDisplayBrightness( 0 ); + + drawStartupBase(); + + startupDotCount = 3; + + drawStartupConnectingStatus(); + + startupState = STARTUP_FADE_IN; + + startupStateStart = millis(); + + startupLastFadeStep = startupStateStart; + + startupLastDotsUpdate = startupStateStart; + + runtimeHealthStartMs = startupStateStart; + + displayPowerState = DISPLAY_POWER_ACTIVE; + + lastUserActivityMs = startupStateStart; + + lastPresetsModifiedTime = presetsModifiedTime; + + presetCacheCount = 0; + + presetCacheReady = false; + + presetCacheBuilding = false; + + presetCacheScanId = 1; + + presetCacheSourceModifiedTime = presetsModifiedTime; + + displayReady = true; + + initDone = true; + + Serial.println( F( "[CoreS3_Display] Initialization complete" ) ); + + Serial.println(); + } + + void loop() override { + if (!displayReady) { + return; + } + + if ( startupState != STARTUP_DONE ) { + handleStartupSequence(); + + return; + } + + serviceWiFiRecoveryAP(); + + if ( !presetCacheReady && !presetCacheBuilding ) { + startPresetCacheRebuild(); + } + + servicePresetCache(); + + servicePresetSaveOperation(); + + servicePresetDeleteOperation(); + + servicePresetBootOperation(); + + if ( serviceRuntimeHealthWarnings() ) { + return; + } + + // Follow a Preset explicitly selected from the WLED Web UI. + // A transition to currentPreset == 0 intentionally does not erase the + // CoreS3 navigation cursor. + syncPresetNavigationCursorFromCurrentPreset(); + + if ( displayPowerState == DISPLAY_POWER_ACTIVE ) { + handleTouch(); + } + + if ( handleDisplayPowerManagement() ) { + return; + } + + unsigned long now = millis(); + + if ( now - lastUpdate < 250 ) { + return; + } + + lastUpdate = now; + + bool presetPendingSettled = settlePendingPreset(); + + NetworkAccessMode currentNetworkMode = getNetworkAccessMode(); + String currentNetworkDisplayText = + getNetworkDisplayText( currentNetworkMode ); + + if ( !readyScreenShown ) { + drawMainScreen( currentNetworkDisplayText ); + + lastNetworkAccessMode = currentNetworkMode; + lastNetworkDisplayText = currentNetworkDisplayText; + + return; + } + + const bool networkPresentationChanged = + ( currentNetworkMode != lastNetworkAccessMode ) || + ( currentNetworkDisplayText != lastNetworkDisplayText ); + + if ( networkPresentationChanged ) { + // Do not redraw/reset MAIN in the middle of a touch gesture. This is + // especially important when a Recovery AP starts during a long press. + if ( + currentPage == SCREEN_MAIN && + touchState.touchTarget != M5STACK_TOUCH_TARGET_NONE + ) { + // Keep the old cache so the transition is still observed after the + // finger is released, unless the Recovery handler updates it itself. + } + else { + lastNetworkAccessMode = currentNetworkMode; + lastNetworkDisplayText = currentNetworkDisplayText; + + // Network changes are informational only. Do not interrupt COLOR, + // EFFECT or PRESET operation. MAIN is the page that owns the + // network-status line, so redraw it only when it is currently visible. + if ( currentPage == SCREEN_MAIN ) { + drawMainScreen( currentNetworkDisplayText ); + + return; + } + } + } + + if ( + currentPage == SCREEN_MAIN && + sampleBatteryStatus() + ) { + drawMainBatteryStatus(); + } + + bool ledOn = (bri > 0); + + if ( (int8_t)ledOn != lastLedState ) { + if ( touchState.touchTarget != M5STACK_TOUCH_TARGET_POWER ) { + drawPowerButton( ledOn, false ); + } + + lastLedState = ledOn ? 1 : 0; + } + + uint8_t effectMode = getCurrentEffectMode(); + + serviceAudioHealthPresentation( effectMode ); + + uint8_t currentSpeed = getCurrentSpeed(); + + uint8_t currentIntensity = getCurrentIntensity(); + + uint8_t currentPalette = getCurrentPalette(); + + uint8_t displayedPreset = getDisplayedPresetId(); + + uint32_t primaryColor = getPrimaryColor(); + + bool primaryColorChanged = ( !lastPrimaryColorValid || primaryColor != lastPrimaryColor ); + + bool hueTouchActive = ( touchState.touchTarget == M5STACK_TOUCH_TARGET_HUE_DOWN || touchState.touchTarget == M5STACK_TOUCH_TARGET_HUE_UP ); + + bool saturationTouchActive = ( touchState.touchTarget == M5STACK_TOUCH_TARGET_SATURATION_DOWN || touchState.touchTarget == M5STACK_TOUCH_TARGET_SATURATION_UP ); + + bool colorSlotTouchActive = ( + touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_1 || + touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_2 || + touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_3 + ); + + bool colorControlTouchActive = ( hueTouchActive || saturationTouchActive || colorSlotTouchActive ); + + bool primaryColorChangeHandled = false; + + if ( currentPage == SCREEN_MAIN ) { + primaryColorChangeHandled = updateMainPageState( effectMode, currentSpeed, currentIntensity, currentPalette, primaryColor, primaryColorChanged ); + } + else if ( currentPage == SCREEN_COLOR ) { + primaryColorChangeHandled = updateColorPageState( effectMode, primaryColor, primaryColorChanged, colorControlTouchActive ); + } + else if ( currentPage == SCREEN_EFFECT ) { + if ( updateEffectPageState( effectMode, currentSpeed, currentIntensity, currentPalette ) ) { + return; + } + } + else if ( currentPage == SCREEN_PRESET ) { + updatePresetPageState( displayedPreset, presetPendingSettled ); + } + + if ( primaryColorChanged && primaryColorChangeHandled ) { + lastPrimaryColor = primaryColor; + + lastPrimaryColorValid = true; + } + } + + void addToJsonInfo( JsonObject& root ) override { + JsonObject user = root["u"]; + + if ( user.isNull() ) { + user = root.createNestedObject( "u" ); + } + + JsonArray hardwareProfileInfo = user.createNestedArray( "M5Stack Hardware Profile" ); + + hardwareProfileInfo.add( getHardwareProfileName() ); + + JsonArray hardwareRevisionInfo = user.createNestedArray( "M5Stack Hardware Revision" ); + + hardwareRevisionInfo.add( getHardwareRevisionName() ); + + JsonArray hardwareRuntimeInfo = user.createNestedArray( "M5Stack Runtime Mode" ); + + hardwareRuntimeInfo.add( getHardwareRuntimeModeName() ); + + JsonArray hardwarePortStatusInfo = user.createNestedArray( "M5Stack Porting Status" ); + + hardwarePortStatusInfo.add( getHardwarePortStatusName() ); + + JsonArray hardwareProbeInfo = user.createNestedArray( "M5Stack Hardware Probe" ); + + hardwareProbeInfo.add( getHardwareProbeStateName() ); + + JsonArray hardwareVariantInfo = user.createNestedArray( "M5Stack Detected Variant" ); + + hardwareVariantInfo.add( getDetectedVariantName() ); + + JsonArray hardwareDetectedRevisionInfo = user.createNestedArray( "M5Stack Detected Revision" ); + + hardwareDetectedRevisionInfo.add( getDetectedRevisionName() ); + + JsonArray hardwarePmuInfo = user.createNestedArray( "M5Stack Detected PMU" ); + + hardwarePmuInfo.add( getDetectedPmuName() ); + + JsonArray hardwareImuInfo = user.createNestedArray( "M5Stack Detected IMU" ); + + hardwareImuInfo.add( getDetectedImuName() ); + + JsonArray hardwareCore2I2cInfo = user.createNestedArray( "M5Stack Core2 I2C Signature" ); + + if ( hardwareBackend.isProbeComplete() ) { + char signature[64]; + + snprintf( + signature, + sizeof(signature), + "34:%c 35:%c 38:%c 40:%c 51:%c 68:%c", + hardwareBackend.hasI2CAddress( 0x34 ) ? 'Y' : 'N', + hardwareBackend.hasI2CAddress( 0x35 ) ? 'Y' : 'N', + hardwareBackend.hasI2CAddress( 0x38 ) ? 'Y' : 'N', + hardwareBackend.hasI2CAddress( 0x40 ) ? 'Y' : 'N', + hardwareBackend.hasI2CAddress( 0x51 ) ? 'Y' : 'N', + hardwareBackend.hasI2CAddress( 0x68 ) ? 'Y' : 'N' + ); + + hardwareCore2I2cInfo.add( signature ); + } + else { + hardwareCore2I2cInfo.add( "Not probed" ); + } + + JsonArray displayInfo = user.createNestedArray( "CoreS3 Display" ); + + if ( isCore2DiagnosticOnlyMode() ) { + displayInfo.add( "DIAGNOSTIC ONLY - NOT INITIALIZED" ); + } + else if (displayReady) { + char text[32]; + + snprintf( text, sizeof(text), "READY (%d x %d)", screenWidth, screenHeight ); + + displayInfo.add( text ); + } + else { + displayInfo.add( "FAILED" ); + } + + JsonArray screenshotInfo = user.createNestedArray( "CoreS3 Display Screenshot" ); + + if ( isCore2DiagnosticOnlyMode() ) { + screenshotInfo.add( "UNAVAILABLE - DIAGNOSTIC ONLY" ); + } + else if ( displayReady ) { + screenshotInfo.add( "/cores3/screenshot.bmp" ); + } + else { + screenshotInfo.add( "UNAVAILABLE" ); + } + + JsonArray touchInfo = user.createNestedArray( "CoreS3 Display Touch" ); + + if ( isCore2DiagnosticOnlyMode() ) { + if ( hardwareBackend.isProbeComplete() ) { + touchInfo.add( + hardwareBackend.hasI2CAddress( 0x38 ) + ? "I2C 0x38 DETECTED - NOT INITIALIZED" + : "I2C 0x38 NOT DETECTED" + ); + } + else { + touchInfo.add( "DIAGNOSTIC PROBE UNAVAILABLE" ); + } + } + else { + touchInfo.add( touchReady ? "READY" : "NOT FOUND" ); + } + + JsonArray wifiInfo = user.createNestedArray( "CoreS3 Display WiFi" ); + + NetworkAccessMode networkMode = getNetworkAccessMode(); + + if ( networkMode == NETWORK_ACCESS_STA ) { + wifiInfo.add( String( "STA: " ) + WiFi.localIP().toString() ); + } + else if ( networkMode == NETWORK_ACCESS_AP ) { + if ( recoveryApSessionActive && recoveryApSSID.length() > 0 ) { + wifiInfo.add( + String( "Recovery AP: " ) + + recoveryApSSID + + " @ " + + WiFi.softAPIP().toString() + ); + } + else { + wifiInfo.add( String( "AP: " ) + WiFi.softAPIP().toString() ); + } + } + else { + wifiInfo.add( "Offline - local control available" ); + } + + JsonArray ledInfo = user.createNestedArray( "CoreS3 Display LED" ); + + ledInfo.add( bri > 0 ? "ON" : "OFF" ); + + JsonArray brightnessInfo = user.createNestedArray( "CoreS3 Display Brightness" ); + + brightnessInfo.add( bri ); + + JsonArray effectInfo = user.createNestedArray( "CoreS3 Display Effect" ); + + if ( strip.getSegmentsNum() > 0 ) { + char effectName[64]; + + getEffectName( strip.getMainSegment().mode, effectName, sizeof(effectName) ); + + effectInfo.add( effectName ); + } + else { + effectInfo.add( "No segment" ); + } + + JsonArray speedInfo = user.createNestedArray( "CoreS3 Effect Speed" ); + + if ( strip.getSegmentsNum() > 0 ) { + speedInfo.add( getCurrentSpeed() ); + } + else { + speedInfo.add( "No segment" ); + } + + JsonArray intensityInfo = user.createNestedArray( "CoreS3 Effect Intensity" ); + + if ( strip.getSegmentsNum() > 0 ) { + intensityInfo.add( getCurrentIntensity() ); + } + else { + intensityInfo.add( "No segment" ); + } + + JsonArray paletteInfo = user.createNestedArray( "CoreS3 Effect Palette" ); + + if ( strip.getSegmentsNum() > 0 ) { + char paletteName[64]; + + getPaletteName( getCurrentPalette(), paletteName, sizeof(paletteName) ); + + paletteInfo.add( paletteName ); + } + else { + paletteInfo.add( "No segment" ); + } + + JsonArray paletteIdInfo = user.createNestedArray( "CoreS3 Effect Palette ID" ); + + if ( strip.getSegmentsNum() > 0 ) { + paletteIdInfo.add( getCurrentPalette() ); + } + else { + paletteIdInfo.add( "No segment" ); + } + + JsonArray paletteTouchInfo = user.createNestedArray( "CoreS3 Palette Touch Area" ); + + paletteTouchInfo.add( "Expanded" ); + + JsonArray presetInfo = user.createNestedArray( "CoreS3 Display Preset" ); + + presetInfo.add( currentPreset > 0 ? "Active Preset" : "Custom State" ); + + JsonArray presetIdInfo = user.createNestedArray( "CoreS3 Display Preset ID" ); + + presetIdInfo.add( currentPreset ); + + JsonArray bootPresetInfo = user.createNestedArray( "CoreS3 Boot Preset" ); + + if ( bootPreset == 0 ) { + bootPresetInfo.add( "NONE" ); + } + else { + bootPresetInfo.add( bootPreset ); + } + + JsonArray presetTouchInfo = user.createNestedArray( "CoreS3 Preset Touch Area" ); + + presetTouchInfo.add( "Expanded" ); + + JsonArray presetCacheInfo = user.createNestedArray( "CoreS3 Preset Cache" ); + + if ( presetCacheReady ) { + char cacheText[32]; + + snprintf( cacheText, sizeof(cacheText), "READY (%u)", (unsigned)presetCacheCount ); + + presetCacheInfo.add( cacheText ); + } + else if ( presetCacheBuilding ) { + // ----------------------------------------------------- + // Do not expose the internal 1..250 scan position. + // ----------------------------------------------------- + + presetCacheInfo.add( "LOADING" ); + } + else { + presetCacheInfo.add( "NOT READY" ); + } + + JsonArray colorInfo = user.createNestedArray( "CoreS3 Display Color" ); + + if ( strip.getSegmentsNum() > 0 ) { + uint32_t color = getPrimaryColor(); + + char colorText[16]; + + snprintf( colorText, sizeof(colorText), "#%02X%02X%02X", R(color), G(color), B(color) ); + + colorInfo.add( colorText ); + } + else { + colorInfo.add( "No segment" ); + } + + JsonArray hueInfo = user.createNestedArray( "CoreS3 Display Hue" ); + + if ( strip.getSegmentsNum() > 0 ) { + hueInfo.add( getDisplayedHue() ); + } + else { + hueInfo.add( "No segment" ); + } + + JsonArray saturationInfo = user.createNestedArray( "CoreS3 Display Saturation" ); + + if ( strip.getSegmentsNum() > 0 ) { + saturationInfo.add( getDisplayedSaturation() ); + } + else { + saturationInfo.add( "No segment" ); + } + + JsonArray pageInfo = user.createNestedArray( "CoreS3 Display Page" ); + + if ( currentPage == SCREEN_MAIN ) { + pageInfo.add( "MAIN" ); + } + else if ( currentPage == SCREEN_COLOR ) { + pageInfo.add( "COLOR" ); + } + else if ( currentPage == SCREEN_EFFECT ) { + pageInfo.add( "EFFECT" ); + } + else { + pageInfo.add( "PRESET" ); + } + + JsonArray powerStateInfo = user.createNestedArray( "CoreS3 Display Power State" ); + + switch ( displayPowerState ) { + case DISPLAY_POWER_ACTIVE: + powerStateInfo.add( "ACTIVE" ); + break; + + case DISPLAY_POWER_SLEEP_FADE_OUT: + powerStateInfo.add( "SLEEP FADE OUT" ); + break; + + case DISPLAY_POWER_SLEEPING: + powerStateInfo.add( "SLEEPING" ); + break; + + case DISPLAY_POWER_WAKE_FADE_IN: + powerStateInfo.add( "WAKE FADE IN" ); + break; + + case DISPLAY_POWER_WAKE_WAIT_RELEASE: + powerStateInfo.add( "WAKE WAIT RELEASE" ); + break; + } + + JsonArray lcdBrightnessInfo = user.createNestedArray( "CoreS3 LCD Brightness" ); + + lcdBrightnessInfo.add( lcdBrightness ); + + JsonArray sleepInfo = user.createNestedArray( "CoreS3 Display Sleep" ); + + if ( sleepTimeoutSec == 0 ) { + sleepInfo.add( "Never" ); + } + else { + char sleepText[24]; + + snprintf( sleepText, sizeof(sleepText), "%u sec", sleepTimeoutSec ); + + sleepInfo.add( sleepText ); + } + + JsonArray fadeInfo = user.createNestedArray( "CoreS3 Display Fade" ); + + if (!fadeEnabled) { + fadeInfo.add( "OFF" ); + } + else { + char fadeText[24]; + + snprintf( fadeText, sizeof(fadeText), "ON (%u ms)", fadeDurationMs ); + + fadeInfo.add( fadeText ); + } + + } +}; + +static CoreS3DisplayUsermod coreS3DisplayUsermod; + +REGISTER_USERMOD(coreS3DisplayUsermod); diff --git a/usermods/CoreS3_Display/CoreS3_WLED_Logo.h b/usermods/CoreS3_Display/CoreS3_WLED_Logo.h new file mode 100644 index 0000000000..1acff82c74 --- /dev/null +++ b/usermods/CoreS3_Display/CoreS3_WLED_Logo.h @@ -0,0 +1,164 @@ +#pragma once + +#include + +// WLED startup logo for M5Stack CoreS3 +// Source image resized to 304 x 95 pixels using nearest-neighbor scaling. +// Embedded as PNG bytes for M5GFX::drawPng(). + +static constexpr uint16_t CORES3_WLED_LOGO_WIDTH = 304; +static constexpr uint16_t CORES3_WLED_LOGO_HEIGHT = 95; + +static const uint8_t CORES3_WLED_LOGO_PNG[] PROGMEM = { + 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0x00, 0x00, 0x00, 0x0D, 0x49, 0x48, 0x44, 0x52, + 0x00, 0x00, 0x01, 0x30, 0x00, 0x00, 0x00, 0x5F, 0x08, 0x06, 0x00, 0x00, 0x00, 0x6F, 0x2E, 0x9B, + 0xC8, 0x00, 0x00, 0x09, 0x15, 0x49, 0x44, 0x41, 0x54, 0x78, 0xDA, 0xED, 0xDD, 0x51, 0x6C, 0x14, + 0xC7, 0x19, 0xC0, 0xF1, 0x6F, 0x2F, 0x1C, 0x01, 0x07, 0xD1, 0x9C, 0xF0, 0x19, 0x0B, 0xDB, 0x08, + 0x2A, 0x44, 0x64, 0x70, 0x94, 0xB3, 0x03, 0xED, 0x43, 0x22, 0xD1, 0x52, 0xA5, 0xAD, 0x14, 0xD4, + 0xA6, 0x55, 0x05, 0x55, 0x14, 0xA9, 0x6D, 0x12, 0x30, 0xA9, 0xFA, 0x90, 0xA0, 0x3C, 0x10, 0x25, + 0x12, 0x8B, 0x54, 0xB5, 0x79, 0x88, 0x5C, 0x5E, 0xDA, 0xDA, 0x86, 0x22, 0x1A, 0xE5, 0xA1, 0x58, + 0x15, 0xB4, 0x15, 0x48, 0x51, 0x23, 0x45, 0xC9, 0x43, 0xFB, 0xD0, 0x34, 0xF6, 0x49, 0x05, 0x47, + 0x44, 0x51, 0x8D, 0x94, 0x60, 0x39, 0x47, 0x24, 0x2C, 0x44, 0x28, 0xB6, 0xE1, 0xAE, 0x0F, 0xBE, + 0xBD, 0x1B, 0xDF, 0xCE, 0xDD, 0xED, 0xDE, 0xED, 0xDE, 0xCE, 0xDA, 0xFF, 0xDF, 0x0B, 0xA7, 0xF3, + 0xDC, 0xEE, 0xCC, 0xCE, 0x32, 0xF7, 0x7D, 0xB3, 0x3B, 0x7B, 0x22, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x10, 0x24, 0xCB, 0xA8, 0xDA, 0xBC, 0x36, 0x59, 0x28, 0xBD, 0xEE, + 0xEC, 0x8D, 0xB4, 0x2A, 0x03, 0x1D, 0xE5, 0xD7, 0xE3, 0xFB, 0x2D, 0x8B, 0x53, 0x05, 0x30, 0x4F, + 0x82, 0x43, 0x00, 0x20, 0xAE, 0x56, 0x99, 0x1A, 0x75, 0x15, 0x7E, 0xF8, 0x96, 0xFF, 0x70, 0xF2, + 0xDC, 0x33, 0x9E, 0x3F, 0xEF, 0x94, 0xCD, 0x7C, 0xE5, 0x86, 0x4C, 0x7C, 0xEB, 0x62, 0xCD, 0xB2, + 0x8F, 0x8E, 0x15, 0x0A, 0x44, 0x62, 0x00, 0x11, 0x18, 0x00, 0x30, 0x80, 0x01, 0x40, 0x74, 0x29, + 0xA4, 0x93, 0x3A, 0xAA, 0x93, 0xF5, 0x33, 0x1F, 0xB9, 0x53, 0xBD, 0xDF, 0x3D, 0x5A, 0x4E, 0x0B, + 0x7F, 0xFE, 0xA1, 0xEB, 0x7D, 0xF5, 0x3D, 0xDD, 0xE7, 0x53, 0x7F, 0xC8, 0xC8, 0xEC, 0xDC, 0x7D, + 0xD5, 0xCB, 0xDE, 0xBD, 0x5D, 0x77, 0x5F, 0xE3, 0xB9, 0xC5, 0x7F, 0x07, 0x8A, 0xA9, 0x24, 0xE9, + 0x24, 0x40, 0x04, 0x06, 0x00, 0x0C, 0x60, 0x00, 0x48, 0x21, 0x63, 0x6F, 0x49, 0x8A, 0x18, 0x60, + 0xD9, 0x28, 0xBC, 0xD3, 0x91, 0x29, 0xA5, 0xAB, 0xDB, 0x92, 0x0F, 0x94, 0xDE, 0xBF, 0xFF, 0xE1, + 0x87, 0x5C, 0x65, 0x37, 0xBD, 0x7D, 0xDA, 0xC8, 0x74, 0x76, 0xF2, 0x69, 0x29, 0x88, 0x88, 0xF4, + 0xA6, 0x94, 0x37, 0xBB, 0x07, 0xDC, 0xD3, 0x04, 0xAF, 0x8C, 0x1B, 0x59, 0xFF, 0xF7, 0xDE, 0xEC, + 0x2A, 0xF7, 0xC1, 0xE6, 0xA4, 0x88, 0x88, 0x24, 0xD7, 0xED, 0xD4, 0x96, 0xDD, 0xB8, 0xEB, 0xA2, + 0x71, 0x6D, 0xD8, 0x3B, 0xB6, 0xBB, 0x54, 0xFF, 0x75, 0x5B, 0xD6, 0x96, 0xDE, 0xDF, 0xD6, 0xB6, + 0xDD, 0x55, 0x76, 0xA8, 0xEF, 0x54, 0x2C, 0xA7, 0x44, 0x88, 0xC0, 0x00, 0x10, 0x81, 0x45, 0x45, + 0x9D, 0x78, 0x0F, 0xB2, 0x6C, 0x54, 0xD1, 0x96, 0x2E, 0xEA, 0x52, 0xCD, 0xFD, 0xE7, 0x8A, 0xEB, + 0xBD, 0xE9, 0xEF, 0x3E, 0x5B, 0x88, 0x32, 0x2A, 0x73, 0x22, 0xAD, 0x4A, 0x4B, 0x22, 0x2F, 0xC7, + 0x67, 0xE3, 0xEE, 0x68, 0xF8, 0xD7, 0x03, 0xDA, 0xCF, 0xB7, 0x32, 0x32, 0x53, 0xA3, 0xAD, 0xCA, + 0xA8, 0x4B, 0xB5, 0x70, 0xEB, 0xB2, 0xF6, 0xF3, 0x9F, 0xFF, 0xFB, 0xC9, 0x42, 0x94, 0x51, 0x99, + 0x1A, 0x6D, 0xE9, 0xA2, 0x2E, 0xD5, 0x27, 0xB7, 0x3F, 0x76, 0xBD, 0x77, 0xE4, 0xD2, 0xF3, 0xDA, + 0x3E, 0x30, 0x3D, 0x32, 0x23, 0x02, 0x03, 0x10, 0x5B, 0x0C, 0x60, 0x00, 0xE2, 0x9B, 0x81, 0x45, + 0xB6, 0x67, 0x8F, 0xF7, 0x81, 0x55, 0x15, 0xC6, 0x62, 0xEF, 0x6A, 0xFB, 0x2F, 0xEE, 0x2B, 0xC8, + 0x05, 0xDE, 0xD5, 0x26, 0xE9, 0x83, 0xA2, 0x4E, 0xF6, 0x87, 0x95, 0x4E, 0x6A, 0x27, 0xE9, 0x83, + 0xE4, 0x4C, 0xF8, 0xE7, 0xF3, 0xFD, 0xD6, 0xAB, 0xD9, 0x6C, 0x98, 0x69, 0xA3, 0x2E, 0x5D, 0x6C, + 0x96, 0x3A, 0xE1, 0x1F, 0x46, 0x3A, 0x59, 0x6D, 0x92, 0x3E, 0x48, 0xCE, 0x84, 0xBF, 0xA9, 0xA9, + 0x24, 0x11, 0x18, 0x00, 0x52, 0x48, 0x00, 0x58, 0xD9, 0x29, 0xA4, 0xE1, 0x9A, 0x4D, 0x21, 0xC3, + 0x4E, 0x1B, 0x5B, 0x91, 0x4E, 0xAA, 0x57, 0x1C, 0x43, 0x4B, 0x1D, 0x2B, 0x6D, 0xEC, 0x15, 0x49, + 0x2E, 0xA6, 0x48, 0xCD, 0x5E, 0x99, 0x0C, 0x3B, 0x6D, 0x6C, 0x45, 0x3A, 0xE9, 0xA4, 0x8E, 0x61, + 0xA5, 0x8D, 0xB5, 0x52, 0x49, 0xD3, 0xD2, 0x49, 0x22, 0x30, 0x00, 0xB1, 0xB5, 0x6A, 0x25, 0x35, + 0xF6, 0xDD, 0x6F, 0xDF, 0x28, 0xBD, 0x4E, 0xA5, 0x6A, 0x87, 0x0F, 0xFD, 0x67, 0x5B, 0x5F, 0xBF, + 0xD5, 0x2F, 0x3F, 0x2B, 0xF7, 0xED, 0xEA, 0x73, 0xD5, 0x6F, 0xFA, 0x3B, 0x3F, 0x73, 0x95, 0x5D, + 0xFB, 0xA7, 0x21, 0x6D, 0x5B, 0x74, 0x65, 0xA3, 0x72, 0x63, 0xF0, 0x5D, 0xF7, 0xB1, 0x7E, 0xBD, + 0x3F, 0x36, 0xE7, 0x4B, 0xF2, 0xAB, 0x6F, 0x79, 0x3E, 0x5F, 0x74, 0x72, 0x1F, 0x3E, 0x19, 0x49, + 0xBD, 0x8F, 0x6D, 0x7A, 0xA3, 0xE1, 0x3A, 0x8B, 0x88, 0xBC, 0x78, 0xF9, 0x39, 0x06, 0x30, 0x13, + 0xF5, 0x3D, 0x78, 0xB7, 0xF4, 0x3A, 0xDD, 0x6E, 0x5E, 0xFD, 0x12, 0x9B, 0x3A, 0x24, 0xF1, 0xD0, + 0xD6, 0xC5, 0xC1, 0x2C, 0x9D, 0xAE, 0x5D, 0xB6, 0x58, 0xCE, 0x4B, 0xD9, 0xA8, 0xDC, 0xED, 0xEC, + 0x2B, 0x1E, 0xEC, 0x74, 0x2C, 0xCF, 0x17, 0x6B, 0xCD, 0x76, 0x25, 0x05, 0x8C, 0x4F, 0x1B, 0x7A, + 0x56, 0x6F, 0x59, 0x3C, 0xEC, 0x6D, 0xE9, 0x65, 0xFF, 0x7F, 0x9A, 0x14, 0x12, 0x00, 0x29, 0x64, + 0xDC, 0x5C, 0xBF, 0x7E, 0xBD, 0x4E, 0x89, 0xE0, 0xBE, 0xBD, 0x9C, 0xC9, 0x7B, 0x3F, 0x13, 0xF7, + 0xF5, 0xEB, 0xE7, 0xBD, 0xAC, 0xBA, 0xFC, 0xC8, 0x59, 0x76, 0xE4, 0x67, 0x32, 0x3F, 0x92, 0x89, + 0x7B, 0xD5, 0xE7, 0xE5, 0xFB, 0xF3, 0xD4, 0x65, 0x47, 0x7E, 0x26, 0xF4, 0x9D, 0xC9, 0xFB, 0x46, + 0x27, 0xEE, 0xFD, 0xF4, 0x87, 0x8E, 0xBA, 0x04, 0xC9, 0x59, 0x76, 0xE4, 0x67, 0x32, 0xBF, 0x91, + 0x7B, 0xBE, 0xAA, 0xD5, 0x39, 0x5D, 0x8C, 0x88, 0xE7, 0xE6, 0xE6, 0xE4, 0xE6, 0xCD, 0x9B, 0x9E, + 0xB6, 0xA5, 0x2E, 0x3F, 0x52, 0x97, 0x1D, 0x45, 0x3D, 0xA1, 0x4F, 0x04, 0x06, 0x80, 0x14, 0x12, + 0x00, 0x48, 0x21, 0x03, 0x92, 0xDB, 0xBF, 0x18, 0x3E, 0xB7, 0xB7, 0xB7, 0x8B, 0x55, 0xBC, 0x65, + 0xCB, 0xFA, 0xAD, 0x39, 0x93, 0x9A, 0x0F, 0x4C, 0x9C, 0x77, 0x85, 0xF4, 0xB9, 0xE7, 0x5E, 0x91, + 0x2F, 0x8F, 0xBC, 0xEE, 0xE9, 0xF3, 0x5F, 0xF6, 0xFF, 0xA0, 0xE6, 0x76, 0xD3, 0xCA, 0xC4, 0xF9, + 0xD5, 0xEE, 0xC7, 0x39, 0xD3, 0xEB, 0x58, 0xBD, 0xE3, 0x5F, 0xAE, 0xE3, 0x76, 0xED, 0xFD, 0xAD, + 0x46, 0xD7, 0xF9, 0xD4, 0x96, 0x3F, 0xBB, 0xCE, 0x21, 0x11, 0x91, 0xEF, 0x7F, 0xB0, 0xA7, 0x66, + 0x59, 0x47, 0xF6, 0xF6, 0x07, 0xF2, 0xAB, 0xAB, 0xAF, 0x7A, 0xDA, 0xAE, 0x6E, 0x9B, 0x44, 0x60, + 0x00, 0xC0, 0x00, 0x06, 0x80, 0x14, 0x12, 0x08, 0x81, 0x7A, 0x35, 0x2C, 0xCD, 0xE1, 0x08, 0xFD, + 0x18, 0x13, 0x81, 0x01, 0x00, 0x11, 0x58, 0xF8, 0x9C, 0xC9, 0x7A, 0x91, 0xA5, 0x13, 0x99, 0xCB, + 0xE9, 0xBB, 0x5E, 0x37, 0xE1, 0x6F, 0xD2, 0xC4, 0xFC, 0xF5, 0x63, 0x39, 0x6D, 0x1F, 0xA4, 0x7F, + 0x11, 0xEF, 0x9F, 0xCE, 0xEC, 0xDA, 0x33, 0xE5, 0xB9, 0x6C, 0x14, 0x13, 0xFE, 0xCF, 0x5F, 0xFD, + 0xD1, 0x8A, 0x1F, 0xC0, 0x88, 0xC0, 0x00, 0x30, 0x80, 0x01, 0x80, 0x71, 0x29, 0xE4, 0x54, 0xD7, + 0x63, 0xA5, 0x65, 0x03, 0x5B, 0xAF, 0xFD, 0xC3, 0xAA, 0xF6, 0x9E, 0x69, 0xF4, 0x93, 0x9A, 0x4C, + 0x21, 0xA3, 0xD9, 0x73, 0x08, 0x41, 0x2A, 0x8F, 0x25, 0x85, 0xFE, 0xAD, 0xD7, 0xFE, 0x99, 0xF5, + 0x3B, 0xBE, 0x10, 0x81, 0x01, 0x20, 0x85, 0x04, 0x00, 0x23, 0x52, 0x48, 0xDD, 0x0F, 0xAD, 0xAA, + 0xA1, 0x9D, 0xFA, 0x88, 0xE2, 0x29, 0x11, 0x23, 0xD3, 0xC9, 0x8E, 0x31, 0xD2, 0x45, 0x34, 0x67, + 0x7E, 0xF2, 0x6B, 0x1C, 0x84, 0x10, 0xE8, 0xC7, 0x17, 0x6B, 0x62, 0xAA, 0xEB, 0x31, 0xF1, 0x3B, + 0xBE, 0x10, 0x81, 0x01, 0x58, 0x5E, 0x11, 0xD8, 0x13, 0xB9, 0xAC, 0xA5, 0x1B, 0x2D, 0x9D, 0xE7, + 0x59, 0xA9, 0xCF, 0x97, 0x32, 0x29, 0xEA, 0x5A, 0x3A, 0xE9, 0x1A, 0x9F, 0x08, 0xCC, 0xA9, 0xF7, + 0xDC, 0xFC, 0xBC, 0xCF, 0x36, 0x36, 0x5F, 0x2E, 0xCC, 0x3E, 0x88, 0x63, 0x0C, 0xCC, 0xC4, 0x7D, + 0xF8, 0xC7, 0x43, 0x37, 0xBE, 0xF4, 0xAC, 0x5A, 0x2B, 0xF7, 0x5B, 0x09, 0xDF, 0xE3, 0x0B, 0x11, + 0x18, 0x80, 0xD8, 0x62, 0x00, 0x03, 0xB0, 0xBC, 0x52, 0xC8, 0x7A, 0xE1, 0x9E, 0xFA, 0x5E, 0x58, + 0x9C, 0x25, 0x42, 0xE9, 0x3A, 0x3F, 0x08, 0x61, 0xD2, 0x33, 0xBE, 0xFC, 0xD0, 0x3D, 0xCF, 0x6B, + 0xCD, 0xD0, 0x51, 0x59, 0x33, 0x74, 0xD4, 0xD5, 0x6E, 0xDD, 0xB2, 0x21, 0xDD, 0xF2, 0xA2, 0x6A, + 0x65, 0xC3, 0x96, 0x3E, 0xDE, 0xA1, 0x4F, 0x3F, 0x8A, 0x4B, 0x8C, 0xD2, 0x8D, 0xFE, 0xA8, 0x47, + 0x0B, 0x97, 0x22, 0xE9, 0x26, 0xEC, 0x9D, 0x67, 0x84, 0x35, 0xDA, 0x86, 0xA8, 0x9E, 0x27, 0xE6, + 0x3C, 0xCF, 0xAB, 0x5E, 0x9D, 0xBF, 0x9E, 0x7A, 0x5C, 0xFE, 0xBA, 0xFB, 0x7D, 0xD7, 0xFB, 0xAD, + 0x7C, 0xF6, 0x97, 0x33, 0x96, 0xBC, 0xD3, 0x91, 0x99, 0x10, 0x91, 0x8C, 0xDF, 0xF1, 0x85, 0x08, + 0x0C, 0x00, 0x29, 0x24, 0x00, 0x18, 0x91, 0x42, 0xDA, 0xB6, 0x5D, 0x50, 0x5E, 0x5B, 0xBA, 0x74, + 0xB2, 0x55, 0x5A, 0xF9, 0xEB, 0x41, 0x26, 0x09, 0xF2, 0x57, 0x89, 0xE2, 0xD4, 0x16, 0x53, 0x7B, + 0x36, 0x8E, 0x57, 0x27, 0xE3, 0x54, 0xE7, 0x27, 0x72, 0xD9, 0x7E, 0x3F, 0x63, 0x11, 0x11, 0x18, + 0x80, 0xE5, 0x19, 0x81, 0x89, 0x48, 0xD6, 0x79, 0x71, 0xF0, 0xE0, 0xC1, 0x8C, 0xF3, 0xBA, 0xAB, + 0xAB, 0xAB, 0xE6, 0xC6, 0x6C, 0xDB, 0xCE, 0x06, 0x55, 0xB1, 0x4B, 0xB3, 0x2B, 0xEF, 0x61, 0xB1, + 0xF9, 0xE9, 0x9C, 0x58, 0x57, 0xBC, 0x3D, 0x83, 0x2A, 0x7F, 0x65, 0xCA, 0xFC, 0x93, 0x6B, 0xE6, + 0x52, 0xAC, 0xFB, 0xA3, 0x70, 0xE7, 0xE3, 0x58, 0xD6, 0xFB, 0xD3, 0xF9, 0xAB, 0x46, 0xD7, 0xCF, + 0xB6, 0xED, 0x8C, 0x8F, 0xE2, 0xD9, 0x46, 0x52, 0xC8, 0x52, 0x38, 0x77, 0xF8, 0xF0, 0xE1, 0x82, + 0x8F, 0x9D, 0x05, 0x96, 0x62, 0xEE, 0xFD, 0x7B, 0x4A, 0x56, 0x9A, 0xF9, 0x37, 0x4E, 0x7B, 0x2E, + 0xFB, 0xBF, 0x1F, 0x1F, 0x31, 0xBE, 0x3D, 0xA9, 0x91, 0xBD, 0xB1, 0xEE, 0x8F, 0x85, 0xFF, 0x3E, + 0x13, 0xCB, 0x7A, 0x1F, 0x9F, 0x7E, 0xD9, 0xF4, 0x2A, 0x4E, 0xF8, 0x18, 0xEC, 0xB8, 0x91, 0x15, + 0xC0, 0xF2, 0xD4, 0x50, 0xC4, 0xA4, 0x46, 0x65, 0x9D, 0x9D, 0x9D, 0xBA, 0x22, 0x27, 0x6C, 0xDB, + 0x7E, 0xA9, 0xE6, 0x46, 0x5E, 0x9B, 0x5C, 0xDC, 0x46, 0x67, 0x6F, 0x6C, 0x0E, 0xD6, 0x80, 0x72, + 0xBB, 0xD3, 0xF8, 0x7E, 0xAB, 0xE6, 0xB1, 0x53, 0x97, 0x60, 0xED, 0x1C, 0x18, 0x30, 0xBA, 0x5D, + 0x9B, 0xDE, 0x3E, 0xED, 0x6A, 0xCB, 0xE4, 0xD3, 0xE5, 0x45, 0xB4, 0xBD, 0x0F, 0x0F, 0x98, 0x7E, + 0x16, 0xCF, 0x5A, 0x47, 0xC7, 0x5D, 0x21, 0xFB, 0x7B, 0x6F, 0x76, 0x95, 0xDB, 0xB0, 0x23, 0x63, + 0x74, 0x13, 0x36, 0xEE, 0xBA, 0xE8, 0xEA, 0x83, 0xBD, 0x63, 0xBB, 0x4B, 0xF5, 0xCF, 0xEC, 0x78, + 0xC4, 0xE8, 0xFA, 0x0F, 0xF5, 0x9D, 0xB2, 0xEA, 0x44, 0x52, 0xDA, 0x4C, 0x6E, 0x66, 0x66, 0x46, + 0x44, 0x44, 0xEE, 0xDD, 0xBB, 0xD7, 0x7F, 0xF2, 0xE4, 0xC9, 0xAC, 0xDF, 0xFD, 0x12, 0x81, 0x01, + 0x88, 0x2D, 0x06, 0x30, 0x00, 0xB1, 0xD5, 0xD0, 0xA5, 0xBE, 0xE1, 0xE1, 0x61, 0xCB, 0x47, 0x3A, + 0xE9, 0x99, 0xFD, 0xC5, 0xF1, 0xD2, 0xEB, 0x3D, 0x7B, 0x16, 0x97, 0x33, 0x7C, 0xF3, 0xF2, 0x37, + 0x6A, 0x7E, 0xE6, 0xCC, 0xBA, 0x3F, 0xD6, 0xFC, 0x7B, 0x4F, 0x4F, 0x8F, 0x24, 0x12, 0x09, 0x4F, + 0xDB, 0xD2, 0xED, 0xDF, 0xCB, 0xE7, 0xEA, 0x51, 0x57, 0xD7, 0x9B, 0x42, 0x7D, 0xE6, 0x52, 0x5D, + 0x9F, 0x8D, 0x9B, 0x79, 0xF6, 0x76, 0x7B, 0x4F, 0x6D, 0x17, 0x6E, 0x5D, 0x36, 0xAE, 0xFA, 0xC9, + 0x75, 0x3B, 0x3D, 0x97, 0xFD, 0xE4, 0xB6, 0x99, 0x57, 0x44, 0xB7, 0xB5, 0x6D, 0xF7, 0xFD, 0x19, + 0x27, 0x6D, 0xAC, 0x1C, 0x4B, 0x88, 0xC0, 0x00, 0x90, 0x42, 0x02, 0x40, 0x1C, 0x04, 0xBA, 0x34, + 0x48, 0x77, 0xA5, 0xA1, 0xEA, 0x7D, 0x1C, 0x9A, 0xAB, 0x90, 0x87, 0x2F, 0xBD, 0x20, 0x95, 0xE9, + 0xA8, 0xDD, 0x7E, 0xAC, 0x4A, 0x1C, 0xFA, 0xD1, 0xE2, 0xDF, 0x57, 0x8D, 0xD5, 0xAC, 0xD3, 0xE6, + 0xCD, 0x9B, 0x4B, 0x29, 0xE4, 0x4F, 0x6F, 0xFD, 0xA4, 0x66, 0x59, 0xDD, 0xFE, 0xD5, 0x3A, 0xF8, + 0xB9, 0x0A, 0xA9, 0xDD, 0x7E, 0x95, 0x7B, 0xEA, 0x9C, 0x30, 0x7A, 0x70, 0x70, 0xF0, 0xBC, 0x65, + 0x59, 0x4F, 0xD5, 0x4A, 0xD9, 0xBD, 0x6E, 0xAB, 0xDE, 0xDF, 0x1B, 0xA5, 0xDB, 0x6E, 0xB5, 0x29, + 0x85, 0x7A, 0xFB, 0x0C, 0x72, 0x5B, 0x41, 0xF5, 0x81, 0x9F, 0xE3, 0xA6, 0x2B, 0x9B, 0xCF, 0xE7, + 0x5F, 0x1A, 0x1D, 0x1D, 0x3D, 0x11, 0x87, 0x3E, 0x38, 0x74, 0xE8, 0xD0, 0x53, 0x89, 0x44, 0xE2, + 0x7C, 0x98, 0x7D, 0xE0, 0x6B, 0x4C, 0x20, 0x02, 0x03, 0xB0, 0x92, 0x04, 0xBA, 0x5E, 0xC7, 0x99, + 0x9C, 0x6B, 0x6B, 0x6B, 0x93, 0xF5, 0xEB, 0xD7, 0x7B, 0xFB, 0x36, 0xA9, 0x13, 0xF5, 0xB4, 0xD2, + 0x70, 0xDF, 0xEF, 0xB5, 0xF5, 0xB2, 0x65, 0x71, 0x72, 0xFF, 0x6F, 0x1D, 0xCD, 0xD5, 0xC9, 0xCF, + 0x45, 0x8E, 0x20, 0xCB, 0x36, 0x7B, 0x71, 0x25, 0xEC, 0x36, 0xD4, 0x2B, 0xB7, 0x61, 0xC3, 0x06, + 0x49, 0x26, 0x93, 0xC6, 0xD6, 0xDF, 0xD4, 0x3E, 0x30, 0xA9, 0xFE, 0xEA, 0xC4, 0x7D, 0x90, 0x88, + 0xC0, 0x00, 0xC4, 0x16, 0x03, 0x18, 0x00, 0x52, 0x48, 0x91, 0xF2, 0xE4, 0x9E, 0x6D, 0xDB, 0x2F, + 0x8A, 0xC8, 0x6F, 0x8A, 0xAF, 0xF5, 0xCF, 0xF3, 0xF9, 0xE5, 0x0E, 0x4B, 0x44, 0xA4, 0x53, 0xF9, + 0xFB, 0x92, 0x30, 0xD3, 0xF6, 0x36, 0x49, 0x3E, 0x53, 0x67, 0xB1, 0x79, 0x3E, 0x9F, 0x2F, 0x4D, + 0xE2, 0x7B, 0xDD, 0xA6, 0x88, 0xC8, 0xB0, 0x9A, 0xE6, 0x16, 0xF7, 0xF1, 0x3D, 0x29, 0xDF, 0x27, + 0xE6, 0xE7, 0xCE, 0xA8, 0xB0, 0x27, 0x32, 0x6B, 0xF5, 0x85, 0x5A, 0x7F, 0xB5, 0x2E, 0x7E, 0xF6, + 0x5F, 0x6D, 0x19, 0x48, 0xAB, 0xDA, 0x30, 0x38, 0x38, 0x38, 0x61, 0x59, 0x56, 0xA6, 0xE6, 0xF9, + 0xB4, 0x02, 0xFB, 0x20, 0xEC, 0xFA, 0xAB, 0x6D, 0xD0, 0xD5, 0xBF, 0x5A, 0x1D, 0x74, 0x75, 0x6D, + 0xF6, 0xC2, 0x05, 0x11, 0x18, 0x00, 0x52, 0x48, 0x00, 0x58, 0x96, 0x29, 0xA4, 0x63, 0x7A, 0x7A, + 0xBA, 0x94, 0xB6, 0x2D, 0xB9, 0xB2, 0xA8, 0x09, 0x2D, 0x1B, 0x5D, 0x56, 0xB0, 0x6F, 0xDF, 0x3E, + 0x4F, 0xCF, 0x29, 0xCB, 0xE5, 0x72, 0xE2, 0x35, 0xF4, 0x0D, 0x2B, 0x8C, 0x76, 0xDA, 0x18, 0x56, + 0x18, 0xDD, 0x48, 0x2A, 0xD3, 0x48, 0xFD, 0xA3, 0x6A, 0xC3, 0xC8, 0xC8, 0x48, 0x7F, 0xAD, 0x54, + 0x4C, 0x44, 0x66, 0x6D, 0xDB, 0x4E, 0xD1, 0x07, 0xD1, 0xA5, 0xC3, 0xD5, 0x84, 0x75, 0xF5, 0x91, + 0x08, 0x0C, 0x00, 0x11, 0x98, 0x4E, 0xF1, 0x4E, 0xE4, 0x13, 0x95, 0xA3, 0x75, 0xBD, 0x91, 0x3D, + 0x48, 0x07, 0x0E, 0x1C, 0x10, 0x11, 0x91, 0x73, 0xE7, 0xCE, 0xC9, 0xC2, 0xC2, 0x42, 0xE0, 0xDF, + 0x42, 0x15, 0xDF, 0x9E, 0x91, 0xB4, 0xB1, 0x99, 0x36, 0xC4, 0xBD, 0xFE, 0x15, 0xF5, 0x7E, 0x90, + 0x3E, 0x88, 0x3E, 0x1A, 0x8B, 0xA2, 0xDE, 0x44, 0x60, 0x00, 0x62, 0x8B, 0x01, 0x0C, 0x00, 0x29, + 0xA4, 0x09, 0xA1, 0xAF, 0x93, 0x36, 0x8A, 0x88, 0x9C, 0x3D, 0x7B, 0x36, 0xF0, 0xED, 0xFB, 0x99, + 0x54, 0x6D, 0xF6, 0xBE, 0x9F, 0xB0, 0x38, 0x75, 0x69, 0xA4, 0xFE, 0x26, 0xB4, 0x41, 0xD7, 0x07, + 0x85, 0x42, 0x61, 0x76, 0x64, 0x64, 0x24, 0x45, 0x1F, 0x44, 0x9B, 0x4E, 0x12, 0x81, 0x01, 0x00, + 0x03, 0x18, 0x00, 0x52, 0xC8, 0x98, 0x51, 0xD3, 0xC6, 0x30, 0xAE, 0x42, 0x36, 0x72, 0xEF, 0x98, + 0x69, 0xBC, 0xB6, 0x21, 0xEE, 0xF5, 0xA7, 0x0F, 0x88, 0xC0, 0x00, 0x80, 0x08, 0xAC, 0x55, 0xC2, + 0x9E, 0xC4, 0x07, 0x40, 0x04, 0x06, 0x00, 0x0C, 0x60, 0x00, 0x48, 0x21, 0x63, 0xE9, 0xC2, 0x85, + 0x0B, 0x96, 0xC8, 0xD2, 0x45, 0xDD, 0xF5, 0xD2, 0x46, 0x93, 0xEE, 0x9F, 0x01, 0x40, 0x04, 0x06, + 0x80, 0x01, 0x0C, 0x00, 0x48, 0x21, 0x23, 0x4B, 0x25, 0x2B, 0xD3, 0xC9, 0xEE, 0xEE, 0x6E, 0x11, + 0x11, 0xB9, 0x73, 0xE7, 0x4E, 0xEA, 0xCC, 0x99, 0x33, 0xB3, 0xCB, 0xAD, 0xE3, 0x96, 0xC3, 0x3D, + 0x42, 0x71, 0x6F, 0x43, 0xDC, 0xEB, 0x3F, 0x3A, 0x3A, 0xFA, 0x17, 0x09, 0xF8, 0xB7, 0x61, 0x89, + 0xC0, 0x00, 0x80, 0x01, 0x0C, 0x00, 0x03, 0x18, 0x00, 0x30, 0x80, 0x01, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x98, 0xEC, 0xFF, 0xB1, 0xC1, 0xD0, 0xE0, 0x9F, 0xE6, + 0x52, 0x5D, 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4E, 0x44, 0xAE, 0x42, 0x60, 0x82 +}; + +static constexpr size_t CORES3_WLED_LOGO_PNG_LEN = sizeof(CORES3_WLED_LOGO_PNG); diff --git a/usermods/CoreS3_Display/CoreS3_WLED_Logo_304x95.png b/usermods/CoreS3_Display/CoreS3_WLED_Logo_304x95.png new file mode 100644 index 0000000000..a3511c96c8 Binary files /dev/null and b/usermods/CoreS3_Display/CoreS3_WLED_Logo_304x95.png differ diff --git a/usermods/CoreS3_Display/M5StackDisplayHardwareBackend.cpp b/usermods/CoreS3_Display/M5StackDisplayHardwareBackend.cpp new file mode 100644 index 0000000000..606a188b1b --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayHardwareBackend.cpp @@ -0,0 +1,595 @@ +#include "wled.h" + +#include "M5StackDisplayHardwareBackend.h" + +#include + +// =========================================================== +// Compile-time M5Stack hardware profile +// +// CoreS3 remains the default verified runtime. +// Core2 / Core2 for AWS remain diagnostic-only until their +// Display / Touch / Power paths are verified on real hardware. +// =========================================================== + +#define WLED_M5STACK_DISPLAY_PROFILE_CORES3 0 +#define WLED_M5STACK_DISPLAY_PROFILE_CORE2 1 +#define WLED_M5STACK_DISPLAY_PROFILE_CORE2_AWS 2 + +#define WLED_M5STACK_DISPLAY_REVISION_UNKNOWN 0 +#define WLED_M5STACK_DISPLAY_REVISION_V1_0 10 +#define WLED_M5STACK_DISPLAY_REVISION_V1_1 11 +#define WLED_M5STACK_DISPLAY_REVISION_V1_3 13 + +#ifndef WLED_M5STACK_DISPLAY_PROFILE + #define WLED_M5STACK_DISPLAY_PROFILE WLED_M5STACK_DISPLAY_PROFILE_CORES3 +#endif + +#ifndef WLED_M5STACK_DISPLAY_REVISION + #define WLED_M5STACK_DISPLAY_REVISION WLED_M5STACK_DISPLAY_REVISION_UNKNOWN +#endif + +#ifndef WLED_M5STACK_CORE2_DIAGNOSTIC_ONLY + #define WLED_M5STACK_CORE2_DIAGNOSTIC_ONLY 0 +#endif + +static_assert( + WLED_M5STACK_DISPLAY_PROFILE >= WLED_M5STACK_DISPLAY_PROFILE_CORES3 && + WLED_M5STACK_DISPLAY_PROFILE <= WLED_M5STACK_DISPLAY_PROFILE_CORE2_AWS, + "Invalid WLED_M5STACK_DISPLAY_PROFILE" +); + +static_assert( + WLED_M5STACK_DISPLAY_REVISION == WLED_M5STACK_DISPLAY_REVISION_UNKNOWN || + WLED_M5STACK_DISPLAY_REVISION == WLED_M5STACK_DISPLAY_REVISION_V1_0 || + WLED_M5STACK_DISPLAY_REVISION == WLED_M5STACK_DISPLAY_REVISION_V1_1 || + WLED_M5STACK_DISPLAY_REVISION == WLED_M5STACK_DISPLAY_REVISION_V1_3, + "Invalid WLED_M5STACK_DISPLAY_REVISION" +); + +static_assert( + WLED_M5STACK_CORE2_DIAGNOSTIC_ONLY == 0 || + WLED_M5STACK_CORE2_DIAGNOSTIC_ONLY == 1, + "WLED_M5STACK_CORE2_DIAGNOSTIC_ONLY must be 0 or 1" +); + +static_assert( + WLED_M5STACK_DISPLAY_PROFILE == WLED_M5STACK_DISPLAY_PROFILE_CORES3 || + WLED_M5STACK_CORE2_DIAGNOSTIC_ONLY == 1, + "Core2-family profiles require WLED_M5STACK_CORE2_DIAGNOSTIC_ONLY=1" +); + +static_assert( + WLED_M5STACK_CORE2_DIAGNOSTIC_ONLY == 0 || + WLED_M5STACK_DISPLAY_PROFILE == WLED_M5STACK_DISPLAY_PROFILE_CORE2 || + WLED_M5STACK_DISPLAY_PROFILE == WLED_M5STACK_DISPLAY_PROFILE_CORE2_AWS, + "Diagnostic-only mode is reserved for Core2-family profiles" +); + +enum M5StackDisplayHardwareProfile : uint8_t { + M5STACK_DISPLAY_HARDWARE_CORES3 = WLED_M5STACK_DISPLAY_PROFILE_CORES3, + M5STACK_DISPLAY_HARDWARE_CORE2 = WLED_M5STACK_DISPLAY_PROFILE_CORE2, + M5STACK_DISPLAY_HARDWARE_CORE2_AWS = WLED_M5STACK_DISPLAY_PROFILE_CORE2_AWS +}; + +enum M5StackDisplayHardwareRevision : uint8_t { + M5STACK_DISPLAY_REVISION_UNKNOWN = WLED_M5STACK_DISPLAY_REVISION_UNKNOWN, + M5STACK_DISPLAY_REVISION_V1_0 = WLED_M5STACK_DISPLAY_REVISION_V1_0, + M5STACK_DISPLAY_REVISION_V1_1 = WLED_M5STACK_DISPLAY_REVISION_V1_1, + M5STACK_DISPLAY_REVISION_V1_3 = WLED_M5STACK_DISPLAY_REVISION_V1_3 +}; + +static constexpr M5StackDisplayHardwareProfile ACTIVE_M5STACK_DISPLAY_PROFILE = + static_cast( WLED_M5STACK_DISPLAY_PROFILE ); + +static constexpr M5StackDisplayHardwareRevision ACTIVE_M5STACK_DISPLAY_REVISION = + static_cast( WLED_M5STACK_DISPLAY_REVISION ); + +static constexpr bool ACTIVE_M5STACK_CORE2_DIAGNOSTIC_ONLY = + ( WLED_M5STACK_CORE2_DIAGNOSTIC_ONLY == 1 ); + +struct M5StackDisplayHardwareCapabilities { + const char* name; + uint8_t displayRotation; + bool displayRuntimeEnabled; + bool touchRuntimeEnabled; + bool brightnessRuntimeEnabled; + bool batteryRuntimeEnabled; + bool diagnosticProbeEnabled; +}; + +static constexpr M5StackDisplayHardwareCapabilities M5STACK_HARDWARE_CAPABILITIES[] = { + { + "M5Stack CoreS3", + 1, + true, + true, + true, + true, + false + }, + { + "M5Stack Core2", + 1, + false, + false, + false, + false, + true + }, + { + "M5Stack Core2 for AWS", + 1, + false, + false, + false, + false, + true + } +}; + +static constexpr const M5StackDisplayHardwareCapabilities& + ACTIVE_M5STACK_HARDWARE_CAPABILITIES = + M5STACK_HARDWARE_CAPABILITIES[WLED_M5STACK_DISPLAY_PROFILE]; + +// =========================================================== +// CoreS3 runtime battery telemetry +// +// CoreS3 uses AXP2101 at 0x34. After Display initialization the internal +// GPIO12/GPIO11 bus is owned by M5GFX I2C_NUM_1, matching the verified +// CoreS3 Power/Audio runtime architecture. +// +// AXP2101: +// 0x00 bit3 = battery present +// 0x01 6:5 = battery current direction (01 = charging) +// 0xA4 = fuel-gauge battery percentage +// =========================================================== + +static constexpr i2c_port_t CORES3_BATTERY_I2C_PORT = I2C_NUM_1; +static constexpr uint32_t CORES3_BATTERY_I2C_FREQUENCY = 400000; +static constexpr uint8_t CORES3_AXP2101_ADDR = 0x34; +static constexpr uint8_t AXP2101_REG_PMU_STATUS1 = 0x00; +static constexpr uint8_t AXP2101_REG_PMU_STATUS2 = 0x01; +static constexpr uint8_t AXP2101_REG_BATTERY_PERCENT = 0xA4; + +static bool readCoreS3Axp2101Register( uint8_t reg, uint8_t& value ) { + auto result = lgfx::i2c::transactionWriteRead( + CORES3_BATTERY_I2C_PORT, + CORES3_AXP2101_ADDR, + ®, + 1, + &value, + 1, + CORES3_BATTERY_I2C_FREQUENCY + ); + + return result.has_value(); +} + +bool M5StackDisplayHardwareBackend::probeI2CAddress( TwoWire& wire, uint8_t address ) { + wire.beginTransmission( address ); + + return wire.endTransmission() == 0; + } + +bool M5StackDisplayHardwareBackend::readI2CRegister8( TwoWire& wire, uint8_t address, uint8_t reg, uint8_t& value ) { + wire.beginTransmission( address ); + wire.write( reg ); + + if ( wire.endTransmission( false ) != 0 ) { + return false; + } + + size_t received = wire.requestFrom( address, (size_t)1, true ); + + if ( received != 1 || !wire.available() ) { + return false; + } + + value = (uint8_t)wire.read(); + + return true; + } + +void M5StackDisplayHardwareBackend::classifyCore2HardwareProbe( TwoWire& wire ) { + if ( hardwareProbe.address68 ) { + uint8_t chipId = 0; + + // BMI270 CHIP_ID register 0x00 returns 0x24. + if ( readI2CRegister8( wire, 0x68, 0x00, chipId ) && chipId == 0x24 ) { + hardwareProbe.imu = M5STACK_DETECTED_IMU_BMI270; + hardwareProbe.imuChipId = chipId; + } + else { + // MPU6886 WHO_AM_I register 0x75 returns 0x19. + if ( readI2CRegister8( wire, 0x68, 0x75, chipId ) && chipId == 0x19 ) { + hardwareProbe.imu = M5STACK_DETECTED_IMU_MPU6886; + hardwareProbe.imuChipId = chipId; + } + } + } + + if ( ACTIVE_M5STACK_DISPLAY_PROFILE == M5STACK_DISPLAY_HARDWARE_CORE2_AWS ) { + if ( hardwareProbe.address34 ) { + // Both documented Core2 for AWS generations use AXP192. + hardwareProbe.pmu = M5STACK_DETECTED_PMU_AXP192; + } + + if ( hardwareProbe.imu == M5STACK_DETECTED_IMU_BMI270 ) { + hardwareProbe.variant = M5STACK_DETECTED_VARIANT_CORE2_AWS_V1_3; + } + else if ( hardwareProbe.imu == M5STACK_DETECTED_IMU_MPU6886 ) { + // Official legacy Core2 for AWS documentation identifies MPU6886, + // but does not assign the v1.0 / v1.1 name here. Keep it generic. + hardwareProbe.variant = M5STACK_DETECTED_VARIANT_CORE2_AWS_LEGACY; + } + } + else if ( ACTIVE_M5STACK_DISPLAY_PROFILE == M5STACK_DISPLAY_HARDWARE_CORE2 ) { + if ( hardwareProbe.address34 && hardwareProbe.address40 ) { + // Core2 v1.1 signature: AXP2101 + INA3221. + hardwareProbe.pmu = M5STACK_DETECTED_PMU_AXP2101; + hardwareProbe.variant = M5STACK_DETECTED_VARIANT_CORE2_V1_1; + } + else if ( hardwareProbe.address34 ) { + hardwareProbe.pmu = M5STACK_DETECTED_PMU_AXP192; + hardwareProbe.variant = M5STACK_DETECTED_VARIANT_CORE2_LEGACY; + } + } + } + +void M5StackDisplayHardwareBackend::printCore2HardwareProbeResult() { + Serial.printf( + "[CoreS3_Display] Hardware probe: %s, Variant=%s, PMU=%s, IMU=%s", + probeStateName(), + detectedVariantName(), + detectedPmuName(), + detectedImuName() + ); + + if ( hardwareProbe.imuChipId > 0 ) { + Serial.printf( " (ID=0x%02X)", hardwareProbe.imuChipId ); + } + + Serial.println(); + + Serial.printf( + "[CoreS3_Display] Detected revision: %s\n", + detectedRevisionName() + ); + + Serial.printf( + "[CoreS3_Display] Core2 I2C signature: " + "34=%s 35=%s 38=%s 40=%s 51=%s 68=%s\n", + hardwareProbe.address34 ? "YES" : "NO", + hardwareProbe.address35 ? "YES" : "NO", + hardwareProbe.address38 ? "YES" : "NO", + hardwareProbe.address40 ? "YES" : "NO", + hardwareProbe.address51 ? "YES" : "NO", + hardwareProbe.address68 ? "YES" : "NO" + ); + } + +M5StackDisplayHardwareBackend::M5StackDisplayHardwareBackend( M5GFX& displayRef ) + : display( displayRef ) { + } + +const char* M5StackDisplayHardwareBackend::profileName() const { + return ACTIVE_M5STACK_HARDWARE_CAPABILITIES.name; + } + +const char* M5StackDisplayHardwareBackend::revisionName() const { + switch ( ACTIVE_M5STACK_DISPLAY_REVISION ) { + case M5STACK_DISPLAY_REVISION_V1_0: + return "v1.0"; + + case M5STACK_DISPLAY_REVISION_V1_1: + return "v1.1"; + + case M5STACK_DISPLAY_REVISION_V1_3: + return "v1.3"; + + case M5STACK_DISPLAY_REVISION_UNKNOWN: + default: + return "UNKNOWN"; + } + } + +bool M5StackDisplayHardwareBackend::isDisplayRuntimeEnabled() const { + return ACTIVE_M5STACK_HARDWARE_CAPABILITIES.displayRuntimeEnabled; + } + +bool M5StackDisplayHardwareBackend::isCore2FamilyProfile() const { + return ACTIVE_M5STACK_HARDWARE_CAPABILITIES.diagnosticProbeEnabled; + } + +bool M5StackDisplayHardwareBackend::isCore2DiagnosticOnlyMode() const { + return isCore2FamilyProfile() && ACTIVE_M5STACK_CORE2_DIAGNOSTIC_ONLY; + } + +const char* M5StackDisplayHardwareBackend::runtimeModeName() const { + return isCore2DiagnosticOnlyMode() ? "CORE2 DIAGNOSTIC ONLY" : "DISPLAY ACTIVE"; + } + +const char* M5StackDisplayHardwareBackend::portStatusName() const { + if ( ACTIVE_M5STACK_HARDWARE_CAPABILITIES.displayRuntimeEnabled ) { + return "CORES3 VERIFIED DISPLAY RUNTIME"; + } + + if ( isCore2DiagnosticOnlyMode() ) { + return "CORE2 PORT PREPARED - DIAGNOSTIC ONLY"; + } + + return "HARDWARE RUNTIME BLOCKED"; + } + +const char* M5StackDisplayHardwareBackend::probeStateName() const { + switch ( hardwareProbe.state ) { + case M5STACK_HARDWARE_PROBE_NOT_REQUIRED: + return "NOT REQUIRED"; + + case M5STACK_HARDWARE_PROBE_COMPLETE: + return "COMPLETE"; + + case M5STACK_HARDWARE_PROBE_FAILED: + return "FAILED"; + + case M5STACK_HARDWARE_PROBE_NOT_RUN: + default: + return "NOT RUN"; + } + } + +const char* M5StackDisplayHardwareBackend::detectedPmuName() const { + switch ( hardwareProbe.pmu ) { + case M5STACK_DETECTED_PMU_AXP192: + return "AXP192 signature"; + + case M5STACK_DETECTED_PMU_AXP2101: + return "AXP2101 signature"; + + case M5STACK_DETECTED_PMU_UNKNOWN: + default: + return "UNKNOWN"; + } + } + +const char* M5StackDisplayHardwareBackend::detectedImuName() const { + switch ( hardwareProbe.imu ) { + case M5STACK_DETECTED_IMU_MPU6886: + return "MPU6886"; + + case M5STACK_DETECTED_IMU_BMI270: + return "BMI270"; + + case M5STACK_DETECTED_IMU_UNKNOWN: + default: + return "UNKNOWN"; + } + } + +const char* M5StackDisplayHardwareBackend::detectedVariantName() const { + switch ( hardwareProbe.variant ) { + case M5STACK_DETECTED_VARIANT_CORE2_LEGACY: + return "Core2 legacy signature"; + + case M5STACK_DETECTED_VARIANT_CORE2_V1_1: + return "Core2 v1.1 signature"; + + case M5STACK_DETECTED_VARIANT_CORE2_AWS_LEGACY: + return "Core2 for AWS MPU6886 generation"; + + case M5STACK_DETECTED_VARIANT_CORE2_AWS_V1_3: + return "Core2 for AWS v1.3 signature"; + + case M5STACK_DETECTED_VARIANT_UNKNOWN: + default: + return "UNKNOWN"; + } + } + +const char* M5StackDisplayHardwareBackend::detectedRevisionName() const { + switch ( hardwareProbe.variant ) { + case M5STACK_DETECTED_VARIANT_CORE2_V1_1: + return "v1.1 signature"; + + case M5STACK_DETECTED_VARIANT_CORE2_AWS_V1_3: + return "v1.3 signature"; + + case M5STACK_DETECTED_VARIANT_CORE2_AWS_LEGACY: + return "Legacy MPU6886 generation; exact revision unknown"; + + case M5STACK_DETECTED_VARIANT_CORE2_LEGACY: + return "Legacy generation; exact revision unknown"; + + case M5STACK_DETECTED_VARIANT_UNKNOWN: + default: + return "UNKNOWN"; + } + } + +bool M5StackDisplayHardwareBackend::isProbeComplete() const { + return hardwareProbe.state == M5STACK_HARDWARE_PROBE_COMPLETE; + } + +bool M5StackDisplayHardwareBackend::hasI2CAddress( uint8_t address ) const { + switch ( address ) { + case 0x34: + return hardwareProbe.address34; + + case 0x35: + return hardwareProbe.address35; + + case 0x38: + return hardwareProbe.address38; + + case 0x40: + return hardwareProbe.address40; + + case 0x51: + return hardwareProbe.address51; + + case 0x68: + return hardwareProbe.address68; + + default: + return false; + } + } + +void M5StackDisplayHardwareBackend::runDiagnostics() { + hardwareProbe = HardwareProbeResult(); + + if ( !ACTIVE_M5STACK_HARDWARE_CAPABILITIES.diagnosticProbeEnabled ) { + hardwareProbe.state = M5STACK_HARDWARE_PROBE_NOT_REQUIRED; + + return; + } + + TwoWire probeWire( 1 ); + + if ( !probeWire.begin( CORE2_INTERNAL_I2C_SDA, CORE2_INTERNAL_I2C_SCL, CORE2_INTERNAL_I2C_FREQUENCY ) ) { + hardwareProbe.state = M5STACK_HARDWARE_PROBE_FAILED; + + Serial.println( F( "[CoreS3_Display] ERROR: Core2 diagnostic I2C start failed" ) ); + + return; + } + + delay( 10 ); + + hardwareProbe.address34 = probeI2CAddress( probeWire, 0x34 ); + hardwareProbe.address35 = probeI2CAddress( probeWire, 0x35 ); + hardwareProbe.address38 = probeI2CAddress( probeWire, 0x38 ); + hardwareProbe.address40 = probeI2CAddress( probeWire, 0x40 ); + hardwareProbe.address51 = probeI2CAddress( probeWire, 0x51 ); + hardwareProbe.address68 = probeI2CAddress( probeWire, 0x68 ); + + classifyCore2HardwareProbe( probeWire ); + + probeWire.end(); + + hardwareProbe.state = M5STACK_HARDWARE_PROBE_COMPLETE; + + printCore2HardwareProbeResult(); + } + +bool M5StackDisplayHardwareBackend::initializeDisplay( int16_t& screenWidth, int16_t& screenHeight, bool& touchReady ) { + if ( !isDisplayRuntimeEnabled() ) { + Serial.printf( + "[CoreS3_Display] Hardware profile not enabled for Display runtime: %s (%s)\n", + profileName(), + revisionName() + ); + + return false; + } + + display.begin(); + + display.setRotation( ACTIVE_M5STACK_HARDWARE_CAPABILITIES.displayRotation ); + + screenWidth = display.width(); + screenHeight = display.height(); + + Serial.printf( "[CoreS3_Display] " "Display size: %d x %d\n", screenWidth, screenHeight ); + + if ( screenWidth <= 0 || screenHeight <= 0 ) { + Serial.println( F( "[CoreS3_Display] " "ERROR: Display not detected" ) ); + + return false; + } + + touchReady = ( display.touch() != nullptr ); + + Serial.printf( "[CoreS3_Display] " "Touch: %s\n", touchReady ? "READY" : "NOT FOUND" ); + + return true; + } + +bool M5StackDisplayHardwareBackend::readTouch( int16_t& touchX, int16_t& touchY ) { + if ( !ACTIVE_M5STACK_HARDWARE_CAPABILITIES.touchRuntimeEnabled ) { + return false; + } + + return ( display.getTouch( &touchX, &touchY ) > 0 ); + } + +void M5StackDisplayHardwareBackend::writeBrightness( uint8_t value ) { + if ( !ACTIVE_M5STACK_HARDWARE_CAPABILITIES.brightnessRuntimeEnabled ) { + return; + } + + display.setBrightness( value ); + } + + +bool M5StackDisplayHardwareBackend::readDisplayRgb565( + uint16_t* pixels, + int16_t width, + int16_t height +) { + if ( + !ACTIVE_M5STACK_HARDWARE_CAPABILITIES.displayRuntimeEnabled || + pixels == nullptr || + width <= 0 || + height <= 0 + ) { + return false; + } + + if ( width != display.width() || height != display.height() ) { + return false; + } + + display.readRect( + 0, + 0, + width, + height, + pixels + ); + + return true; + } + + +bool M5StackDisplayHardwareBackend::readBatteryStatus( M5StackBatteryStatus& status ) { + status = M5StackBatteryStatus(); + + if ( !ACTIVE_M5STACK_HARDWARE_CAPABILITIES.batteryRuntimeEnabled ) { + return false; + } + + if ( ACTIVE_M5STACK_DISPLAY_PROFILE != M5STACK_DISPLAY_HARDWARE_CORES3 ) { + return false; + } + + uint8_t status1 = 0; + uint8_t status2 = 0; + uint8_t batteryPercent = 0; + + if ( + !readCoreS3Axp2101Register( AXP2101_REG_PMU_STATUS1, status1 ) || + !readCoreS3Axp2101Register( AXP2101_REG_PMU_STATUS2, status2 ) || + !readCoreS3Axp2101Register( AXP2101_REG_BATTERY_PERCENT, batteryPercent ) + ) { + return false; + } + + status.available = true; + status.present = ( status1 & 0x08 ) != 0; + + const uint8_t batteryDirection = ( status2 >> 5 ) & 0x03; + status.charging = ( batteryDirection == 0x01 ); + + // AXP2101 REG A4 is a direct percentage value. Treat values outside + // the documented 0..100 range as invalid telemetry. + if ( batteryPercent > 100 ) { + status.available = false; + return false; + } + + status.level = batteryPercent; + + return true; + } diff --git a/usermods/CoreS3_Display/M5StackDisplayHardwareBackend.h b/usermods/CoreS3_Display/M5StackDisplayHardwareBackend.h new file mode 100644 index 0000000000..a3ef44a5bf --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayHardwareBackend.h @@ -0,0 +1,124 @@ +#pragma once + +#include +#include + +// M5Stack display hardware boundary. +// +// This class contains only board-dependent Display / Touch / brightness +// access and Core2-family read-only hardware diagnostics. +// UI/WLED behavior remains in CoreS3_Display.cpp. +struct M5StackBatteryStatus { + bool available = false; + bool present = false; + bool charging = false; + uint8_t level = 0; +}; + +class M5StackDisplayHardwareBackend { + private: + + enum M5StackHardwareProbeState : uint8_t { + M5STACK_HARDWARE_PROBE_NOT_REQUIRED = 0, + M5STACK_HARDWARE_PROBE_NOT_RUN, + M5STACK_HARDWARE_PROBE_COMPLETE, + M5STACK_HARDWARE_PROBE_FAILED + }; + + enum M5StackDetectedPmu : uint8_t { + M5STACK_DETECTED_PMU_UNKNOWN = 0, + M5STACK_DETECTED_PMU_AXP192, + M5STACK_DETECTED_PMU_AXP2101 + }; + + enum M5StackDetectedImu : uint8_t { + M5STACK_DETECTED_IMU_UNKNOWN = 0, + M5STACK_DETECTED_IMU_MPU6886, + M5STACK_DETECTED_IMU_BMI270 + }; + + enum M5StackDetectedVariant : uint8_t { + M5STACK_DETECTED_VARIANT_UNKNOWN = 0, + M5STACK_DETECTED_VARIANT_CORE2_LEGACY, + M5STACK_DETECTED_VARIANT_CORE2_V1_1, + M5STACK_DETECTED_VARIANT_CORE2_AWS_LEGACY, + M5STACK_DETECTED_VARIANT_CORE2_AWS_V1_3 + }; + + struct HardwareProbeResult { + M5StackHardwareProbeState state = M5STACK_HARDWARE_PROBE_NOT_RUN; + M5StackDetectedPmu pmu = M5STACK_DETECTED_PMU_UNKNOWN; + M5StackDetectedImu imu = M5STACK_DETECTED_IMU_UNKNOWN; + M5StackDetectedVariant variant = M5STACK_DETECTED_VARIANT_UNKNOWN; + + bool address34 = false; + bool address35 = false; + bool address38 = false; + bool address40 = false; + bool address51 = false; + bool address68 = false; + + uint8_t imuChipId = 0; + }; + + static constexpr int CORE2_INTERNAL_I2C_SDA = 21; + static constexpr int CORE2_INTERNAL_I2C_SCL = 22; + static constexpr uint32_t CORE2_INTERNAL_I2C_FREQUENCY = 400000; + + M5GFX& display; + HardwareProbeResult hardwareProbe; + + bool probeI2CAddress( TwoWire& wire, uint8_t address ); + bool readI2CRegister8( TwoWire& wire, uint8_t address, uint8_t reg, uint8_t& value ); + + void classifyCore2HardwareProbe( TwoWire& wire ); + void printCore2HardwareProbeResult(); + + public: + + explicit M5StackDisplayHardwareBackend( M5GFX& displayRef ); + + const char* profileName() const; + const char* revisionName() const; + + bool isDisplayRuntimeEnabled() const; + bool isCore2FamilyProfile() const; + bool isCore2DiagnosticOnlyMode() const; + + const char* runtimeModeName() const; + const char* portStatusName() const; + + const char* probeStateName() const; + const char* detectedPmuName() const; + const char* detectedImuName() const; + const char* detectedVariantName() const; + const char* detectedRevisionName() const; + + bool isProbeComplete() const; + bool hasI2CAddress( uint8_t address ) const; + + void runDiagnostics(); + + bool initializeDisplay( + int16_t& screenWidth, + int16_t& screenHeight, + bool& touchReady + ); + + bool readTouch( int16_t& touchX, int16_t& touchY ); + void writeBrightness( uint8_t value ); + + // Read the current physical LCD contents as RGB565. + // The caller owns the buffer; this backend keeps panel access out of the + // UI/WLED-state layer. + bool readDisplayRgb565( + uint16_t* pixels, + int16_t width, + int16_t height + ); + + // Read battery state through the active board-specific hardware backend. + // The UI consumes only this generic status object; PMIC/register details + // stay outside CoreS3_Display.cpp. + bool readBatteryStatus( M5StackBatteryStatus& status ); +}; diff --git a/usermods/CoreS3_Display/M5StackDisplayTouchContext.h b/usermods/CoreS3_Display/M5StackDisplayTouchContext.h new file mode 100644 index 0000000000..d9722870f2 --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayTouchContext.h @@ -0,0 +1,20 @@ +#pragma once + +#include "M5StackDisplayTouchState.h" + +// Explicit input boundary for the Touch state-machine entry points. +// +// The contexts do not own data. They only reference the existing +// M5StackTouchRuntimeState and the current hit-test snapshot/time. +// This prepares Press/Hold/Release for later physical extraction +// without changing the state-machine algorithm. +struct M5StackTouchFrameContext { + M5StackTouchRuntimeState& state; + const M5StackTouchHitState& hit; + unsigned long now; +}; + +struct M5StackTouchReleaseContext { + M5StackTouchRuntimeState& state; + unsigned long now; +}; diff --git a/usermods/CoreS3_Display/M5StackDisplayTouchHelpers.h b/usermods/CoreS3_Display/M5StackDisplayTouchHelpers.h new file mode 100644 index 0000000000..008586af43 --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayTouchHelpers.h @@ -0,0 +1,85 @@ +#pragma once + +#include "M5StackDisplayUI.h" +#include "M5StackDisplayTouchState.h" + +// Stateless helpers shared by the M5Stack controller Touch layer. +// +// These functions do not own runtime state, access hardware, draw UI, +// or modify WLED state. Their bodies are moved from the hardware-verified +// CoreS3_Display implementation without algorithm changes. +namespace M5StackDisplayTouchHelpers { + +inline void resetRepeatTouch( M5StackRepeatTouchState& state ) { + state.pressStart = 0; + state.lastRepeat = 0; + state.longPressActive = false; +} + +inline void beginRepeatTouch( M5StackRepeatTouchState& state, unsigned long now ) { + state.pressStart = now; + state.lastRepeat = now; + state.longPressActive = false; +} + +inline bool serviceRepeatTouch( M5StackRepeatTouchState& state, unsigned long now, unsigned long longPressMs, unsigned long repeatMs ) { + if ( !state.longPressActive && now - state.pressStart >= longPressMs ) { + state.longPressActive = true; + state.lastRepeat = now; + return true; + } + + if ( state.longPressActive && now - state.lastRepeat >= repeatMs ) { + state.lastRepeat = now; + return true; + } + + return false; +} + +inline bool pointInsideRect( int16_t px, int16_t py, const M5StackTouchRect& rect ) { + return ( px >= rect.x && px < rect.x + rect.w && py >= rect.y && py < rect.y + rect.h ); +} + +inline bool isPowerButtonTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_POWER ); } +inline bool isBrightnessDownTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_BRIGHTNESS_DOWN ); } +inline bool isBrightnessUpTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_BRIGHTNESS_UP ); } +inline bool isEffectPrevTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_EFFECT_PREV ); } +inline bool isEffectDetailTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_EFFECT_DETAIL ); } +inline bool isEffectNextTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_EFFECT_NEXT ); } +inline bool isColorButtonTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_COLOR_OPEN ); } +inline bool isPresetOpenButtonTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_PRESET_OPEN ); } +inline bool isBackButtonTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_BACK ); } +inline bool isHueDownTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_HUE_DOWN ); } +inline bool isHueUpTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_HUE_UP ); } +inline bool isSaturationDownTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_SATURATION_DOWN ); } +inline bool isSaturationUpTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_SATURATION_UP ); } +inline bool isSpeedDownTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_SPEED_DOWN ); } +inline bool isSpeedUpTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_SPEED_UP ); } +inline bool isIntensityDownTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_INTENSITY_DOWN ); } +inline bool isIntensityUpTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_INTENSITY_UP ); } +inline bool isPalettePrevTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_PALETTE_PREV ); } +inline bool isPaletteNextTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_PALETTE_NEXT ); } +inline bool isPresetPrevTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_PRESET_PREV ); } +inline bool isPresetNextTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_PRESET_NEXT ); } +inline bool isPresetManageTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_PRESET_MANAGE ); } +inline bool isPresetSaveNewTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_PRESET_SAVE_NEW ); } +inline bool isPresetSaveHoldTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_PRESET_SAVE_HOLD ); } +inline bool isPresetOverwriteOpenTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_OVERWRITE_OPEN ); } +inline bool isPresetOverwritePrevTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_OVERWRITE_PREV ); } +inline bool isPresetOverwriteNextTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_OVERWRITE_NEXT ); } +inline bool isPresetOverwriteHoldTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_OVERWRITE_HOLD ); } +inline bool isPresetDeleteOpenTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_DELETE_OPEN ); } +inline bool isPresetDeletePrevTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_DELETE_PREV ); } +inline bool isPresetDeleteNextTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_DELETE_NEXT ); } +inline bool isPresetDeleteHoldTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_DELETE_HOLD ); } +inline bool isPresetBootOpenTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_BOOT_OPEN ); } +inline bool isPresetBootPrevTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_BOOT_PREV ); } +inline bool isPresetBootNextTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_BOOT_NEXT ); } +inline bool isPresetBootHoldTouched( int16_t x, int16_t y ) { return pointInsideRect( x, y, M5STACK_TOUCH_BOOT_HOLD ); } + +inline bool isTouchTargetPair( M5StackTouchTarget target, M5StackTouchTarget firstTarget, M5StackTouchTarget secondTarget ) { + return target == firstTarget || target == secondTarget; +} + +} // namespace M5StackDisplayTouchHelpers diff --git a/usermods/CoreS3_Display/M5StackDisplayTouchState.h b/usermods/CoreS3_Display/M5StackDisplayTouchState.h new file mode 100644 index 0000000000..db28a4e595 --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayTouchState.h @@ -0,0 +1,193 @@ +#pragma once + +#include + +// Common M5Stack controller Touch state types. +// +// These types contain no hardware access and no WLED operations. +// Touch geometry is defined separately in M5StackDisplayUI.h. +// The runtime state machine lives in M5StackDisplayTouchStateMachine.inc. + +// Touch action selected when a press begins. +enum M5StackTouchTarget : uint8_t { + M5STACK_TOUCH_TARGET_NONE = 0, + + M5STACK_TOUCH_TARGET_POWER, + + M5STACK_TOUCH_TARGET_WIFI_RECOVERY, + + M5STACK_TOUCH_TARGET_BRIGHTNESS_DOWN, M5STACK_TOUCH_TARGET_BRIGHTNESS_UP, + + M5STACK_TOUCH_TARGET_EFFECT_PREV, M5STACK_TOUCH_TARGET_EFFECT_DETAIL, M5STACK_TOUCH_TARGET_EFFECT_NEXT, + + M5STACK_TOUCH_TARGET_COLOR_OPEN, M5STACK_TOUCH_TARGET_PRESET_OPEN, + + M5STACK_TOUCH_TARGET_BACK, + + M5STACK_TOUCH_TARGET_COLOR_SLOT_1, M5STACK_TOUCH_TARGET_COLOR_SLOT_2, M5STACK_TOUCH_TARGET_COLOR_SLOT_3, + + M5STACK_TOUCH_TARGET_HUE_DOWN, M5STACK_TOUCH_TARGET_HUE_UP, + + M5STACK_TOUCH_TARGET_SATURATION_DOWN, M5STACK_TOUCH_TARGET_SATURATION_UP, + + M5STACK_TOUCH_TARGET_SPEED_DOWN, M5STACK_TOUCH_TARGET_SPEED_UP, + + M5STACK_TOUCH_TARGET_INTENSITY_DOWN, M5STACK_TOUCH_TARGET_INTENSITY_UP, + + M5STACK_TOUCH_TARGET_PALETTE_PREV, M5STACK_TOUCH_TARGET_PALETTE_NEXT, + + M5STACK_TOUCH_TARGET_PRESET_PREV, M5STACK_TOUCH_TARGET_PRESET_NEXT, + + M5STACK_TOUCH_TARGET_PRESET_MANAGE, M5STACK_TOUCH_TARGET_PRESET_SAVE_NEW, M5STACK_TOUCH_TARGET_PRESET_SAVE_HOLD, + + M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_OPEN, M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_PREV, M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_NEXT, M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_HOLD, + + M5STACK_TOUCH_TARGET_PRESET_DELETE_OPEN, M5STACK_TOUCH_TARGET_PRESET_DELETE_PREV, M5STACK_TOUCH_TARGET_PRESET_DELETE_NEXT, M5STACK_TOUCH_TARGET_PRESET_DELETE_HOLD, + + M5STACK_TOUCH_TARGET_PRESET_BOOT_OPEN, M5STACK_TOUCH_TARGET_PRESET_BOOT_PREV, M5STACK_TOUCH_TARGET_PRESET_BOOT_NEXT, M5STACK_TOUCH_TARGET_PRESET_BOOT_HOLD +}; + +// One hit-test snapshot built from the current Touch coordinates. +struct M5StackTouchHitState { + bool insidePower = false; + bool insideWiFiRecovery = false; + bool insideBrightnessDown = false; + bool insideBrightnessUp = false; + bool insideEffectPrev = false; + bool insideEffectDetail = false; + bool insideEffectNext = false; + bool insideColor = false; + bool insidePresetOpen = false; + bool insideBack = false; + bool insideColorSlot1 = false; + bool insideColorSlot2 = false; + bool insideColorSlot3 = false; + bool insideHueDown = false; + bool insideHueUp = false; + bool insideSaturationDown = false; + bool insideSaturationUp = false; + bool insideSpeedDown = false; + bool insideSpeedUp = false; + bool insideIntensityDown = false; + bool insideIntensityUp = false; + bool insidePalettePrev = false; + bool insidePaletteNext = false; + bool insidePresetPrev = false; + bool insidePresetNext = false; + bool insidePresetManage = false; + bool insidePresetSaveNew = false; + bool insidePresetSaveHold = false; + bool insidePresetOverwriteOpen = false; + bool insidePresetOverwritePrev = false; + bool insidePresetOverwriteNext = false; + bool insidePresetOverwriteHold = false; + bool insidePresetDeleteOpen = false; + bool insidePresetDeletePrev = false; + bool insidePresetDeleteNext = false; + bool insidePresetDeleteHold = false; + bool insidePresetBootOpen = false; + bool insidePresetBootPrev = false; + bool insidePresetBootNext = false; + bool insidePresetBootHold = false; +}; + +// Shared long-press / repeat timing state. +struct M5StackRepeatTouchState { + unsigned long pressStart = 0; + unsigned long lastRepeat = 0; + bool longPressActive = false; +}; + +// Consolidated runtime state for Touch interaction and feedback. +// +// Field names/defaults intentionally match the previously separate +// CoreS3_Display.cpp members so these phases remain storage-only refactors. +struct M5StackTouchRuntimeState { + unsigned long lastTouchPoll = 0; + unsigned long lastTouchAction = 0; + unsigned long touchReleaseCandidate = 0; + M5StackTouchTarget touchTarget = M5STACK_TOUCH_TARGET_NONE; + bool touchActive = false; + bool lastTouchInsidePower = false; + bool lastTouchInsideWiFiRecovery = false; + bool lastTouchInsideBrightness = false; + bool lastTouchInsideEffect = false; + bool lastTouchInsideEffectDetail = false; + bool lastTouchInsideColor = false; + bool lastTouchInsidePresetOpen = false; + bool lastTouchInsideBack = false; + bool lastTouchInsideColorSlot = false; + bool lastTouchInsideHue = false; + bool lastTouchInsideSaturation = false; + bool lastTouchInsideSpeed = false; + bool lastTouchInsideIntensity = false; + bool lastTouchInsidePalette = false; + bool lastTouchInsidePresetNav = false; + bool lastTouchInsidePresetManage = false; + bool lastTouchInsidePresetSaveNew = false; + bool lastTouchInsidePresetSaveHold = false; + bool lastTouchInsidePresetOverwriteOpen = false; + bool lastTouchInsidePresetOverwriteNav = false; + bool lastTouchInsidePresetOverwriteHold = false; + bool lastTouchInsidePresetDeleteOpen = false; + bool lastTouchInsidePresetDeleteNav = false; + bool lastTouchInsidePresetDeleteHold = false; + bool lastTouchInsidePresetBootOpen = false; + bool lastTouchInsidePresetBootNav = false; + bool lastTouchInsidePresetBootHold = false; + M5StackRepeatTouchState wifiRecoveryHoldState; + M5StackRepeatTouchState brightnessRepeatState; + M5StackRepeatTouchState effectRepeatState; + M5StackRepeatTouchState colorSlotHoldState; + M5StackRepeatTouchState hueRepeatState; + M5StackRepeatTouchState saturationRepeatState; + M5StackRepeatTouchState speedRepeatState; + M5StackRepeatTouchState intensityRepeatState; + M5StackRepeatTouchState paletteRepeatState; + M5StackRepeatTouchState presetRepeatState; + + // ESP32 toolchains use a 16-bit signed short here. + // Using the fundamental type also keeps VS Code IntelliSense from + // mis-parsing these final coordinate members in this header. + signed short lastTouchX = -1; + signed short lastTouchY = -1; + + // Wake Touch state used while the LCD is sleeping/waking. + unsigned long wakeTouchLastPoll = 0; + bool wakeTouchState = false; + unsigned long wakeReleaseCandidate = 0; + + // Visual pressed-state flags used only for button feedback drawing. + bool powerButtonVisualPressed = false; + bool wifiRecoveryVisualPressed = false; + bool brightnessButtonVisualPressed = false; + bool effectButtonVisualPressed = false; + bool effectDetailVisualPressed = false; + bool colorButtonVisualPressed = false; + bool presetOpenButtonVisualPressed = false; + bool backButtonVisualPressed = false; + bool hueButtonVisualPressed = false; + bool saturationButtonVisualPressed = false; + bool speedButtonVisualPressed = false; + bool intensityButtonVisualPressed = false; + bool paletteButtonVisualPressed = false; + bool presetNavButtonVisualPressed = false; + bool presetManageButtonVisualPressed = false; + bool presetSaveNewButtonVisualPressed = false; + bool presetSaveHoldButtonVisualPressed = false; + bool presetOverwriteOpenButtonVisualPressed = false; + bool presetOverwriteNavButtonVisualPressed = false; + bool presetOverwriteHoldButtonVisualPressed = false; + bool presetDeleteOpenButtonVisualPressed = false; + bool presetDeleteNavButtonVisualPressed = false; + bool presetDeleteHoldButtonVisualPressed = false; + bool presetBootOpenButtonVisualPressed = false; + bool presetBootNavButtonVisualPressed = false; + bool presetBootHoldButtonVisualPressed = false; + + // VS Code IntelliSense has occasionally failed to expose the final member + // of this large runtime-state struct even though the ESP32 compiler parses + // it correctly. Keep an unused tail guard so all real runtime members sit + // before the parser-sensitive final position. + bool intellisenseTailGuard = false; +}; diff --git a/usermods/CoreS3_Display/M5StackDisplayTouchStateMachine.inc b/usermods/CoreS3_Display/M5StackDisplayTouchStateMachine.inc new file mode 100644 index 0000000000..5b4bef126d --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayTouchStateMachine.inc @@ -0,0 +1,1720 @@ + // ========================================================= + // Shared paired-touch helpers + // ========================================================= + + + bool isSelectedTouchPairInside( M5StackTouchTarget firstTarget, bool firstInside, M5StackTouchTarget secondTarget, bool secondInside ) { + if ( touchState.touchTarget == firstTarget ) { + return firstInside; + } + + if ( touchState.touchTarget == secondTarget ) { + return secondInside; + } + + return false; + } + + // ========================================================= + // Touch processing + // ========================================================= + + // ========================================================= + // Build one touch hit-test snapshot + // ========================================================= + + M5StackTouchHitState buildTouchHitState( int16_t touchX, int16_t touchY ) { + M5StackTouchHitState hit; + + hit.insidePower = M5StackDisplayTouchHelpers::isPowerButtonTouched( touchX, touchY ); + + if ( currentPage == SCREEN_MAIN ) { + if ( getNetworkAccessMode() == NETWORK_ACCESS_NONE ) { + hit.insideWiFiRecovery = + M5StackDisplayTouchHelpers::pointInsideRect( + touchX, + touchY, + M5STACK_TOUCH_WIFI_RECOVERY + ); + } + + hit.insideBrightnessDown = M5StackDisplayTouchHelpers::isBrightnessDownTouched( touchX, touchY ); + + hit.insideBrightnessUp = M5StackDisplayTouchHelpers::isBrightnessUpTouched( touchX, touchY ); + + hit.insideEffectPrev = M5StackDisplayTouchHelpers::isEffectPrevTouched( touchX, touchY ); + + hit.insideEffectDetail = M5StackDisplayTouchHelpers::isEffectDetailTouched( touchX, touchY ); + + hit.insideEffectNext = M5StackDisplayTouchHelpers::isEffectNextTouched( touchX, touchY ); + + hit.insideColor = M5StackDisplayTouchHelpers::isColorButtonTouched( touchX, touchY ); + + hit.insidePresetOpen = M5StackDisplayTouchHelpers::isPresetOpenButtonTouched( touchX, touchY ); + } + + if ( currentPage == SCREEN_COLOR ) { + hit.insideBack = M5StackDisplayTouchHelpers::isBackButtonTouched( touchX, touchY ); + + const M5StackEffectColorCapabilities colorCapability = + getEffectColorCapabilities( getCurrentEffectMode() ); + + if ( colorCapability.color1 ) { + hit.insideColorSlot1 = + M5StackDisplayTouchHelpers::pointInsideRect( + touchX, + touchY, + M5STACK_TOUCH_COLOR_SLOT_1 + ); + } + + if ( colorCapability.color2 ) { + hit.insideColorSlot2 = + M5StackDisplayTouchHelpers::pointInsideRect( + touchX, + touchY, + M5STACK_TOUCH_COLOR_SLOT_2 + ); + } + + if ( colorCapability.color3 ) { + hit.insideColorSlot3 = + M5StackDisplayTouchHelpers::pointInsideRect( + touchX, + touchY, + M5STACK_TOUCH_COLOR_SLOT_3 + ); + } + + if ( effectUsesAnyColor( colorCapability ) ) { + hit.insideHueDown = M5StackDisplayTouchHelpers::isHueDownTouched( touchX, touchY ); + + hit.insideHueUp = M5StackDisplayTouchHelpers::isHueUpTouched( touchX, touchY ); + + hit.insideSaturationDown = M5StackDisplayTouchHelpers::isSaturationDownTouched( touchX, touchY ); + + hit.insideSaturationUp = M5StackDisplayTouchHelpers::isSaturationUpTouched( touchX, touchY ); + } + } + + if ( currentPage == SCREEN_EFFECT ) { + hit.insideBack = M5StackDisplayTouchHelpers::isBackButtonTouched( touchX, touchY ); + + const uint8_t effectMode = getCurrentEffectMode(); + + if ( isEffectSpeedControlVisible( effectMode ) ) { + hit.insideSpeedDown = M5StackDisplayTouchHelpers::isSpeedDownTouched( touchX, touchY ); + + hit.insideSpeedUp = M5StackDisplayTouchHelpers::isSpeedUpTouched( touchX, touchY ); + } + + if ( isEffectIntensityControlVisible( effectMode ) ) { + hit.insideIntensityDown = M5StackDisplayTouchHelpers::isIntensityDownTouched( touchX, touchY ); + + hit.insideIntensityUp = M5StackDisplayTouchHelpers::isIntensityUpTouched( touchX, touchY ); + } + + if ( isEffectPaletteControlVisible( effectMode ) ) { + hit.insidePalettePrev = M5StackDisplayTouchHelpers::isPalettePrevTouched( touchX, touchY ); + + hit.insidePaletteNext = M5StackDisplayTouchHelpers::isPaletteNextTouched( touchX, touchY ); + } + } + + if ( currentPage == SCREEN_PRESET ) { + hit.insideBack = M5StackDisplayTouchHelpers::isBackButtonTouched( touchX, touchY ); + + if ( presetSubPage == PRESET_SUBPAGE_NAV ) { + hit.insidePresetPrev = M5StackDisplayTouchHelpers::isPresetPrevTouched( touchX, touchY ); + + hit.insidePresetNext = M5StackDisplayTouchHelpers::isPresetNextTouched( touchX, touchY ); + + hit.insidePresetManage = ( presetCacheReady && !presetCacheBuilding && pendingPresetId == 0 && M5StackDisplayTouchHelpers::isPresetManageTouched( touchX, touchY ) ); + } + else if ( presetSubPage == PRESET_SUBPAGE_MANAGE ) { + hit.insidePresetSaveNew = ( findFirstFreePresetId() > 0 && pendingPresetId == 0 && !presetNeedsSaving() && M5StackDisplayTouchHelpers::isPresetSaveNewTouched( touchX, touchY ) ); + + hit.insidePresetOverwriteOpen = ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 && pendingPresetId == 0 && !presetNeedsSaving() && M5StackDisplayTouchHelpers::isPresetOverwriteOpenTouched( touchX, touchY ) ); + + hit.insidePresetDeleteOpen = ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 && pendingPresetId == 0 && !presetNeedsSaving() && M5StackDisplayTouchHelpers::isPresetDeleteOpenTouched( touchX, touchY ) ); + + hit.insidePresetBootOpen = ( presetCacheReady && !presetCacheBuilding && pendingPresetId == 0 && !presetNeedsSaving() && M5StackDisplayTouchHelpers::isPresetBootOpenTouched( touchX, touchY ) ); + } + else if ( presetSubPage == PRESET_SUBPAGE_SAVE ) { + hit.insidePresetSaveHold = ( presetSaveOperationState == PRESET_SAVE_OP_IDLE && presetCacheReady && !presetCacheBuilding && presetSaveCandidateId > 0 && findPresetCacheIndex( presetSaveCandidateId ) < 0 && pendingPresetId == 0 && !presetNeedsSaving() && M5StackDisplayTouchHelpers::isPresetSaveHoldTouched( touchX, touchY ) ); + } + else if ( presetSubPage == PRESET_SUBPAGE_OVERWRITE ) { + hit.insidePresetOverwritePrev = ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 && M5StackDisplayTouchHelpers::isPresetOverwritePrevTouched( touchX, touchY ) ); + + hit.insidePresetOverwriteNext = ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 && M5StackDisplayTouchHelpers::isPresetOverwriteNextTouched( touchX, touchY ) ); + + hit.insidePresetOverwriteHold = ( presetSaveOperationState == PRESET_SAVE_OP_IDLE && presetCacheReady && !presetCacheBuilding && presetOverwriteTargetId > 0 && findPresetCacheIndex( presetOverwriteTargetId ) >= 0 && pendingPresetId == 0 && !presetNeedsSaving() && M5StackDisplayTouchHelpers::isPresetOverwriteHoldTouched( touchX, touchY ) ); + } + else if ( presetSubPage == PRESET_SUBPAGE_DELETE ) { + hit.insidePresetDeletePrev = ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 && M5StackDisplayTouchHelpers::isPresetDeletePrevTouched( touchX, touchY ) ); + + hit.insidePresetDeleteNext = ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 && M5StackDisplayTouchHelpers::isPresetDeleteNextTouched( touchX, touchY ) ); + + hit.insidePresetDeleteHold = ( presetDeleteOperationState == PRESET_DELETE_OP_IDLE && presetCacheReady && !presetCacheBuilding && presetDeleteTargetId > 0 && findPresetCacheIndex( presetDeleteTargetId ) >= 0 && pendingPresetId == 0 && !presetNeedsSaving() && M5StackDisplayTouchHelpers::isPresetDeleteHoldTouched( touchX, touchY ) ); + } + else if ( presetSubPage == PRESET_SUBPAGE_BOOT ) { + hit.insidePresetBootPrev = ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 && M5StackDisplayTouchHelpers::isPresetBootPrevTouched( touchX, touchY ) ); + + hit.insidePresetBootNext = ( presetCacheReady && !presetCacheBuilding && presetCacheCount > 0 && M5StackDisplayTouchHelpers::isPresetBootNextTouched( touchX, touchY ) ); + + hit.insidePresetBootHold = ( presetBootOperationState == PRESET_BOOT_OP_IDLE && presetCacheReady && !presetCacheBuilding && pendingPresetId == 0 && !presetNeedsSaving() && isPresetBootTargetValid() && !isPresetBootTargetCurrent() && M5StackDisplayTouchHelpers::isPresetBootHoldTouched( touchX, touchY ) ); + } + } + + + return hit; + } + + // ========================================================= + // Touch Press + // + // Starts a new gesture and acquires its M5StackTouchTarget. + // ========================================================= + + void handleTouchPress( const M5StackTouchFrameContext& context ) { + M5StackTouchRuntimeState& touchState = context.state; + const M5StackTouchHitState& hit = context.hit; + const unsigned long now = context.now; + + if (!touchState.touchActive) { + touchState.touchActive = true; + + touchState.touchTarget = M5STACK_TOUCH_TARGET_NONE; + + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.wifiRecoveryHoldState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.brightnessRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.effectRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.colorSlotHoldState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.hueRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.saturationRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.speedRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.intensityRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.paletteRepeatState ); + M5StackDisplayTouchHelpers::resetRepeatTouch( touchState.presetRepeatState ); + + presetSaveHoldStartTime = 0; + + presetSaveHoldTriggered = false; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_NONE ) { + if (hit.insidePower) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_POWER; + + touchState.lastTouchInsidePower = true; + } + + else if ( currentPage == SCREEN_MAIN ) { + if (hit.insideWiFiRecovery) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_WIFI_RECOVERY; + + touchState.lastTouchInsideWiFiRecovery = true; + touchState.wifiRecoveryVisualPressed = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( + touchState.wifiRecoveryHoldState, + now + ); + + drawMainNetworkStatusLine( + "Hold... Recovery AP", + TFT_YELLOW + ); + } + else if (hit.insideBrightnessDown) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_BRIGHTNESS_DOWN; + + touchState.lastTouchInsideBrightness = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.brightnessRepeatState, now ); + } + else if (hit.insideBrightnessUp) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_BRIGHTNESS_UP; + + touchState.lastTouchInsideBrightness = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.brightnessRepeatState, now ); + } + else if (hit.insideEffectPrev) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_EFFECT_PREV; + + touchState.lastTouchInsideEffect = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.effectRepeatState, now ); + } + else if (hit.insideEffectDetail) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_EFFECT_DETAIL; + + touchState.lastTouchInsideEffectDetail = true; + } + else if (hit.insideEffectNext) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_EFFECT_NEXT; + + touchState.lastTouchInsideEffect = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.effectRepeatState, now ); + } + else if (hit.insideColor) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_COLOR_OPEN; + + touchState.lastTouchInsideColor = true; + } + else if (hit.insidePresetOpen) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_OPEN; + + touchState.lastTouchInsidePresetOpen = true; + } + } + + else if ( currentPage == SCREEN_COLOR ) { + if (hit.insideBack) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_BACK; + + touchState.lastTouchInsideBack = true; + } + else if (hit.insideColorSlot1) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_COLOR_SLOT_1; + + touchState.lastTouchInsideColorSlot = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.colorSlotHoldState, now ); + } + else if (hit.insideColorSlot2) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_COLOR_SLOT_2; + + touchState.lastTouchInsideColorSlot = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.colorSlotHoldState, now ); + } + else if (hit.insideColorSlot3) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_COLOR_SLOT_3; + + touchState.lastTouchInsideColorSlot = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.colorSlotHoldState, now ); + } + else if (hit.insideHueDown) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_HUE_DOWN; + + touchState.lastTouchInsideHue = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.hueRepeatState, now ); + + beginHueEdit(); + } + else if (hit.insideHueUp) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_HUE_UP; + + touchState.lastTouchInsideHue = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.hueRepeatState, now ); + + beginHueEdit(); + } + else if (hit.insideSaturationDown) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_SATURATION_DOWN; + + touchState.lastTouchInsideSaturation = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.saturationRepeatState, now ); + + beginSaturationEdit(); + } + else if (hit.insideSaturationUp) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_SATURATION_UP; + + touchState.lastTouchInsideSaturation = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.saturationRepeatState, now ); + + beginSaturationEdit(); + } + } + + else if ( currentPage == SCREEN_EFFECT ) { + if (hit.insideBack) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_BACK; + + touchState.lastTouchInsideBack = true; + } + else if (hit.insideSpeedDown) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_SPEED_DOWN; + + touchState.lastTouchInsideSpeed = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.speedRepeatState, now ); + } + else if (hit.insideSpeedUp) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_SPEED_UP; + + touchState.lastTouchInsideSpeed = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.speedRepeatState, now ); + } + else if (hit.insideIntensityDown) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_INTENSITY_DOWN; + + touchState.lastTouchInsideIntensity = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.intensityRepeatState, now ); + } + else if (hit.insideIntensityUp) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_INTENSITY_UP; + + touchState.lastTouchInsideIntensity = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.intensityRepeatState, now ); + } + else if (hit.insidePalettePrev) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PALETTE_PREV; + + touchState.lastTouchInsidePalette = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.paletteRepeatState, now ); + } + else if (hit.insidePaletteNext) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PALETTE_NEXT; + + touchState.lastTouchInsidePalette = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.paletteRepeatState, now ); + } + } + + else if ( currentPage == SCREEN_PRESET ) { + if (hit.insideBack) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_BACK; + + touchState.lastTouchInsideBack = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_NAV && hit.insidePresetPrev ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_PREV; + + touchState.lastTouchInsidePresetNav = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.presetRepeatState, now ); + } + else if ( presetSubPage == PRESET_SUBPAGE_NAV && hit.insidePresetNext ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_NEXT; + + touchState.lastTouchInsidePresetNav = true; + + M5StackDisplayTouchHelpers::beginRepeatTouch( touchState.presetRepeatState, now ); + } + else if ( presetSubPage == PRESET_SUBPAGE_NAV && hit.insidePresetManage ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_MANAGE; + + touchState.lastTouchInsidePresetManage = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_MANAGE && hit.insidePresetSaveNew ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_SAVE_NEW; + + touchState.lastTouchInsidePresetSaveNew = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_MANAGE && hit.insidePresetOverwriteOpen ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_OPEN; + + touchState.lastTouchInsidePresetOverwriteOpen = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_MANAGE && hit.insidePresetDeleteOpen ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_DELETE_OPEN; + + touchState.lastTouchInsidePresetDeleteOpen = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_MANAGE && hit.insidePresetBootOpen ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_BOOT_OPEN; + + touchState.lastTouchInsidePresetBootOpen = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_SAVE && hit.insidePresetSaveHold ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_SAVE_HOLD; + + touchState.lastTouchInsidePresetSaveHold = true; + + presetSaveHoldStartTime = now; + + presetSaveHoldTriggered = false; + } + else if ( presetSubPage == PRESET_SUBPAGE_OVERWRITE && hit.insidePresetOverwritePrev ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_PREV; + + touchState.lastTouchInsidePresetOverwriteNav = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_OVERWRITE && hit.insidePresetOverwriteNext ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_NEXT; + + touchState.lastTouchInsidePresetOverwriteNav = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_OVERWRITE && hit.insidePresetOverwriteHold ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_HOLD; + + touchState.lastTouchInsidePresetOverwriteHold = true; + + presetSaveHoldStartTime = now; + + presetSaveHoldTriggered = false; + } + else if ( presetSubPage == PRESET_SUBPAGE_DELETE && hit.insidePresetDeletePrev ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_DELETE_PREV; + + touchState.lastTouchInsidePresetDeleteNav = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_DELETE && hit.insidePresetDeleteNext ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_DELETE_NEXT; + + touchState.lastTouchInsidePresetDeleteNav = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_DELETE && hit.insidePresetDeleteHold ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_DELETE_HOLD; + + touchState.lastTouchInsidePresetDeleteHold = true; + + presetSaveHoldStartTime = now; + + presetSaveHoldTriggered = false; + } + else if ( presetSubPage == PRESET_SUBPAGE_BOOT && hit.insidePresetBootPrev ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_BOOT_PREV; + + touchState.lastTouchInsidePresetBootNav = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_BOOT && hit.insidePresetBootNext ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_BOOT_NEXT; + + touchState.lastTouchInsidePresetBootNav = true; + } + else if ( presetSubPage == PRESET_SUBPAGE_BOOT && hit.insidePresetBootHold ) { + touchState.touchTarget = M5STACK_TOUCH_TARGET_PRESET_BOOT_HOLD; + + touchState.lastTouchInsidePresetBootHold = true; + + presetSaveHoldStartTime = now; + + presetSaveHoldTriggered = false; + } + } + } + + } + + // ========================================================= + // Touch Hold + // + // Preserves pressed visuals, long-press and repeat behavior. + // ========================================================= + + void handleTouchHold( const M5StackTouchFrameContext& context ) { + M5StackTouchRuntimeState& touchState = context.state; + const M5StackTouchHitState& hit = context.hit; + const unsigned long now = context.now; + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_POWER ) { + touchState.lastTouchInsidePower = hit.insidePower; + + if ( hit.insidePower != touchState.powerButtonVisualPressed ) { + drawPowerButton( bri > 0, hit.insidePower ); + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_WIFI_RECOVERY ) { + touchState.lastTouchInsideWiFiRecovery = hit.insideWiFiRecovery; + + if ( !hit.insideWiFiRecovery ) { + // Leaving the header area cancels this hold completely. Re-entering + // during the same gesture must not trigger Recovery AP immediately. + touchState.wifiRecoveryHoldState.pressStart = 0; + + if ( touchState.wifiRecoveryVisualPressed ) { + touchState.wifiRecoveryVisualPressed = false; + + drawMainNetworkStatusLine( + getCurrentNetworkDisplayText() + ); + } + + return; + } + + if ( + !touchState.wifiRecoveryHoldState.longPressActive && + touchState.wifiRecoveryHoldState.pressStart > 0 && + now - touchState.wifiRecoveryHoldState.pressStart >= WIFI_RECOVERY_HOLD_MS + ) { + touchState.wifiRecoveryHoldState.longPressActive = true; + + drawMainNetworkStatusLine( + "Starting Recovery AP...", + TFT_YELLOW + ); + + if ( startWiFiRecoveryAP() ) { + NetworkAccessMode recoveryMode = getNetworkAccessMode(); + String recoveryText = getNetworkDisplayText( recoveryMode ); + + lastNetworkAccessMode = recoveryMode; + lastNetworkDisplayText = recoveryText; + + touchState.wifiRecoveryVisualPressed = false; + + drawMainNetworkStatusLine( + recoveryText, + TFT_GREEN + ); + } + else { + // Keep the visual marked active so release restores the normal + // offline hint after a failed start attempt. + drawMainNetworkStatusLine( + "Recovery AP failed", + TFT_RED + ); + } + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_BRIGHTNESS_DOWN, M5STACK_TOUCH_TARGET_BRIGHTNESS_UP ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_BRIGHTNESS_DOWN, hit.insideBrightnessDown, M5STACK_TOUCH_TARGET_BRIGHTNESS_UP, hit.insideBrightnessUp ); + + touchState.lastTouchInsideBrightness = insideSelectedButton; + + if ( insideSelectedButton != touchState.brightnessButtonVisualPressed ) { + drawBrightness( bri, insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.brightnessButtonVisualPressed = insideSelectedButton; + } + + if (!insideSelectedButton) { + return; + } + + if ( M5StackDisplayTouchHelpers::serviceRepeatTouch( touchState.brightnessRepeatState, now, BRI_LONG_PRESS_MS, BRI_REPEAT_MS ) ) { + brightnessLongPressStep( touchState.touchTarget ); + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_EFFECT_PREV, M5STACK_TOUCH_TARGET_EFFECT_NEXT ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_EFFECT_PREV, hit.insideEffectPrev, M5STACK_TOUCH_TARGET_EFFECT_NEXT, hit.insideEffectNext ); + + touchState.lastTouchInsideEffect = insideSelectedButton; + + if ( insideSelectedButton != touchState.effectButtonVisualPressed ) { + drawEffect( getCurrentEffectMode(), insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.effectButtonVisualPressed = insideSelectedButton; + } + + if (!insideSelectedButton) { + return; + } + + if ( M5StackDisplayTouchHelpers::serviceRepeatTouch( touchState.effectRepeatState, now, EFFECT_LONG_PRESS_MS, EFFECT_REPEAT_MS ) ) { + effectLongPressStep( touchState.touchTarget ); + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_EFFECT_DETAIL ) { + bool insideSelectedButton = hit.insideEffectDetail; + + touchState.lastTouchInsideEffectDetail = insideSelectedButton; + + if ( insideSelectedButton != touchState.effectDetailVisualPressed ) { + drawEffectDetailButton( getCurrentEffectMode(), insideSelectedButton ); + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_OPEN ) { + touchState.lastTouchInsideColor = hit.insideColor; + + if ( hit.insideColor != touchState.colorButtonVisualPressed ) { + drawColorButton( getPrimaryColor(), hit.insideColor ); + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_OPEN ) { + touchState.lastTouchInsidePresetOpen = hit.insidePresetOpen; + + if ( hit.insidePresetOpen != touchState.presetOpenButtonVisualPressed ) { + drawPresetOpenButton( hit.insidePresetOpen ); + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_BACK ) { + touchState.lastTouchInsideBack = hit.insideBack; + + if ( hit.insideBack != touchState.backButtonVisualPressed ) { + drawBackButton( hit.insideBack ); + } + + return; + } + + if ( + touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_1 || + touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_2 || + touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_3 + ) { + bool insideSelectedSlot = false; + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_1 ) { + insideSelectedSlot = hit.insideColorSlot1; + } + else if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_2 ) { + insideSelectedSlot = hit.insideColorSlot2; + } + else { + insideSelectedSlot = hit.insideColorSlot3; + } + + touchState.lastTouchInsideColorSlot = insideSelectedSlot; + + if (!insideSelectedSlot) { + return; + } + + if ( + !touchState.colorSlotHoldState.longPressActive && + touchState.colorSlotHoldState.pressStart > 0 && + now - touchState.colorSlotHoldState.pressStart >= COLOR_SLOT_LONG_PRESS_MS + ) { + touchState.colorSlotHoldState.longPressActive = true; + + uint8_t colorSlot = 0; + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_2 ) { + colorSlot = 1; + } + else if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_3 ) { + colorSlot = 2; + } + + toggleColorSlotBlack( colorSlot ); + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_HUE_DOWN, M5STACK_TOUCH_TARGET_HUE_UP ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_HUE_DOWN, hit.insideHueDown, M5STACK_TOUCH_TARGET_HUE_UP, hit.insideHueUp ); + + touchState.lastTouchInsideHue = insideSelectedButton; + + if ( insideSelectedButton != touchState.hueButtonVisualPressed ) { + drawHue( logicalHueValue, insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.hueButtonVisualPressed = insideSelectedButton; + } + + if (!insideSelectedButton) { + return; + } + + if ( M5StackDisplayTouchHelpers::serviceRepeatTouch( touchState.hueRepeatState, now, HUE_LONG_PRESS_MS, HUE_REPEAT_MS ) ) { + hueLongPressStep( touchState.touchTarget ); + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_SATURATION_DOWN, M5STACK_TOUCH_TARGET_SATURATION_UP ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_SATURATION_DOWN, hit.insideSaturationDown, M5STACK_TOUCH_TARGET_SATURATION_UP, hit.insideSaturationUp ); + + touchState.lastTouchInsideSaturation = insideSelectedButton; + + if ( insideSelectedButton != touchState.saturationButtonVisualPressed ) { + drawSaturation( logicalSaturationValue, insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.saturationButtonVisualPressed = insideSelectedButton; + } + + if (!insideSelectedButton) { + return; + } + + if ( M5StackDisplayTouchHelpers::serviceRepeatTouch( touchState.saturationRepeatState, now, SATURATION_LONG_PRESS_MS, SATURATION_REPEAT_MS ) ) { + saturationLongPressStep( touchState.touchTarget ); + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_SPEED_DOWN, M5STACK_TOUCH_TARGET_SPEED_UP ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_SPEED_DOWN, hit.insideSpeedDown, M5STACK_TOUCH_TARGET_SPEED_UP, hit.insideSpeedUp ); + + touchState.lastTouchInsideSpeed = insideSelectedButton; + + if ( insideSelectedButton != touchState.speedButtonVisualPressed ) { + drawSpeed( getCurrentSpeed(), insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.speedButtonVisualPressed = insideSelectedButton; + } + + if (!insideSelectedButton) { + return; + } + + if ( M5StackDisplayTouchHelpers::serviceRepeatTouch( touchState.speedRepeatState, now, SPEED_LONG_PRESS_MS, SPEED_REPEAT_MS ) ) { + speedLongPressStep( touchState.touchTarget ); + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_INTENSITY_DOWN, M5STACK_TOUCH_TARGET_INTENSITY_UP ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_INTENSITY_DOWN, hit.insideIntensityDown, M5STACK_TOUCH_TARGET_INTENSITY_UP, hit.insideIntensityUp ); + + touchState.lastTouchInsideIntensity = insideSelectedButton; + + if ( insideSelectedButton != touchState.intensityButtonVisualPressed ) { + drawIntensity( getCurrentIntensity(), insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.intensityButtonVisualPressed = insideSelectedButton; + } + + if (!insideSelectedButton) { + return; + } + + if ( M5StackDisplayTouchHelpers::serviceRepeatTouch( touchState.intensityRepeatState, now, INTENSITY_LONG_PRESS_MS, INTENSITY_REPEAT_MS ) ) { + intensityLongPressStep( touchState.touchTarget ); + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_PALETTE_PREV, M5STACK_TOUCH_TARGET_PALETTE_NEXT ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_PALETTE_PREV, hit.insidePalettePrev, M5STACK_TOUCH_TARGET_PALETTE_NEXT, hit.insidePaletteNext ); + + touchState.lastTouchInsidePalette = insideSelectedButton; + + if ( insideSelectedButton != touchState.paletteButtonVisualPressed ) { + drawPalette( getCurrentPalette(), insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.paletteButtonVisualPressed = insideSelectedButton; + } + + if (!insideSelectedButton) { + return; + } + + if ( M5StackDisplayTouchHelpers::serviceRepeatTouch( touchState.paletteRepeatState, now, PALETTE_LONG_PRESS_MS, PALETTE_REPEAT_MS ) ) { + paletteLongPressStep( touchState.touchTarget ); + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_PRESET_PREV, M5STACK_TOUCH_TARGET_PRESET_NEXT ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_PRESET_PREV, hit.insidePresetPrev, M5STACK_TOUCH_TARGET_PRESET_NEXT, hit.insidePresetNext ); + + touchState.lastTouchInsidePresetNav = insideSelectedButton; + + if ( insideSelectedButton != touchState.presetNavButtonVisualPressed ) { + drawPresetNavigation( insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.presetNavButtonVisualPressed = insideSelectedButton; + } + + if (!insideSelectedButton) { + return; + } + + if ( M5StackDisplayTouchHelpers::serviceRepeatTouch( touchState.presetRepeatState, now, PRESET_LONG_PRESS_MS, PRESET_REPEAT_MS ) ) { + presetLongPressStep( touchState.touchTarget ); + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_MANAGE ) { + touchState.lastTouchInsidePresetManage = hit.insidePresetManage; + + if ( hit.insidePresetManage != touchState.presetManageButtonVisualPressed ) { + drawPresetManageButton( hit.insidePresetManage ); + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_SAVE_NEW ) { + touchState.lastTouchInsidePresetSaveNew = hit.insidePresetSaveNew; + + if ( hit.insidePresetSaveNew != touchState.presetSaveNewButtonVisualPressed ) { + drawPresetSaveNewButton( hit.insidePresetSaveNew ); + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_SAVE_HOLD ) { + touchState.lastTouchInsidePresetSaveHold = hit.insidePresetSaveHold; + + if ( hit.insidePresetSaveHold != touchState.presetSaveHoldButtonVisualPressed ) { + drawPresetSaveHoldButton( hit.insidePresetSaveHold ); + } + + if (!hit.insidePresetSaveHold) { + presetSaveHoldStartTime = 0; + + return; + } + + if ( presetSaveHoldStartTime == 0 ) { + presetSaveHoldStartTime = now; + } + + if ( !presetSaveHoldTriggered && now - presetSaveHoldStartTime >= PRESET_SAVE_HOLD_MS ) { + presetSaveHoldTriggered = true; + + requestNewPresetSave(); + + return; + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_OPEN ) { + touchState.lastTouchInsidePresetOverwriteOpen = hit.insidePresetOverwriteOpen; + + if ( hit.insidePresetOverwriteOpen != touchState.presetOverwriteOpenButtonVisualPressed ) { + drawPresetOverwriteOpenButton( hit.insidePresetOverwriteOpen ); + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_PREV, M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_NEXT ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_PREV, hit.insidePresetOverwritePrev, M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_NEXT, hit.insidePresetOverwriteNext ); + + touchState.lastTouchInsidePresetOverwriteNav = insideSelectedButton; + + if ( insideSelectedButton != touchState.presetOverwriteNavButtonVisualPressed ) { + drawPresetOverwriteNavigation( insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.presetOverwriteNavButtonVisualPressed = insideSelectedButton; + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_HOLD ) { + touchState.lastTouchInsidePresetOverwriteHold = hit.insidePresetOverwriteHold; + + if ( hit.insidePresetOverwriteHold != touchState.presetOverwriteHoldButtonVisualPressed ) { + drawPresetOverwriteHoldButton( hit.insidePresetOverwriteHold ); + } + + if (!hit.insidePresetOverwriteHold) { + presetSaveHoldStartTime = 0; + + return; + } + + if ( presetSaveHoldStartTime == 0 ) { + presetSaveHoldStartTime = now; + } + + if ( !presetSaveHoldTriggered && now - presetSaveHoldStartTime >= PRESET_SAVE_HOLD_MS ) { + presetSaveHoldTriggered = true; + + requestPresetOverwrite(); + + return; + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_DELETE_OPEN ) { + touchState.lastTouchInsidePresetDeleteOpen = hit.insidePresetDeleteOpen; + + if ( hit.insidePresetDeleteOpen != touchState.presetDeleteOpenButtonVisualPressed ) { + drawPresetDeleteOpenButton( hit.insidePresetDeleteOpen ); + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_PRESET_DELETE_PREV, M5STACK_TOUCH_TARGET_PRESET_DELETE_NEXT ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_PRESET_DELETE_PREV, hit.insidePresetDeletePrev, M5STACK_TOUCH_TARGET_PRESET_DELETE_NEXT, hit.insidePresetDeleteNext ); + + touchState.lastTouchInsidePresetDeleteNav = insideSelectedButton; + + if ( insideSelectedButton != touchState.presetDeleteNavButtonVisualPressed ) { + drawPresetDeleteNavigation( insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.presetDeleteNavButtonVisualPressed = insideSelectedButton; + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_DELETE_HOLD ) { + touchState.lastTouchInsidePresetDeleteHold = hit.insidePresetDeleteHold; + + if ( hit.insidePresetDeleteHold != touchState.presetDeleteHoldButtonVisualPressed ) { + drawPresetDeleteHoldButton( hit.insidePresetDeleteHold ); + } + + if (!hit.insidePresetDeleteHold) { + presetSaveHoldStartTime = 0; + + return; + } + + if ( presetSaveHoldStartTime == 0 ) { + presetSaveHoldStartTime = now; + } + + if ( !presetSaveHoldTriggered && now - presetSaveHoldStartTime >= PRESET_SAVE_HOLD_MS ) { + presetSaveHoldTriggered = true; + + requestPresetDelete(); + + return; + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_BOOT_OPEN ) { + touchState.lastTouchInsidePresetBootOpen = hit.insidePresetBootOpen; + + if ( hit.insidePresetBootOpen != touchState.presetBootOpenButtonVisualPressed ) { + drawPresetBootOpenButton( hit.insidePresetBootOpen ); + } + + return; + } + + if ( M5StackDisplayTouchHelpers::isTouchTargetPair( touchState.touchTarget, M5STACK_TOUCH_TARGET_PRESET_BOOT_PREV, M5STACK_TOUCH_TARGET_PRESET_BOOT_NEXT ) ) { + bool insideSelectedButton = isSelectedTouchPairInside( M5STACK_TOUCH_TARGET_PRESET_BOOT_PREV, hit.insidePresetBootPrev, M5STACK_TOUCH_TARGET_PRESET_BOOT_NEXT, hit.insidePresetBootNext ); + + touchState.lastTouchInsidePresetBootNav = insideSelectedButton; + + if ( insideSelectedButton != touchState.presetBootNavButtonVisualPressed ) { + drawPresetBootNavigation( insideSelectedButton ? touchState.touchTarget : M5STACK_TOUCH_TARGET_NONE ); + + touchState.presetBootNavButtonVisualPressed = insideSelectedButton; + } + + return; + } + + if ( touchState.touchTarget == M5STACK_TOUCH_TARGET_PRESET_BOOT_HOLD ) { + touchState.lastTouchInsidePresetBootHold = hit.insidePresetBootHold; + + if ( hit.insidePresetBootHold != touchState.presetBootHoldButtonVisualPressed ) { + drawPresetBootHoldButton( hit.insidePresetBootHold ); + } + + if (!hit.insidePresetBootHold) { + presetSaveHoldStartTime = 0; + + return; + } + + if ( presetSaveHoldStartTime == 0 ) { + presetSaveHoldStartTime = now; + } + + if ( !presetSaveHoldTriggered && now - presetSaveHoldStartTime >= PRESET_SAVE_HOLD_MS ) { + presetSaveHoldTriggered = true; + + requestPresetBootSetting(); + + return; + } + + return; + } + + return; + } + + // ========================================================= + // Touch Release action state + // + // Only one M5StackTouchTarget can own a gesture, so release-time + // execution is represented by one action instead of parallel + // boolean flags. + // ========================================================= + + enum TouchReleaseAction : uint8_t { + TOUCH_RELEASE_ACTION_NONE = 0, + TOUCH_RELEASE_ACTION_POWER, + TOUCH_RELEASE_ACTION_BRIGHTNESS_SHORT, + TOUCH_RELEASE_ACTION_EFFECT_STEP, + TOUCH_RELEASE_ACTION_EFFECT_DETAIL, + TOUCH_RELEASE_ACTION_COLOR_OPEN, + TOUCH_RELEASE_ACTION_COLOR_SLOT_SELECT, + TOUCH_RELEASE_ACTION_PRESET_OPEN, + TOUCH_RELEASE_ACTION_BACK, + TOUCH_RELEASE_ACTION_HUE_SHORT, + TOUCH_RELEASE_ACTION_SATURATION_SHORT, + TOUCH_RELEASE_ACTION_SPEED_SHORT, + TOUCH_RELEASE_ACTION_INTENSITY_SHORT, + TOUCH_RELEASE_ACTION_PALETTE_SHORT, + TOUCH_RELEASE_ACTION_PRESET_SHORT, + TOUCH_RELEASE_ACTION_PRESET_MANAGE_OPEN, + TOUCH_RELEASE_ACTION_PRESET_SAVE_NEW, + TOUCH_RELEASE_ACTION_PRESET_OVERWRITE_OPEN, + TOUCH_RELEASE_ACTION_PRESET_OVERWRITE_STEP, + TOUCH_RELEASE_ACTION_PRESET_DELETE_OPEN, + TOUCH_RELEASE_ACTION_PRESET_DELETE_STEP, + TOUCH_RELEASE_ACTION_PRESET_BOOT_OPEN, + TOUCH_RELEASE_ACTION_PRESET_BOOT_STEP + }; + + struct ColorEditSnapshot { + bool hueValid; + CHSV32 hueHsv; + uint8_t hueValue; + uint8_t hueWhite; + + bool saturationValid; + CHSV32 saturationHsv; + uint8_t saturationValue; + uint8_t saturationWhite; + }; + + TouchReleaseAction determineTouchReleaseAction( M5StackTouchTarget releasedTarget, unsigned long now ) { + switch (releasedTarget) { + case M5STACK_TOUCH_TARGET_POWER: + return ( touchState.lastTouchInsidePower && now - touchState.lastTouchAction >= TOUCH_ACTION_COOLDOWN_MS ) ? TOUCH_RELEASE_ACTION_POWER : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_WIFI_RECOVERY: + // Recovery AP is a hold-only gesture. Short release intentionally + // performs no action. + return TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_BRIGHTNESS_DOWN: + case M5STACK_TOUCH_TARGET_BRIGHTNESS_UP: + return ( touchState.lastTouchInsideBrightness && !touchState.brightnessRepeatState.longPressActive ) ? TOUCH_RELEASE_ACTION_BRIGHTNESS_SHORT : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_EFFECT_PREV: + case M5STACK_TOUCH_TARGET_EFFECT_NEXT: + return ( touchState.lastTouchInsideEffect && !touchState.effectRepeatState.longPressActive ) ? TOUCH_RELEASE_ACTION_EFFECT_STEP : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_EFFECT_DETAIL: + return touchState.lastTouchInsideEffectDetail ? TOUCH_RELEASE_ACTION_EFFECT_DETAIL : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_COLOR_OPEN: + return touchState.lastTouchInsideColor ? TOUCH_RELEASE_ACTION_COLOR_OPEN : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_COLOR_SLOT_1: + case M5STACK_TOUCH_TARGET_COLOR_SLOT_2: + case M5STACK_TOUCH_TARGET_COLOR_SLOT_3: + return ( + touchState.lastTouchInsideColorSlot && + !touchState.colorSlotHoldState.longPressActive + ) + ? TOUCH_RELEASE_ACTION_COLOR_SLOT_SELECT + : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PRESET_OPEN: + return touchState.lastTouchInsidePresetOpen ? TOUCH_RELEASE_ACTION_PRESET_OPEN : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_BACK: + return touchState.lastTouchInsideBack ? TOUCH_RELEASE_ACTION_BACK : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_HUE_DOWN: + case M5STACK_TOUCH_TARGET_HUE_UP: + return ( touchState.lastTouchInsideHue && !touchState.hueRepeatState.longPressActive ) ? TOUCH_RELEASE_ACTION_HUE_SHORT : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_SATURATION_DOWN: + case M5STACK_TOUCH_TARGET_SATURATION_UP: + return ( touchState.lastTouchInsideSaturation && !touchState.saturationRepeatState.longPressActive ) ? TOUCH_RELEASE_ACTION_SATURATION_SHORT : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_SPEED_DOWN: + case M5STACK_TOUCH_TARGET_SPEED_UP: + return ( touchState.lastTouchInsideSpeed && !touchState.speedRepeatState.longPressActive ) ? TOUCH_RELEASE_ACTION_SPEED_SHORT : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_INTENSITY_DOWN: + case M5STACK_TOUCH_TARGET_INTENSITY_UP: + return ( touchState.lastTouchInsideIntensity && !touchState.intensityRepeatState.longPressActive ) ? TOUCH_RELEASE_ACTION_INTENSITY_SHORT : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PALETTE_PREV: + case M5STACK_TOUCH_TARGET_PALETTE_NEXT: + return ( touchState.lastTouchInsidePalette && !touchState.paletteRepeatState.longPressActive ) ? TOUCH_RELEASE_ACTION_PALETTE_SHORT : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PRESET_PREV: + case M5STACK_TOUCH_TARGET_PRESET_NEXT: + return ( touchState.lastTouchInsidePresetNav && !touchState.presetRepeatState.longPressActive ) ? TOUCH_RELEASE_ACTION_PRESET_SHORT : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PRESET_MANAGE: + return touchState.lastTouchInsidePresetManage ? TOUCH_RELEASE_ACTION_PRESET_MANAGE_OPEN : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PRESET_SAVE_NEW: + return touchState.lastTouchInsidePresetSaveNew ? TOUCH_RELEASE_ACTION_PRESET_SAVE_NEW : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_OPEN: + return touchState.lastTouchInsidePresetOverwriteOpen ? TOUCH_RELEASE_ACTION_PRESET_OVERWRITE_OPEN : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_PREV: + case M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_NEXT: + return touchState.lastTouchInsidePresetOverwriteNav ? TOUCH_RELEASE_ACTION_PRESET_OVERWRITE_STEP : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PRESET_DELETE_OPEN: + return touchState.lastTouchInsidePresetDeleteOpen ? TOUCH_RELEASE_ACTION_PRESET_DELETE_OPEN : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PRESET_DELETE_PREV: + case M5STACK_TOUCH_TARGET_PRESET_DELETE_NEXT: + return touchState.lastTouchInsidePresetDeleteNav ? TOUCH_RELEASE_ACTION_PRESET_DELETE_STEP : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PRESET_BOOT_OPEN: + return touchState.lastTouchInsidePresetBootOpen ? TOUCH_RELEASE_ACTION_PRESET_BOOT_OPEN : TOUCH_RELEASE_ACTION_NONE; + + case M5STACK_TOUCH_TARGET_PRESET_BOOT_PREV: + case M5STACK_TOUCH_TARGET_PRESET_BOOT_NEXT: + return touchState.lastTouchInsidePresetBootNav ? TOUCH_RELEASE_ACTION_PRESET_BOOT_STEP : TOUCH_RELEASE_ACTION_NONE; + + default: + return TOUCH_RELEASE_ACTION_NONE; + } + } + + void releaseTouchVisualState( M5StackTouchTarget releasedTarget ) { + if ( + releasedTarget == M5STACK_TOUCH_TARGET_WIFI_RECOVERY && + touchState.wifiRecoveryVisualPressed + ) { + drawMainNetworkStatusLine( + getCurrentNetworkDisplayText() + ); + + touchState.wifiRecoveryVisualPressed = false; + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_POWER && touchState.powerButtonVisualPressed ) { + drawPowerButton( bri > 0, false ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_BRIGHTNESS_DOWN || releasedTarget == M5STACK_TOUCH_TARGET_BRIGHTNESS_UP ) && touchState.brightnessButtonVisualPressed ) { + drawBrightness( bri, M5STACK_TOUCH_TARGET_NONE ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_EFFECT_PREV || releasedTarget == M5STACK_TOUCH_TARGET_EFFECT_NEXT ) && touchState.effectButtonVisualPressed ) { + drawEffect( getCurrentEffectMode(), M5STACK_TOUCH_TARGET_NONE ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_EFFECT_DETAIL && touchState.effectDetailVisualPressed ) { + drawEffectDetailButton( getCurrentEffectMode(), false ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_COLOR_OPEN && touchState.colorButtonVisualPressed ) { + drawColorButton( getPrimaryColor(), false ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_OPEN && touchState.presetOpenButtonVisualPressed ) { + drawPresetOpenButton( false ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_BACK && touchState.backButtonVisualPressed ) { + drawBackButton( false ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_HUE_DOWN || releasedTarget == M5STACK_TOUCH_TARGET_HUE_UP ) && touchState.hueButtonVisualPressed ) { + drawHue( logicalHueValue, M5STACK_TOUCH_TARGET_NONE ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_SATURATION_DOWN || releasedTarget == M5STACK_TOUCH_TARGET_SATURATION_UP ) && touchState.saturationButtonVisualPressed ) { + drawSaturation( logicalSaturationValue, M5STACK_TOUCH_TARGET_NONE ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_SPEED_DOWN || releasedTarget == M5STACK_TOUCH_TARGET_SPEED_UP ) && touchState.speedButtonVisualPressed ) { + drawSpeed( getCurrentSpeed(), M5STACK_TOUCH_TARGET_NONE ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_INTENSITY_DOWN || releasedTarget == M5STACK_TOUCH_TARGET_INTENSITY_UP ) && touchState.intensityButtonVisualPressed ) { + drawIntensity( getCurrentIntensity(), M5STACK_TOUCH_TARGET_NONE ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_PALETTE_PREV || releasedTarget == M5STACK_TOUCH_TARGET_PALETTE_NEXT ) && touchState.paletteButtonVisualPressed ) { + drawPalette( getCurrentPalette(), M5STACK_TOUCH_TARGET_NONE ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_PREV || releasedTarget == M5STACK_TOUCH_TARGET_PRESET_NEXT ) && touchState.presetNavButtonVisualPressed ) { + drawPresetNavigation( M5STACK_TOUCH_TARGET_NONE ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_MANAGE && touchState.presetManageButtonVisualPressed ) { + drawPresetManageButton( false ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_SAVE_NEW && touchState.presetSaveNewButtonVisualPressed ) { + drawPresetSaveNewButton( false ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_SAVE_HOLD && touchState.presetSaveHoldButtonVisualPressed ) { + drawPresetSaveHoldButton( false ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_OPEN && touchState.presetOverwriteOpenButtonVisualPressed ) { + drawPresetOverwriteOpenButton( false ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_PREV || releasedTarget == M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_NEXT ) && touchState.presetOverwriteNavButtonVisualPressed ) { + drawPresetOverwriteNavigation( M5STACK_TOUCH_TARGET_NONE ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_HOLD && touchState.presetOverwriteHoldButtonVisualPressed ) { + drawPresetOverwriteHoldButton( false ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_DELETE_OPEN && touchState.presetDeleteOpenButtonVisualPressed ) { + drawPresetDeleteOpenButton( false ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_DELETE_PREV || releasedTarget == M5STACK_TOUCH_TARGET_PRESET_DELETE_NEXT ) && touchState.presetDeleteNavButtonVisualPressed ) { + drawPresetDeleteNavigation( M5STACK_TOUCH_TARGET_NONE ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_DELETE_HOLD && touchState.presetDeleteHoldButtonVisualPressed ) { + drawPresetDeleteHoldButton( false ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_BOOT_OPEN && touchState.presetBootOpenButtonVisualPressed ) { + drawPresetBootOpenButton( false ); + } + + if ( ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_BOOT_PREV || releasedTarget == M5STACK_TOUCH_TARGET_PRESET_BOOT_NEXT ) && touchState.presetBootNavButtonVisualPressed ) { + drawPresetBootNavigation( M5STACK_TOUCH_TARGET_NONE ); + } + + if ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_BOOT_HOLD && touchState.presetBootHoldButtonVisualPressed ) { + drawPresetBootHoldButton( false ); + } + } + + void clearColorEditState() { + hueEditValid = false; + saturationEditValid = false; + } + + void executeBackReleaseAction() { + if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_BOOT ) { + presetBootOperationState = PRESET_BOOT_OP_IDLE; + presetBootTargetId = 0; + presetBootTargetName = "NONE"; + presetBootResultStartMs = 0; + + drawPresetManageScreen(); + + return; + } + + if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_DELETE ) { + presetDeleteOperationState = PRESET_DELETE_OP_IDLE; + + presetDeleteTargetId = 0; + + presetDeleteTargetName = ""; + + presetDeleteWasCurrentPreset = false; + presetDeleteWasBootPreset = false; + + presetDeleteResultStartMs = 0; + + drawPresetManageScreen(); + + return; + } + + if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_OVERWRITE ) { + presetSaveOperationState = PRESET_SAVE_OP_IDLE; + + presetSaveOperationIsOverwrite = false; + + presetSaveCandidateId = 0; + + presetSaveCandidateName = ""; + + presetOverwriteTargetId = 0; + + presetOverwriteTargetName = ""; + + drawPresetManageScreen(); + + return; + } + + if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_SAVE ) { + presetSaveOperationState = PRESET_SAVE_OP_IDLE; + + presetSaveCandidateId = 0; + + presetSaveCandidateName = ""; + + drawPresetManageScreen(); + + return; + } + + if ( currentPage == SCREEN_PRESET && presetSubPage == PRESET_SUBPAGE_MANAGE ) { + drawPresetScreen(); + + return; + } + + drawMainScreen( getCurrentNetworkDisplayText() ); + } + + void executeTouchReleaseAction( TouchReleaseAction action, M5StackTouchTarget releasedTarget, unsigned long now ) { + switch (action) { + case TOUCH_RELEASE_ACTION_POWER: + touchState.lastTouchAction = now; + + toggleLedPowerFromTouch(); + + clearColorEditState(); + return; + + case TOUCH_RELEASE_ACTION_BRIGHTNESS_SHORT: + brightnessShortPress( releasedTarget ); + + drawBrightness( bri, M5STACK_TOUCH_TARGET_NONE ); + + lastBrightnessValue = bri; + + clearColorEditState(); + return; + + case TOUCH_RELEASE_ACTION_EFFECT_STEP: + if ( releasedTarget == M5STACK_TOUCH_TARGET_EFFECT_PREV ) { + applyEffectStep( -1 ); + } + else { + applyEffectStep( 1 ); + } + + drawEffect( getCurrentEffectMode(), M5STACK_TOUCH_TARGET_NONE ); + + clearColorEditState(); + return; + + case TOUCH_RELEASE_ACTION_EFFECT_DETAIL: + clearColorEditState(); + + drawEffectDetailScreen(); + return; + + case TOUCH_RELEASE_ACTION_COLOR_OPEN: + clearColorEditState(); + + drawColorScreen(); + return; + + case TOUCH_RELEASE_ACTION_COLOR_SLOT_SELECT: + clearColorEditState(); + + if ( releasedTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_1 ) { + selectColorSlot( 0 ); + } + else if ( releasedTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_2 ) { + selectColorSlot( 1 ); + } + else if ( releasedTarget == M5STACK_TOUCH_TARGET_COLOR_SLOT_3 ) { + selectColorSlot( 2 ); + } + + return; + + case TOUCH_RELEASE_ACTION_PRESET_OPEN: + clearColorEditState(); + + presetNoEntries = false; + + drawPresetScreen(); + return; + + case TOUCH_RELEASE_ACTION_PRESET_MANAGE_OPEN: + clearColorEditState(); + + drawPresetManageScreen(); + return; + + case TOUCH_RELEASE_ACTION_PRESET_SAVE_NEW: + clearColorEditState(); + + if ( preparePresetSaveCandidate() ) { + drawPresetSaveScreen(); + } + else { + drawPresetManageScreen(); + } + return; + + case TOUCH_RELEASE_ACTION_PRESET_OVERWRITE_OPEN: + clearColorEditState(); + + if ( preparePresetOverwriteTarget() ) { + drawPresetOverwriteScreen(); + } + else { + drawPresetManageScreen(); + } + return; + + case TOUCH_RELEASE_ACTION_PRESET_OVERWRITE_STEP: { + int direction = ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_OVERWRITE_PREV ) ? -1 : 1; + + if ( stepPresetOverwriteTarget( direction ) ) { + drawPresetOverwriteScreen(); + } + + clearColorEditState(); + return; + } + + case TOUCH_RELEASE_ACTION_PRESET_DELETE_OPEN: + clearColorEditState(); + + if ( preparePresetDeleteTarget() ) { + drawPresetDeleteScreen(); + } + else { + drawPresetManageScreen(); + } + return; + + case TOUCH_RELEASE_ACTION_PRESET_DELETE_STEP: { + int direction = ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_DELETE_PREV ) ? -1 : 1; + + if ( stepPresetDeleteTarget( direction ) ) { + drawPresetDeleteScreen(); + } + + clearColorEditState(); + return; + } + + case TOUCH_RELEASE_ACTION_PRESET_BOOT_OPEN: + clearColorEditState(); + + if ( preparePresetBootTarget() ) { + drawPresetBootScreen(); + } + else { + drawPresetManageScreen(); + } + return; + + case TOUCH_RELEASE_ACTION_PRESET_BOOT_STEP: { + int direction = ( releasedTarget == M5STACK_TOUCH_TARGET_PRESET_BOOT_PREV ) ? -1 : 1; + + if ( stepPresetBootTarget( direction ) ) { + drawPresetBootScreen(); + } + + clearColorEditState(); + return; + } + + case TOUCH_RELEASE_ACTION_BACK: + clearColorEditState(); + + executeBackReleaseAction(); + return; + + case TOUCH_RELEASE_ACTION_HUE_SHORT: + hueShortPress( releasedTarget ); + + drawHue( logicalHueValue, M5STACK_TOUCH_TARGET_NONE ); + + drawSaturation( logicalSaturationValue, M5STACK_TOUCH_TARGET_NONE ); + + clearColorEditState(); + return; + + case TOUCH_RELEASE_ACTION_SATURATION_SHORT: + saturationShortPress( releasedTarget ); + + drawHue( logicalHueValue, M5STACK_TOUCH_TARGET_NONE ); + + drawSaturation( logicalSaturationValue, M5STACK_TOUCH_TARGET_NONE ); + + clearColorEditState(); + return; + + case TOUCH_RELEASE_ACTION_SPEED_SHORT: + speedShortPress( releasedTarget ); + + drawSpeed( getCurrentSpeed(), M5STACK_TOUCH_TARGET_NONE ); + + lastSpeedValue = getCurrentSpeed(); + + clearColorEditState(); + return; + + case TOUCH_RELEASE_ACTION_INTENSITY_SHORT: + intensityShortPress( releasedTarget ); + + drawIntensity( getCurrentIntensity(), M5STACK_TOUCH_TARGET_NONE ); + + lastIntensityValue = getCurrentIntensity(); + + clearColorEditState(); + return; + + case TOUCH_RELEASE_ACTION_PALETTE_SHORT: + paletteShortPress( releasedTarget ); + + drawPalette( getCurrentPalette(), M5STACK_TOUCH_TARGET_NONE ); + + lastPaletteValue = getCurrentPalette(); + + clearColorEditState(); + return; + + case TOUCH_RELEASE_ACTION_PRESET_SHORT: + presetShortPress( releasedTarget ); + + drawPresetNavigation( M5STACK_TOUCH_TARGET_NONE ); + + clearColorEditState(); + return; + + default: + clearColorEditState(); + return; + } + } + + // ========================================================= + // Touch Release + // + // Release confirmation is unchanged. Action selection, + // pressed-visual release, gesture reset, and action execution + // are now separated into focused helpers. + // ========================================================= + + void handleTouchRelease( const M5StackTouchReleaseContext& context ) { + M5StackTouchRuntimeState& touchState = context.state; + const unsigned long now = context.now; + + if (!touchState.touchActive) { + return; + } + + if ( touchState.touchReleaseCandidate == 0 ) { + touchState.touchReleaseCandidate = now; + + return; + } + + if ( now - touchState.touchReleaseCandidate < TOUCH_RELEASE_CONFIRM_MS ) { + return; + } + + M5StackTouchTarget releasedTarget = touchState.touchTarget; + + TouchReleaseAction releaseAction = determineTouchReleaseAction( releasedTarget, now ); + + releaseTouchVisualState( releasedTarget ); + + ColorEditSnapshot editSnapshot = { + hueEditValid, hueEditHsv, hueEditValue, hueEditWhite, + saturationEditValid, saturationEditHsv, saturationEditValue, saturationEditWhite + }; + + resetTouchGesture(); + + hueEditValid = editSnapshot.hueValid; + hueEditHsv = editSnapshot.hueHsv; + hueEditValue = editSnapshot.hueValue; + hueEditWhite = editSnapshot.hueWhite; + + saturationEditValid = editSnapshot.saturationValid; + saturationEditHsv = editSnapshot.saturationHsv; + saturationEditValue = editSnapshot.saturationValue; + saturationEditWhite = editSnapshot.saturationWhite; + + executeTouchReleaseAction( releaseAction, releasedTarget, now ); + } + + // ========================================================= + // Touch dispatcher + // + // Poll touch once, then route the same state through + // Press / Hold / Release helpers. + // ========================================================= + + void handleTouch() { + if (!touchReady) { + return; + } + + if (!readyScreenShown) { + return; + } + + if ( isPresetSaveBusy() || isPresetDeleteBusy() || isPresetBootBusy() ) { + return; + } + + unsigned long now = millis(); + + if ( now - touchState.lastTouchPoll < TOUCH_POLL_MS ) { + return; + } + + touchState.lastTouchPoll = now; + + int16_t touchX = -1; + + int16_t touchY = -1; + + bool touching = readDisplayTouch( touchX, touchY ); + + if (touching) { + + lastUserActivityMs = now; + + touchState.touchReleaseCandidate = 0; + + touchState.lastTouchX = touchX; + + touchState.lastTouchY = touchY; + + M5StackTouchHitState hit = buildTouchHitState( touchX, touchY ); + + M5StackTouchFrameContext touchContext = { + touchState, + hit, + now + }; + + handleTouchPress( touchContext ); + + handleTouchHold( touchContext ); + + return; + } + + M5StackTouchReleaseContext releaseContext = { + touchState, + now + }; + + handleTouchRelease( releaseContext ); + } diff --git a/usermods/CoreS3_Display/M5StackDisplayUI.h b/usermods/CoreS3_Display/M5StackDisplayUI.h new file mode 100644 index 0000000000..e5e125ddf0 --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayUI.h @@ -0,0 +1,68 @@ +#pragma once + +#include + +// Common M5Stack controller UI geometry. +// +// The 320 x 240 hit areas below are the hardware-verified CoreS3 +// baseline. Another M5Stack profile may reuse them only after +// real-hardware validation. + +// =========================================================== +// Shared 320 x 240 UI touch rectangles +// +// These hit areas are part of the common M5Stack controller UI. +// Their numeric values remain the hardware-verified CoreS3 baseline +// and can be reused by another profile only after hardware validation. +// =========================================================== + +struct M5StackTouchRect { + int16_t x; + int16_t y; + int16_t w; + int16_t h; +}; + +static constexpr M5StackTouchRect M5STACK_TOUCH_POWER = { 8, 8, 44, 44 }; +// MAIN header: Recovery AP hold applies only to the network-status area. +// The right-side battery indicator is display-only and intentionally has +// no touch target. +static constexpr M5StackTouchRect M5STACK_TOUCH_WIFI_RECOVERY = { 60, 28, 178, 28 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_BRIGHTNESS_DOWN = { 16, 82, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_BRIGHTNESS_UP = { 240, 82, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_EFFECT_PREV = { 16, 138, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_EFFECT_DETAIL = { 88, 138, 144, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_EFFECT_NEXT = { 240, 138, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_COLOR_OPEN = { 8, 180, 152, 60 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_PRESET_OPEN = { 160, 180, 152, 60 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_BACK = { 268, 8, 44, 44 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_COLOR_SLOT_1 = { 24, 62, 88, 54 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_COLOR_SLOT_2 = { 116, 62, 88, 54 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_COLOR_SLOT_3 = { 208, 62, 88, 54 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_HUE_DOWN = { 16, 151, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_HUE_UP = { 240, 151, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_SATURATION_DOWN = { 16, 204, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_SATURATION_UP = { 240, 204, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_SPEED_DOWN = { 16, 82, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_SPEED_UP = { 240, 82, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_INTENSITY_DOWN = { 16, 140, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_INTENSITY_UP = { 240, 140, 64, 34 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_PALETTE_PREV = { 8, 188, 80, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_PALETTE_NEXT = { 232, 188, 80, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_PRESET_PREV = { 8, 188, 80, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_PRESET_NEXT = { 232, 188, 80, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_PRESET_MANAGE = { 88, 188, 144, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_PRESET_SAVE_NEW = { 48, 60, 224, 42 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_PRESET_SAVE_HOLD = { 48, 170, 224, 66 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_OVERWRITE_OPEN = { 48, 102, 224, 42 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_OVERWRITE_PREV = { 8, 116, 80, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_OVERWRITE_NEXT = { 232, 116, 80, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_OVERWRITE_HOLD = { 48, 184, 224, 56 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_DELETE_OPEN = { 48, 144, 224, 42 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_DELETE_PREV = { 8, 116, 80, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_DELETE_NEXT = { 232, 116, 80, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_DELETE_HOLD = { 48, 184, 224, 56 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_BOOT_OPEN = { 48, 186, 224, 48 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_BOOT_PREV = { 8, 116, 80, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_BOOT_NEXT = { 232, 116, 80, 52 }; +static constexpr M5StackTouchRect M5STACK_TOUCH_BOOT_HOLD = { 48, 184, 224, 56 }; diff --git a/usermods/CoreS3_Display/library.json b/usermods/CoreS3_Display/library.json new file mode 100644 index 0000000000..356e43e2a1 --- /dev/null +++ b/usermods/CoreS3_Display/library.json @@ -0,0 +1,11 @@ +{ + "name": "CoreS3_Display", + "version": "0.1.0", + "description": "M5Stack CoreS3 display usermod for WLED", + "build": { + "libArchive": false + }, + "dependencies": { + "M5GFX": "https://github.com/m5stack/M5GFX.git#0.2.26" + } +} diff --git a/usermods/CoreS3_Display/platformio_override.ini.sample b/usermods/CoreS3_Display/platformio_override.ini.sample new file mode 100644 index 0000000000..d9d3cc16df --- /dev/null +++ b/usermods/CoreS3_Display/platformio_override.ini.sample @@ -0,0 +1,72 @@ +; ============================================================================= +; WLED - M5Stack CoreS3 +; Public build example for the CoreS3 production configuration +; +; Usage: +; Copy this file to the WLED repository root as: +; platformio_override.ini +; +; Validated runtime basis: +; WLED 17.0.0-devV5 / current main-derived tree +; pioarduino 55.03.39 +; Arduino Core 3.3.9 +; ESP-IDF 5.5.4 +; M5Stack CoreS3: 16MB Flash + 8MB QUAD PSRAM +; +; This example preserves: +; - the validated CoreS3 Quad PSRAM configuration +; - CoreS3 Power / Display / Audio / AudioReactive integration +; - the validated ESP32-S3 NeoPixelBus RMT DMA1024 and LCD/GDMA patches +; +; IMPORTANT: +; Do NOT set lib_archive=yes in this custom environment. +; WLED main already contains [v5_pioarduino_workaround]. +; ============================================================================= + +[platformio] +default_envs = m5stack_cores3 + +[env:m5stack_cores3] + +; WLED's current 16MB ESP32-S3 parent environment. +extends = env:esp32s3dev_16MB_opi + +; CoreS3 uses 8MB QUAD PSRAM, not Octal PSRAM. +board_build.arduino.memory_type = qio_qspi +board_build.flash_mode = qio + +; Current WLED pioarduino / ESP-IDF 5 build family. +platform = ${esp32_idf_V5.platform_pioarduino} +platform_packages = ${esp32_idf_V5.platform_packages_pioarduino} + +; Preserve normal WLED build scripts. +; +; Apply the validated NeoPixelBus ESP32-S3 RMT DMA1024 and LCD/GDMA patches. +extra_scripts = + ${scripts_defaults.extra_scripts} + pre:pio-scripts/cores3_v17_neopixelbus_patch.py + +custom_usermods = + CoreS3_Power + CoreS3_Display + CoreS3_Audio + audioreactive + +build_unflags = + ${env:esp32s3dev_16MB_opi.build_unflags} + +build_flags = + ${env:esp32s3dev_16MB_opi.build_flags} + + ; CoreS3 internal I2C bus. + -D I2CSDAPIN=12 + -D I2CSCLPIN=11 + + ; CoreS3 built-in ES7210 / AudioReactive integration. + -D WLED_M5STACK_CORES3_AUDIO=1 + -D UM_AUDIOREACTIVE_ENABLE + -D SR_DMTYPE=7 + + ; GPIO0 is ES7210 MCLK, not a WLED physical button. + -D BTNPIN=-1 + -D BTNTYPE=0 diff --git a/usermods/CoreS3_Display/readme.md b/usermods/CoreS3_Display/readme.md new file mode 100644 index 0000000000..8c8380a620 --- /dev/null +++ b/usermods/CoreS3_Display/readme.md @@ -0,0 +1,552 @@ +# WLED on M5Stack CoreS3 + +[日本語](readme_jp.md) + +This project runs the WLED v17 series natively on M5Stack CoreS3 and combines
+**local touch control / built-in microphone Audio Reactive / battery status / safe power-off / LED control** in a single device. + +> **Status:** Hardware-validated CoreS3 implementation
+> **Base:** WLED 17.0.0-devV5
+> **Target:** M5Stack CoreS3 (ESP32-S3 / 16MB Flash / 8MB Quad PSRAM) +> +> This is a community-maintained CoreS3 port/extension of WLED and is not an official WLED or M5Stack firmware release. + +--- + +## Features + +- Native WLED runtime on M5Stack CoreS3 +- Local control with the 320 × 240 touch display +- Bidirectional synchronization between the WLED Web UI and CoreS3 UI +- LED Brightness / Effect / Speed / Intensity / Palette control +- Independent C1 / C2 / C3 multi-color editing +- Preset Call / SAVE / OVERWRITE / DELETE / BOOT PRESET +- Audio Reactive using the built-in ES7210 dual microphones +- Battery level display +- Display Sleep / Wake +- Wi-Fi Offline / Recovery AP UX +- Safe Shutdown using the AXP2101 Power Key +- LED BLACK frame before hard power-off +- Restore of the previous LED state when Safe Shutdown is canceled +- ESP32-S3 / NeoPixelBus RMT DMA1024 and LCD/GDMA runtime-rebuild stabilization +- Browser capture of the current LCD as a BMP image + +--- + +## Screenshots + +The following images were captured directly from the CoreS3 LCD using the Browser Screenshot feature. + +### Main + +

+ +

+ +The MAIN screen provides LED Power, Brightness, Effect, Color, Preset, network status, and battery status. + +### Effect + + + + + + + + + + +
Standard EffectAudio Reactive Effect
+ +Only parameters that are available for the selected Effect are shown.
+Audio Reactive Effects can expose a different parameter set from standard Effects, and the UI follows that dynamically. + +### Color + + + + + + + + + + + + +
C1 EditC1 / C2Color Not Used
+ +C1 / C2 / C3 can be selected and edited independently.
+When the current Effect does not use a Color Slot, the UI shows `COLOR NOT USED`. + +### Preset + + + + + + + + + + + + + + + + + + + + + + +
PresetPreset ManageSave New
Delete PresetBoot Preset
+ +In addition to recalling Presets, CoreS3 can perform SAVE NEW / OVERWRITE / DELETE / BOOT PRESET operations. + +### Browser Screenshot + +The current CoreS3 LCD can be captured from a browser at: + +```text +http:///cores3/screenshot.bmp +``` + +Example: + +```text +http://192.168.1.100/cores3/screenshot.bmp +``` + +The endpoint returns a still image.
+Refreshing the URL captures the current 320 × 240 LCD contents again. + +The LCD is read as RGB565 and converted to a 24-bit BMP.
+Large screenshot buffers are not kept resident during normal operation; they are allocated in PSRAM only while a request is active. + +The PNG images included in this documentation were also created by converting BMP files captured with this Browser Screenshot endpoint on the PC side. + +--- + +## Tested Hardware + +### Controller + +- M5Stack CoreS3 +- ESP32-S3 240 MHz +- 16 MB Flash +- 8 MB Quad PSRAM +- AXP2101 PMIC +- ES7210 audio codec / built-in microphones +- 320 × 240 touch display + +### LED + +Hardware validation used the M5Stack DIGITAL RGB LED STRIP. + +- SK6812 +- RGB type +- 60 LEDs / m +- Two strips connected, for 120 physical LEDs +- WLED Bus Type: `WS281x` +- Color Order: `GRB` +- CoreS3 Port.C +- GPIO17 + +> **Important:** This project does not guarantee the safety of directly powering 120 LEDs at high brightness from the CoreS3.
+> For larger LED counts or higher brightness, use an appropriately sized external 5V LED power supply with a common GND. + +--- + +## Recommended Brightness + +The standard WLED default Brightness is not modified. + +When powering an LED Strip directly from CoreS3, hardware testing showed that the battery level can decrease even while USB-C power is connected at Brightness 128. + +For this reason, **a starting Brightness around 64** is recommended for the CoreS3 test configuration. + +```text +WLED default: 128 +CoreS3 recommendation: 64 +``` + +This does not modify the WLED core default.
+Actual power consumption depends heavily on LED count, colors, Effect, and the external power architecture. + +--- + +## Build Environment + +Validated build environment: + +```text +WLED 17.0.0-devV5 +PlatformIO env m5stack_cores3 +Platform pioarduino / platform-espressif32 55.03.39 +Arduino Core 3.3.9 +ESP-IDF libraries 5.5.4 +NeoPixelBus 2.9.0+sha.76afe83 +``` + +The CoreS3 environment is defined in `platformio_override.ini`. + +Key settings: + +```ini +[platformio] +default_envs = m5stack_cores3 + +[env:m5stack_cores3] +extends = env:esp32s3dev_16MB_opi + +board_build.arduino.memory_type = qio_qspi +board_build.flash_mode = qio +``` + +CoreS3-specific usermods: + +```text +CoreS3_Power +CoreS3_Display +CoreS3_Audio +audioreactive +``` + +Audio Reactive definitions: + +```text +WLED_M5STACK_CORES3_AUDIO=1 +UM_AUDIOREACTIVE_ENABLE +SR_DMTYPE=7 +``` + +GPIO0 is used for the ES7210 MCLK and is therefore excluded from the WLED physical Button configuration. + +--- + + +### Public Build Example + +A ready-to-use CoreS3 PlatformIO override example is included at: + +```text +usermods/CoreS3_Display/platformio_override.ini.sample +``` + +Copy it to the WLED repository root and rename it to: + +```text +platformio_override.ini +``` + +The example includes the CoreS3 environment, Quad PSRAM settings, CoreS3 usermods, Audio Reactive definitions, and the NeoPixelBus patch pre-script. + +## Build + +### 1. Requirements + +- Visual Studio Code +- PlatformIO IDE +- Git +- USB-C cable + +### 2. Open the WLED source tree + +Open the WLED repository in VS Code. + +Confirm that `platformio_override.ini` exists at the repository root. If it does not, copy `usermods/CoreS3_Display/platformio_override.ini.sample` to the repository root and rename it to `platformio_override.ini`. + +### 3. Build + +Build the following PlatformIO environment: + +```text +m5stack_cores3 +``` + +A successful build ends with: + +```text +Environment Status +-------------- ------- +m5stack_cores3 SUCCESS +``` + +### 4. Upload + +Upload the `m5stack_cores3` environment from PlatformIO. + +If another serial monitor or program is holding the COM port open, close it before Upload. + + +--- + +## NeoPixelBus / RMT DMA1024 + LCD/GDMA Patches + +With ESP32-S3 + NeoPixelBus RMT output, hardware testing found an intermittent condition where pixels beyond the configured LED Count could light unexpectedly. + +The CoreS3 build applies the following stabilization: + +```text +RMT DMA enabled +mem_block_symbols 1024 +``` + +The patch does not rely on manually editing files under `.pio/libdeps`. + +```text +pio-scripts/cores3_v17_neopixelbus_patch.py +``` + +runs as a PlatformIO pre-script and automatically applies the DMA1024 patch even after NeoPixelBus is downloaded again. + +Example when the patch is applied: + +```text +[CoreS3 RMT DMA1024] applied ESP32-S3 DMA / 1024-symbol patch: ... +``` + +When already present: + +```text +[CoreS3 RMT DMA1024] patch already present: NeoEsp32RmtXMethod.h +``` + +The same pre-script also applies the validated LCD/GDMA teardown fix used during runtime LED-bus rebuilds.
+When the last LCD mux bus is destroyed, the GDMA channel is stopped, reset, disconnected, and deleted so stale LCD peripheral ownership is not carried into the next bus initialization. + +Example when the LCD/GDMA patch is applied: + +```text +[CoreS3 LCD GDMA] applied full GDMA teardown production patch: ... +``` + +When already present: + +```text +[CoreS3 LCD GDMA] patch already present: NeoEsp32LcdXMethod.h +``` + +Both patches are idempotent. Their reproducibility has been validated by deleting the NeoPixelBus dependency and rebuilding from a clean dependency state. + +--- + +## Power Management + +### DCDC3 Always-PWM + +During investigation of unexpected complete CoreS3 power-offs, DCDC OVP was observed multiple times in the AXP2101 `PWROFF_STATUS`. + +The CoreS3 Power usermod uses DCDC3 Always-PWM as a stabilization measure. + +```text +DCDC1: AUTO +DCDC3: ALWAYS_PWM +OVP protection: unchanged / enabled +``` + +Long-duration hardware testing and regression checks showed strong stabilization with this setting. + +> This does not claim that DCDC3 itself was proven to be the sole hardware root cause.
+> In this project, DCDC3 Always-PWM is treated as a stabilization measure that has been strongly validated on real hardware. + +### Safe Shutdown + +Holding the physical CoreS3 Power Key sends a BLACK frame to the LED Strip before the PMIC hard power-off. + +Processing sequence: + +```text +Power Key long press + ↓ +LED BLACK frame + ↓ +Strip suspend + ↓ +AXP2101 hard power-off +``` + +If the Power Key is released after BLACK but before the final hard power-off, shutdown is canceled and the previous LED output and Brightness are restored. + +--- + +## Audio Reactive + +The built-in ES7210 and CoreS3 microphones are used. + +The Audio usermod initializes ES7210, while Audio Reactive owns I2S1 / PCM / FFT processing. + +Main settings: + +```text +Codec ES7210 +I2S I2S1 +Sample Rate 16000 Hz +Format Stereo / 16-bit +MCLK GPIO0 +DIN GPIO14 +``` + +Codec initialization is deferred slightly during startup and retried multiple times if necessary. + +Audio status can be checked from WLED Info. + +--- + +## Touch UI + +Main screens: + +```text +MAIN +COLOR +EFFECT +PRESET +PRESET MANAGE +``` + +CoreS3 and the WLED Web UI synchronize in both directions. + +- Change on CoreS3 → reflected in Web UI +- Change in Web UI → reflected on CoreS3 + +Short press / long press / drag touch operations are supported. + +--- + +## Preset Management + +The following operations are available from CoreS3: + +```text +Preset Call +SAVE NEW +OVERWRITE +DELETE +BOOT PRESET +``` + +A Preset cache is used to synchronize with Presets stored by WLED. + +--- + +## Network Recovery + +If Wi-Fi connectivity is lost, Recovery AP can be started from the CoreS3 Display. + +When Recovery AP is active, access: + +```text +http://4.3.2.1 +``` + +The normal WLED Web UI and CoreS3 local controls can be used together. + +--- + +## Battery Status + +Battery level is read from the CoreS3 AXP2101 fuel gauge and displayed on the MAIN screen. + +Battery status is updated periodically instead of continuously polling I2C. + +--- + +## Project Structure + +The main CoreS3-specific files are: + +```text +WLED/ +├─ platformio_override.ini +├─ pio-scripts/ +│ └─ cores3_v17_neopixelbus_patch.py +└─ usermods/ + ├─ CoreS3_Power/ + ├─ CoreS3_Display/ + ├─ CoreS3_Audio/ + └─ audioreactive/ +``` + +### CoreS3_Power + +Responsibilities: + +- AXP2101 +- AW9523B +- External 5V +- DCDC3 Always-PWM +- Power Key +- Safe Shutdown +- Power Health + +### CoreS3_Display + +Responsibilities: + +- M5GFX +- Touch +- Local UI +- Battery display +- Wi-Fi status +- Preset UI +- Browser Screenshot + +### CoreS3_Audio + +Responsibilities: + +- ES7210 probe / initialization +- Audio pins +- Audio health +- Audio Reactive handoff + +### audioreactive + +Adds CoreS3 built-in microphone / I2S1 integration. + +--- + +## Current Limitations / Notes + +- Segment management is intentionally not implemented in the local UI.
+ Use the WLED Web UI for Segment configuration. +- Directly powering 120 LEDs from CoreS3 at high brightness is not recommended. +- The standard WLED Brightness default of 128 is not modified. +- A starting Brightness around 64 is recommended for CoreS3. +- Browser Screenshot returns a still BMP image; it is not a live stream. +- DCDC OVP protection is not disabled. + +--- + +## Upstream + +This project is based on WLED: + +https://github.com/wled/WLED + +WLED itself remains the upstream project.
+Please refer to the upstream repository for WLED documentation, supported LED types, API behavior, and licensing. + +--- + +## Licensing + +WLED source in this repository follows the upstream **EUPL v1.2** license.
+NeoPixelBus remains licensed under **LGPL-3.0-or-later**. The CoreS3 build-time patch script modifies the PlatformIO-downloaded NeoPixelBus source while preserving the upstream library license header. + +Refer to the repository `LICENSE` file and the respective upstream projects for complete license terms. + +--- + +## Acknowledgements + +- WLED project and contributors +- M5Stack +- NeoPixelBus +- Audio Reactive / WLED usermod contributors + +This project builds on the work of many open-source projects and contributors. diff --git a/usermods/CoreS3_Display/readme_jp.md b/usermods/CoreS3_Display/readme_jp.md new file mode 100644 index 0000000000..d03354cfe6 --- /dev/null +++ b/usermods/CoreS3_Display/readme_jp.md @@ -0,0 +1,552 @@ +# WLED on M5Stack CoreS3 + +[English](readme.md) + +M5Stack CoreS3 上で WLED v17 系をネイティブ動作させ、
+**タッチディスプレイ / 内蔵マイク Audio Reactive / バッテリー表示 / 安全な電源OFF / LED制御** を1台にまとめるプロジェクトです。 + +> **Status:** CoreS3 実機検証済み実装
+> **Base:** WLED 17.0.0-devV5
+> **Target:** M5Stack CoreS3 (ESP32-S3 / 16MB Flash / 8MB Quad PSRAM) +> +> 本プロジェクトはコミュニティによる WLED の CoreS3 向け移植・拡張であり、WLED または M5Stack の公式ファームウェアではありません。 + +--- + +## Features + +- WLED を M5Stack CoreS3 上でネイティブ実行 +- 320 × 240 タッチディスプレイによるローカル操作 +- WLED Web UI と CoreS3 UI の双方向同期 +- LED Brightness / Effect / Speed / Intensity / Palette 操作 +- C1 / C2 / C3 のマルチカラー編集 +- Preset 呼び出し / SAVE / OVERWRITE / DELETE / BOOT PRESET +- 内蔵 ES7210 デュアルマイクを利用した Audio Reactive +- バッテリー残量表示 +- Display Sleep / Wake +- Wi-Fi Offline / Recovery AP UX +- AXP2101 Power Key を使った Safe Shutdown +- 電源OFF前に LED BLACK frame を送信 +- Safe Shutdown のキャンセル時は直前の LED 状態を復元 +- ESP32-S3 / NeoPixelBus 向け RMT DMA1024 および LCD/GDMA runtime-rebuild 安定化 +- ブラウザから現在の LCD 画面を BMP で取得 + +--- + +## Screenshots + +以下は、CoreS3 本体の LCD を Browser Screenshot 機能で直接取得した実画面です。 + +### Main + +

+ +

+ +MAIN 画面では、LED Power、Brightness、Effect、Color、Preset、およびネットワーク / バッテリー状態を確認できます。 + +### Effect + + + + + + + + + + +
Standard EffectAudio Reactive Effect
+ +Effect ごとに使用可能なパラメータだけを表示します。
+Audio Reactive Effect では、通常 Effect と異なるパラメータ構成にも追従します。 + +### Color + + + + + + + + + + + + +
C1 EditC1 / C2Color Not Used
+ +C1 / C2 / C3 を個別に選択して編集できます。
+Effect が Color Slot を使用しない場合は `COLOR NOT USED` と表示します。 + +### Preset + + + + + + + + + + + + + + + + + + + + + + +
PresetPreset ManageSave New
Delete PresetBoot Preset
+ +Preset の呼び出しだけでなく、SAVE NEW / OVERWRITE / DELETE / BOOT PRESET まで CoreS3 から操作できます。 + +### Browser Screenshot + +CoreS3 の現在の LCD 表示は、ブラウザから次の URL で取得できます。 + +```text +http:///cores3/screenshot.bmp +``` + +例: + +```text +http://192.168.1.100/cores3/screenshot.bmp +``` + +Screenshot は静止画です。
+URL を更新すると、その時点の 320 × 240 LCD 内容を再取得します。 + +BMP は CoreS3 の LCD から RGB565 で読み出し、24-bit BMP に変換して返します。
+大きな Screenshot バッファは通常時には常駐せず、要求時のみ PSRAM に確保します。 + +このドキュメントに掲載している PNG 画像も、Browser Screenshot で取得した BMP を PC 側で PNG に変換したものです。 + +--- + +## Tested Hardware + +### Controller + +- M5Stack CoreS3 +- ESP32-S3 240 MHz +- 16 MB Flash +- 8 MB Quad PSRAM +- AXP2101 PMIC +- ES7210 audio codec / built-in microphones +- 320 × 240 touch display + +### LED + +実機確認では M5Stack の DIGITAL RGB LED STRIP を使用しています。 + +- SK6812 +- RGB type +- 60 LEDs / m +- 2本連結で物理的には 120 LEDs +- WLED Bus Type: `WS281x` +- Color Order: `GRB` +- CoreS3 Port.C +- GPIO17 + +> **Important:** 120 LEDs を CoreS3 から直接高輝度で駆動する電源構成は、本プロジェクトでは安全性を保証していません。
+> LED 数や輝度が大きい場合は、LED 用の適切な外部 5V 電源と共通 GND を使用してください。 + +--- + +## Recommended Brightness + +WLED 標準の初期 Brightness は変更していません。 + +CoreS3 から LED Strip を直接使用する場合、実機では Brightness 128 でも USB-C 給電中にバッテリー残量が低下する状況を確認しています。 + +そのため、CoreS3 での開始値としては **Brightness 64 前後**を推奨します。 + +```text +WLED default: 128 +CoreS3 recommendation: 64 +``` + +これは WLED 本体のデフォルト値を変更するものではありません。
+使用 LED 数、色、Effect、外部電源構成によって消費電力は大きく変化します。 + +--- + +## Build Environment + +検証済みのビルド環境: + +```text +WLED 17.0.0-devV5 +PlatformIO env m5stack_cores3 +Platform pioarduino / platform-espressif32 55.03.39 +Arduino Core 3.3.9 +ESP-IDF libraries 5.5.4 +NeoPixelBus 2.9.0+sha.76afe83 +``` + +`platformio_override.ini` で CoreS3 用の環境を定義しています。 + +主な設定: + +```ini +[platformio] +default_envs = m5stack_cores3 + +[env:m5stack_cores3] +extends = env:esp32s3dev_16MB_opi + +board_build.arduino.memory_type = qio_qspi +board_build.flash_mode = qio +``` + +CoreS3 固有 Usermod: + +```text +CoreS3_Power +CoreS3_Display +CoreS3_Audio +audioreactive +``` + +Audio Reactive 用: + +```text +WLED_M5STACK_CORES3_AUDIO=1 +UM_AUDIOREACTIVE_ENABLE +SR_DMTYPE=7 +``` + +GPIO0 は ES7210 MCLK として使用するため、WLED の物理 Button から除外しています。 + +--- + + +### 公開用 Build Example + +CoreS3 用の PlatformIO 設定サンプルを次の場所に同梱しています。 + +```text +usermods/CoreS3_Display/platformio_override.ini.sample +``` + +このファイルを WLED リポジトリ直下へコピーし、次の名前に変更して使用します。 + +```text +platformio_override.ini +``` + +このサンプルには、CoreS3 Environment、Quad PSRAM 設定、CoreS3 Usermod、Audio Reactive 定義、および NeoPixelBus patch 用 pre-script が含まれています。 + +## Build + +### 1. Requirements + +- Visual Studio Code +- PlatformIO IDE +- Git +- USB-C cable + +### 2. Open the WLED source tree + +WLED リポジトリを VS Code で開きます。 + +`platformio_override.ini` がリポジトリ直下にあることを確認してください。存在しない場合は、`usermods/CoreS3_Display/platformio_override.ini.sample` をリポジトリ直下へコピーし、`platformio_override.ini` にリネームしてください。 + +### 3. Build + +PlatformIO で次の Environment を Build します。 + +```text +m5stack_cores3 +``` + +正常時は最後に次のように表示されます。 + +```text +Environment Status +-------------- ------- +m5stack_cores3 SUCCESS +``` + +### 4. Upload + +PlatformIO から `m5stack_cores3` を Upload します。 + +シリアルモニタなどのプログラムが COM ポートを開いている場合は、Upload 前に閉じてください。 + + +--- + +## NeoPixelBus / RMT DMA1024 + LCD/GDMA Patch + +ESP32-S3 + NeoPixelBus の RMT 出力では、LED Count より後ろのピクセルが不定期に点灯する問題を実機で確認しました。 + +CoreS3 向けには次の安定化を適用しています。 + +```text +RMT DMA enabled +mem_block_symbols 1024 +``` + +パッチは `.pio/libdeps` のライブラリを手作業で変更する方式ではありません。 + +```text +pio-scripts/cores3_v17_neopixelbus_patch.py +``` + +が PlatformIO の pre-script として動作し、NeoPixelBus を新規取得した場合でも自動的に DMA1024 patch を適用します。 + +Build 時の例: + +```text +[CoreS3 RMT DMA1024] applied ESP32-S3 DMA / 1024-symbol patch: ... +``` + +すでに適用済みの場合: + +```text +[CoreS3 RMT DMA1024] patch already present: NeoEsp32RmtXMethod.h +``` + +同じ pre-script では、runtime の LED Bus 再構築時に使用する LCD/GDMA teardown 修正も適用します。
+最後の LCD mux bus を破棄する際に GDMA channel を stop / reset / disconnect / delete し、次回の Bus 初期化へ古い LCD peripheral ownership が残らないようにします。 + +LCD/GDMA patch 適用時の例: + +```text +[CoreS3 LCD GDMA] applied full GDMA teardown production patch: ... +``` + +すでに適用済みの場合: + +```text +[CoreS3 LCD GDMA] patch already present: NeoEsp32LcdXMethod.h +``` + +2つの patch はどちらも idempotent です。NeoPixelBus dependency を削除したクリーンな状態からの再 Build でも再適用性を確認済みです。 + +--- + +## Power Management + +### DCDC3 Always-PWM + +CoreS3 の予期しない完全電源OFF調査では、AXP2101 の `PWROFF_STATUS` で DCDC OVP を複数回確認しました。 + +CoreS3 Power Usermod では、DCDC3 を Always-PWM に設定する安定化策を使用しています。 + +```text +DCDC1: AUTO +DCDC3: ALWAYS_PWM +OVP protection: unchanged / enabled +``` + +この設定は長時間実機試験と回帰確認で安定化効果を確認しています。 + +> DCDC3 がハードウェア上の絶対的な根本原因だった、と断定しているわけではありません。
+> 本プロジェクトでは「実機で強く検証された安定化策」として扱っています。 + +### Safe Shutdown + +CoreS3 の物理 Power Key 長押しでは、PMIC hard-off の前に LED Strip へ BLACK frame を送信します。 + +処理の概要: + +```text +Power Key long press + ↓ +LED BLACK frame + ↓ +Strip suspend + ↓ +AXP2101 hard power-off +``` + +BLACK 送信後に Power Key を離して Shutdown をキャンセルした場合は、直前の LED 出力と Brightness を復元します。 + +--- + +## Audio Reactive + +CoreS3 内蔵 ES7210 と内蔵マイクを使用します。 + +Audio Usermod が ES7210 を初期化し、Audio Reactive 側が I2S1 / PCM / FFT を担当します。 + +主な仕様: + +```text +Codec ES7210 +I2S I2S1 +Sample Rate 16000 Hz +Format Stereo / 16-bit +MCLK GPIO0 +DIN GPIO14 +``` + +起動時に Codec 初期化を少し遅延し、失敗時は複数回 Retry する構成です。 + +WLED Info から Audio の状態を確認できます。 + +--- + +## Touch UI + +主な画面: + +```text +MAIN +COLOR +EFFECT +PRESET +PRESET MANAGE +``` + +CoreS3 と WLED Web UI は双方向に同期します。 + +- CoreS3 で変更 → Web UI に反映 +- Web UI で変更 → CoreS3 に反映 + +Touch の短押し / 長押し / ドラッグ操作もサポートしています。 + +--- + +## Preset Management + +CoreS3 から次の操作ができます。 + +```text +Preset Call +SAVE NEW +OVERWRITE +DELETE +BOOT PRESET +``` + +Preset cache を使用し、WLED 側で保存された Preset と同期します。 + +--- + +## Network Recovery + +Wi-Fi 接続が失われた場合、CoreS3 Display から Recovery AP を起動できる構成です。 + +Recovery AP 使用時は次へアクセスします。 + +```text +http://4.3.2.1 +``` + +通常の WLED Web UI と CoreS3 のローカル操作を併用できます。 + +--- + +## Battery Status + +CoreS3 の AXP2101 fuel gauge からバッテリー残量を取得し、MAIN 画面に表示します。 + +Battery status は定期更新され、常時 I2C polling し続けないよう間隔を設けています。 + +--- + +## Project Structure + +CoreS3 対応の中心は次のファイルです。 + +```text +WLED/ +├─ platformio_override.ini +├─ pio-scripts/ +│ └─ cores3_v17_neopixelbus_patch.py +└─ usermods/ + ├─ CoreS3_Power/ + ├─ CoreS3_Display/ + ├─ CoreS3_Audio/ + └─ audioreactive/ +``` + +### CoreS3_Power + +担当: + +- AXP2101 +- AW9523B +- External 5V +- DCDC3 Always-PWM +- Power Key +- Safe Shutdown +- Power Health + +### CoreS3_Display + +担当: + +- M5GFX +- Touch +- Local UI +- Battery display +- Wi-Fi status +- Preset UI +- Browser Screenshot + +### CoreS3_Audio + +担当: + +- ES7210 probe / initialization +- Audio pins +- Audio health +- Audio Reactive handoff + +### audioreactive + +CoreS3 built-in microphone / I2S1 連携を追加しています。 + +--- + +## Current Limitations / Notes + +- Local UI では Segment 管理を行いません
+ Segment 設定は WLED Web UI を使用します。 +- 120 LED を CoreS3 から直接高輝度で給電する構成は推奨しません。 +- WLED 標準 Brightness 128 は変更していません。 +- CoreS3 では Brightness 64 前後からの使用を推奨します。 +- Browser Screenshot は BMP の静止画です。ライブストリームではありません。 +- DCDC OVP 保護は無効化していません。 + +--- + +## Upstream + +This project is based on WLED: + +https://github.com/wled/WLED + +WLED itself remains the upstream project.
+Please also refer to the upstream repository for WLED documentation, supported LED types, API behavior, and licensing. + +--- + +## Licensing + +このリポジトリの WLED ソースは、upstream と同じ **EUPL v1.2** に従います。
+NeoPixelBus は **LGPL-3.0-or-later** のままです。CoreS3 の build-time patch script は PlatformIO が取得した NeoPixelBus ソースへ修正を適用しますが、upstream library のライセンスヘッダーは保持します。 + +完全なライセンス条件については、リポジトリの `LICENSE` と各 upstream project を参照してください。 + +--- + +## Acknowledgements + +- WLED project and contributors +- M5Stack +- NeoPixelBus +- Audio Reactive / WLED usermod contributors + +This project builds on the work of many open-source projects and contributors. diff --git a/usermods/CoreS3_Display/screenshots/color-c1-c2.png b/usermods/CoreS3_Display/screenshots/color-c1-c2.png new file mode 100644 index 0000000000..c01479d8f2 Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/color-c1-c2.png differ diff --git a/usermods/CoreS3_Display/screenshots/color-c1.png b/usermods/CoreS3_Display/screenshots/color-c1.png new file mode 100644 index 0000000000..fb060cbf4b Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/color-c1.png differ diff --git a/usermods/CoreS3_Display/screenshots/color-unused.png b/usermods/CoreS3_Display/screenshots/color-unused.png new file mode 100644 index 0000000000..95699f4662 Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/color-unused.png differ diff --git a/usermods/CoreS3_Display/screenshots/effect-rocktaves.png b/usermods/CoreS3_Display/screenshots/effect-rocktaves.png new file mode 100644 index 0000000000..98b312ec7a Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/effect-rocktaves.png differ diff --git a/usermods/CoreS3_Display/screenshots/effect-solid.png b/usermods/CoreS3_Display/screenshots/effect-solid.png new file mode 100644 index 0000000000..3a9c495146 Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/effect-solid.png differ diff --git a/usermods/CoreS3_Display/screenshots/main.png b/usermods/CoreS3_Display/screenshots/main.png new file mode 100644 index 0000000000..12152333dd Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/main.png differ diff --git a/usermods/CoreS3_Display/screenshots/preset-boot.png b/usermods/CoreS3_Display/screenshots/preset-boot.png new file mode 100644 index 0000000000..395c068981 Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/preset-boot.png differ diff --git a/usermods/CoreS3_Display/screenshots/preset-delete.png b/usermods/CoreS3_Display/screenshots/preset-delete.png new file mode 100644 index 0000000000..d99f2c5f3e Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/preset-delete.png differ diff --git a/usermods/CoreS3_Display/screenshots/preset-manage.png b/usermods/CoreS3_Display/screenshots/preset-manage.png new file mode 100644 index 0000000000..0153f4c36f Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/preset-manage.png differ diff --git a/usermods/CoreS3_Display/screenshots/preset-save.png b/usermods/CoreS3_Display/screenshots/preset-save.png new file mode 100644 index 0000000000..bb9fa1222a Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/preset-save.png differ diff --git a/usermods/CoreS3_Display/screenshots/preset.png b/usermods/CoreS3_Display/screenshots/preset.png new file mode 100644 index 0000000000..edb21b390a Binary files /dev/null and b/usermods/CoreS3_Display/screenshots/preset.png differ diff --git a/usermods/CoreS3_Power/CoreS3_Power.cpp b/usermods/CoreS3_Power/CoreS3_Power.cpp new file mode 100644 index 0000000000..e0ae406170 --- /dev/null +++ b/usermods/CoreS3_Power/CoreS3_Power.cpp @@ -0,0 +1,1293 @@ +#include "wled.h" +#include +#include +#include +#include + +/* + * CoreS3 Power Production Baseline + * + * Production baseline for M5Stack CoreS3 power and LED-bus runtime handling. + * + * LED runtime re-init protection: + * - WLED core re-init gate guarantees the usermod can inspect each pending + * bus configuration before strip.finalizeInit() replaces the old buses. + * - Physical digital buses are compared per primary data pin. + * - If any existing output shrinks or disappears, the complete old LED range + * is turned OFF and explicitly rendered BLACK before the bus rebuild. + * - WLED ON/OFF state is restored after re-init; an already-OFF state remains OFF. + * - Pure growth / unchanged configurations pass through without OFF/ON cycling. + * + * Preserved validated power behavior: + * - AW9523B external-5V enable writes (BOOST then BUS). + * - Runtime External 5V re-assert after M5GFX initialization. + * - AXP2101 power-key IRQ handling for Safe Shutdown. + * - Safe Physical Shutdown LED BLACK / suspend / cancel-and-restore logic. + * - AXP2101 DCDC3 Always-PWM stability setting. + * - Concise boot reset / PWRON / PWROFF cause reporting. + */ + +// ============================================================ +// Read-only CoreS3 power health bridge +// +// CoreS3_Display consumes these states for user-facing warning UX only. +// Power ownership and all register writes remain inside this usermod. +// ============================================================ +static volatile bool coreS3PowerInitializationCompleteState = false; +static volatile bool coreS3PowerExternal5VReadyState = false; +static volatile bool coreS3PowerSafeShutdownMonitorReadyState = false; + +// Guaranteed WLED bus re-init gate handshake. +// +// wled.cpp calls coreS3PowerShouldDeferBusReinit() immediately before it would +// consume doInitBusses and call strip.finalizeInit(). +// +// prepared=false -> leave doInitBusses asserted and defer finalizeInit(). +// prepared=true -> allow WLED to consume doInitBusses and rebuild now. +static volatile bool coreS3PowerBusReinitPreparedState = false; + +static inline void coreS3PowerMarkBusReinitPrepared() +{ + coreS3PowerBusReinitPreparedState = true; +} + +extern "C" bool coreS3PowerShouldDeferBusReinit() +{ + // Keep normal WLED startup behavior before this usermod completes setup. + if (!coreS3PowerInitializationCompleteState) return false; + + if (!coreS3PowerBusReinitPreparedState) return true; + + coreS3PowerBusReinitPreparedState = false; + return false; +} + +extern "C" bool coreS3PowerInitializationComplete() +{ + return coreS3PowerInitializationCompleteState; +} + +extern "C" bool coreS3PowerExternal5VReady() +{ + return coreS3PowerExternal5VReadyState; +} + +extern "C" bool coreS3PowerSafeShutdownMonitorReady() +{ + return coreS3PowerSafeShutdownMonitorReadyState; +} + +class CoreS3PowerUsermod : public Usermod +{ +private: + static constexpr uint8_t AW9523B_ADDR = 0x58; + static constexpr uint8_t AXP2101_ADDR = 0x34; + + static constexpr i2c_port_t CORES3_INTERNAL_I2C_PORT = I2C_NUM_1; + static constexpr uint32_t CORES3_INTERNAL_I2C_FREQUENCY = 400000; + + static constexpr uint8_t AXP2101_REG_CHIP_ID = 0x03; + static constexpr uint8_t AXP2101_REG_PWRON_STATUS = 0x20; + static constexpr uint8_t AXP2101_REG_PWROFF_STATUS = 0x21; + + static constexpr uint8_t AXP2101_PWROFF_PWRON_PULLDOWN_MASK = 0x01; + static constexpr uint8_t AXP2101_PWROFF_SOFTWARE_MASK = 0x02; + static constexpr uint8_t AXP2101_PWROFF_PWRON_LOW_MASK = 0x04; + static constexpr uint8_t AXP2101_PWROFF_VSYS_UV_MASK = 0x08; + static constexpr uint8_t AXP2101_PWROFF_VBUS_OV_MASK = 0x10; + static constexpr uint8_t AXP2101_PWROFF_DCDC_UV_MASK = 0x20; + static constexpr uint8_t AXP2101_PWROFF_DCDC_OV_MASK = 0x40; + static constexpr uint8_t AXP2101_PWROFF_OVER_TEMP_MASK = 0x80; + + static constexpr uint8_t AXP2101_REG_IRQ_ENABLE_1 = 0x41; + static constexpr uint8_t AXP2101_REG_IRQ_STATUS_1 = 0x49; + + static constexpr uint8_t AXP2101_REG_DCDC_FORCE_PWM = 0x81; + // AXP2101 REG0x81: bit2=DCDC1 mode, bit4=DCDC3 mode. + // 0=Auto PWM/PFM, 1=Always PWM. Keep all unrelated bits untouched. + static constexpr uint8_t AXP2101_DCDC1_ALWAYS_PWM_MASK = 0x04; + static constexpr uint8_t AXP2101_DCDC3_ALWAYS_PWM_MASK = 0x10; + + static constexpr uint8_t AXP2101_PKEY_POSITIVE_MASK = 0x01; + static constexpr uint8_t AXP2101_PKEY_NEGATIVE_MASK = 0x02; + static constexpr uint8_t AXP2101_PKEY_LONG_MASK = 0x04; + static constexpr uint8_t AXP2101_PKEY_SHORT_MASK = 0x08; + static constexpr uint8_t AXP2101_PKEY_EVENT_MASK = 0x0F; + + static constexpr uint8_t AXP2101_PKEY_IRQ_ENABLE_MASK = + AXP2101_PKEY_POSITIVE_MASK | + AXP2101_PKEY_NEGATIVE_MASK | + AXP2101_PKEY_LONG_MASK; + + static constexpr unsigned long POWER_KEY_POLL_INTERVAL_MS = 20; + static constexpr unsigned long SAFE_SHUTDOWN_FALLBACK_HOLD_MS = 1500; + static constexpr unsigned long SAFE_SHUTDOWN_BLACK_REFRESH_MS = 100; + static constexpr unsigned long SAFE_SHUTDOWN_SHOW_WAIT_MS = 150; + static constexpr unsigned long RUNTIME_POWER_HEALTH_POLL_MS = 10000; + + // Runtime LED-bus shrink protection timing. + static constexpr unsigned long LED_REINIT_OFF_CONFIRM_TIMEOUT_MS = 3000; + static constexpr unsigned long LED_REINIT_OFF_SETTLE_MS = 500; + static constexpr unsigned long LED_REINIT_OUTPUT_WAIT_MS = 500; + + // Require multiple actual overlay/show frames that explicitly overwrite + // the complete OLD logical range with BLACK before allowing finalizeInit(). + static constexpr uint8_t LED_REINIT_REQUIRED_BLACK_FRAMES = 3; + static constexpr unsigned long LED_REINIT_BLACK_FRAME_TRIGGER_MS = 50; + static constexpr unsigned long LED_REINIT_POST_BLACK_GUARD_MS = 20; + + static constexpr uint8_t REG_OUTPUT_P0 = 0x02; + static constexpr uint8_t REG_OUTPUT_P1 = 0x03; + static constexpr uint8_t REG_CONFIG_P0 = 0x04; + static constexpr uint8_t REG_CONFIG_P1 = 0x05; + static constexpr uint8_t REG_GCR = 0x11; + static constexpr uint8_t REG_LEDMODE_P0 = 0x12; + static constexpr uint8_t REG_LEDMODE_P1 = 0x13; + + static constexpr uint8_t BUS_EN_MASK = 0x02; + static constexpr uint8_t BOOST_EN_MASK = 0x80; + + static constexpr uint8_t CORE_S3_CONFIG_P0 = 0x18; + static constexpr uint8_t CORE_S3_CONFIG_P1 = 0x0C; + static constexpr uint8_t CORE_S3_GCR = 0x10; + static constexpr uint8_t CORE_S3_LEDMODE_P0 = 0xFF; + static constexpr uint8_t CORE_S3_LEDMODE_P1 = 0xFF; + + bool aw9523Found = false; + bool axp2101Found = false; + // CoreS3 external 5V state. + bool external5VEnableAttempted = false; + bool external5VEnableSuccess = false; + bool runtimeExternal5VEnableAttempted = false; + bool runtimeExternal5VEnableSuccess = false; + bool runtimeExternal5VViolationLogged = false; + unsigned long lastRuntimePowerHealthPoll = 0; + + bool busEnabled = false; + bool boostEnabled = false; + + uint8_t p0Before = 0; + uint8_t p1Before = 0; + uint8_t p0After = 0; + uint8_t p1After = 0; + + bool powerKeyMonitorReady = false; + bool runtimePowerKeyMonitorAttempted = false; + bool runtimePowerKeyBusReadyLogged = false; + bool powerKeyPressed = false; + bool safeShutdownBlankActive = false; + bool safeShutdownEverTriggered = false; + bool safeShutdownLastCanceled = false; + + uint8_t axpIrqEnableBefore = 0; + uint8_t axpIrqEnableAfter = 0; + uint8_t lastPowerKeyStatus = 0; + uint8_t lastSafeShutdownTriggerStatus = 0; + uint8_t savedLogicalBrightness = 0; + + unsigned long powerKeyPressedAt = 0; + unsigned long lastPowerKeyPoll = 0; + unsigned long lastShutdownBlackRefresh = 0; + unsigned long lastRuntimeI2CFailureLog = 0; + + esp_reset_reason_t bootResetReason = ESP_RST_UNKNOWN; + bool bootPowerOnStatusValid = false; + bool bootPowerOffStatusValid = false; + uint8_t bootPowerOnStatus = 0; + uint8_t bootPowerOffStatus = 0; + + // DCDC3 Always-PWM stability state. + bool dcdc3StabilityAttempted = false; + bool dcdc3StabilityApplied = false; + uint8_t dcdcModeBefore = 0; + uint8_t dcdcModeAfter = 0; + + + // Runtime LED shrink state machine. + // + // The WLED core gate keeps doInitBusses pending until either loop() or + // handleOverlayDraw() classifies the new bus configuration. A real shrink + // then runs OFF -> confirmed BLACK -> rebuild -> state restore. + enum class LedShrinkSaveState : uint8_t { + IDLE = 0, + OFF_REQUEST_PENDING, + WAIT_OFF, + WAIT_REINIT_COMPLETE + }; + + LedShrinkSaveState ledShrinkSaveState = LedShrinkSaveState::IDLE; + + bool ledShrinkWasOn = false; + uint8_t ledShrinkOriginalBrightness = 0; + uint8_t ledShrinkOriginalBriLast = 0; + + uint16_t ledShrinkOldPhysical = 0; + uint16_t ledShrinkTargetPhysical = 0; + + unsigned long ledShrinkOffRequestedAt = 0; + unsigned long ledShrinkOffConfirmedAt = 0; + + // Actual BLACK frame confirmation. + uint8_t ledShrinkBlackOverlayFrames = 0; + unsigned long ledShrinkLastBlackTriggerAt = 0; + + const char* resetReasonText(esp_reset_reason_t reason) + { + switch (reason) { + case ESP_RST_UNKNOWN: return "UNKNOWN"; + case ESP_RST_POWERON: return "POWERON"; + case ESP_RST_EXT: return "EXT"; + case ESP_RST_SW: return "SOFTWARE"; + case ESP_RST_PANIC: return "PANIC"; + case ESP_RST_INT_WDT: return "INT_WDT"; + case ESP_RST_TASK_WDT: return "TASK_WDT"; + case ESP_RST_WDT: return "WDT"; + case ESP_RST_DEEPSLEEP: return "DEEPSLEEP"; + case ESP_RST_BROWNOUT: return "BROWNOUT"; + case ESP_RST_SDIO: return "SDIO"; + default: return "OTHER"; + } + } + + void printPowerOffSource(uint8_t status) + { + Serial.printf("[CoreS3_Power][BOOT] AXP2101 PWROFF_STATUS=0x%02X\n", status); + + if (status == 0) { + Serial.println(F("[CoreS3_Power][BOOT] Power-off cause: NONE LATCHED / UNKNOWN")); + return; + } + + if (status & AXP2101_PWROFF_OVER_TEMP_MASK) + Serial.println(F("[CoreS3_Power][BOOT] Power-off cause: PMIC DIE OVER TEMPERATURE")); + if (status & AXP2101_PWROFF_DCDC_OV_MASK) + Serial.println(F("[CoreS3_Power][BOOT] Power-off cause: DCDC OVER VOLTAGE")); + if (status & AXP2101_PWROFF_DCDC_UV_MASK) + Serial.println(F("[CoreS3_Power][BOOT] Power-off cause: DCDC UNDER VOLTAGE")); + if (status & AXP2101_PWROFF_VBUS_OV_MASK) + Serial.println(F("[CoreS3_Power][BOOT] Power-off cause: VBUS OVER VOLTAGE")); + if (status & AXP2101_PWROFF_VSYS_UV_MASK) + Serial.println(F("[CoreS3_Power][BOOT] Power-off cause: VSYS UNDER VOLTAGE")); + if (status & AXP2101_PWROFF_PWRON_LOW_MASK) + Serial.println(F("[CoreS3_Power][BOOT] Power-off cause: PWRON HELD LOW / EN MODE")); + if (status & AXP2101_PWROFF_SOFTWARE_MASK) + Serial.println(F("[CoreS3_Power][BOOT] Power-off cause: SOFTWARE POWER OFF")); + if (status & AXP2101_PWROFF_PWRON_PULLDOWN_MASK) + Serial.println(F("[CoreS3_Power][BOOT] Power-off cause: PWRON / POWER KEY PULL-DOWN")); + } + + bool probeI2C(uint8_t address) + { + Wire.beginTransmission(address); + return (Wire.endTransmission() == 0); + } + + bool readRegister(uint8_t address, uint8_t reg, uint8_t &value) + { + Wire.beginTransmission(address); + Wire.write(reg); + if (Wire.endTransmission(false) != 0) return false; + + uint8_t count = Wire.requestFrom(address, (uint8_t)1); + if (count != 1 || !Wire.available()) return false; + + value = Wire.read(); + return true; + } + + bool writeRegister(uint8_t address, uint8_t reg, uint8_t value) + { + Wire.beginTransmission(address); + Wire.write(reg); + Wire.write(value); + return (Wire.endTransmission() == 0); + } + + bool readRuntimeRegister(uint8_t address, uint8_t reg, uint8_t& value) + { + auto result = lgfx::i2c::transactionWriteRead( + CORES3_INTERNAL_I2C_PORT, + address, + ®, + 1, + &value, + 1, + CORES3_INTERNAL_I2C_FREQUENCY + ); + return result.has_value(); + } + + bool writeRuntimeRegister(uint8_t address, uint8_t reg, uint8_t value) + { + const uint8_t data[2] = { reg, value }; + auto result = lgfx::i2c::transactionWrite( + CORES3_INTERNAL_I2C_PORT, + address, + data, + sizeof(data), + CORES3_INTERNAL_I2C_FREQUENCY + ); + return result.has_value(); + } + + void captureBootAxpDiagnostics() + { + bootPowerOnStatusValid = + readRegister(AXP2101_ADDR, AXP2101_REG_PWRON_STATUS, bootPowerOnStatus); + bootPowerOffStatusValid = + readRegister(AXP2101_ADDR, AXP2101_REG_PWROFF_STATUS, bootPowerOffStatus); + + if (bootPowerOnStatusValid) { + Serial.printf("[CoreS3_Power][BOOT] AXP2101 PWRON_STATUS=0x%02X\n", bootPowerOnStatus); + } + else { + Serial.println(F("[CoreS3_Power][BOOT] AXP2101 PWRON_STATUS read FAILED")); + } + + if (bootPowerOffStatusValid) { + printPowerOffSource(bootPowerOffStatus); + } + else { + Serial.println(F("[CoreS3_Power][BOOT] AXP2101 PWROFF_STATUS read FAILED")); + } + } + + bool configureAW9523() + { + bool ok = true; + ok &= writeRegister(AW9523B_ADDR, REG_CONFIG_P0, CORE_S3_CONFIG_P0); + ok &= writeRegister(AW9523B_ADDR, REG_CONFIG_P1, CORE_S3_CONFIG_P1); + ok &= writeRegister(AW9523B_ADDR, REG_GCR, CORE_S3_GCR); + ok &= writeRegister(AW9523B_ADDR, REG_LEDMODE_P0, CORE_S3_LEDMODE_P0); + ok &= writeRegister(AW9523B_ADDR, REG_LEDMODE_P1, CORE_S3_LEDMODE_P1); + return ok; + } + + // ------------------------------------------------------------ + // CoreS3 External 5V startup enable + // + // CoreS3 external 5V: + // AW9523B P0 bit1 = BUS_EN + // AW9523B P1 bit7 = BOOST_EN + // + // Both outputs are enabled for normal CoreS3 external 5V operation. + // Enable BOOST first, then BUS, matching the previously validated CoreS3 + // external-5V startup ordering. + // + // ------------------------------------------------------------ + bool enableExternal5VAtStartup() + { + external5VEnableAttempted = true; + + if (!readRegister(AW9523B_ADDR, REG_OUTPUT_P0, p0Before)) return false; + if (!readRegister(AW9523B_ADDR, REG_OUTPUT_P1, p1Before)) return false; + if (!configureAW9523()) return false; + + // Bring up the boost source first. + uint8_t newP1 = p1Before | BOOST_EN_MASK; + if (!writeRegister(AW9523B_ADDR, REG_OUTPUT_P1, newP1)) return false; + delay(10); + + // Then connect the external BUS. + uint8_t newP0 = p0Before | BUS_EN_MASK; + if (!writeRegister(AW9523B_ADDR, REG_OUTPUT_P0, newP0)) return false; + delay(10); + + if (!readRegister(AW9523B_ADDR, REG_OUTPUT_P0, p0After)) return false; + if (!readRegister(AW9523B_ADDR, REG_OUTPUT_P1, p1After)) return false; + + busEnabled = (p0After & BUS_EN_MASK) != 0; + boostEnabled = (p1After & BOOST_EN_MASK) != 0; + + return busEnabled && boostEnabled; + } + + // Re-assert the intended ON state after CoreS3_Display/M5GFX has taken + // ownership of the internal I2C bus. + bool applyRuntimeExternal5VEnable() + { + uint8_t p0 = 0; + uint8_t p1 = 0; + + if (!readRuntimeRegister(AW9523B_ADDR, REG_OUTPUT_P0, p0)) return false; + if (!readRuntimeRegister(AW9523B_ADDR, REG_OUTPUT_P1, p1)) return false; + + uint8_t newP1 = p1 | BOOST_EN_MASK; + if (!writeRuntimeRegister(AW9523B_ADDR, REG_OUTPUT_P1, newP1)) return false; + delay(2); + + uint8_t newP0 = p0 | BUS_EN_MASK; + if (!writeRuntimeRegister(AW9523B_ADDR, REG_OUTPUT_P0, newP0)) return false; + delay(2); + + uint8_t verifyP0 = 0; + uint8_t verifyP1 = 0; + + if (!readRuntimeRegister(AW9523B_ADDR, REG_OUTPUT_P0, verifyP0)) return false; + if (!readRuntimeRegister(AW9523B_ADDR, REG_OUTPUT_P1, verifyP1)) return false; + + p0After = verifyP0; + p1After = verifyP1; + + busEnabled = (verifyP0 & BUS_EN_MASK) != 0; + boostEnabled = (verifyP1 & BOOST_EN_MASK) != 0; + + return busEnabled && boostEnabled; + } + + void serviceRuntimeExternal5VEnable() + { + if (!powerKeyMonitorReady || runtimeExternal5VEnableAttempted) return; + + runtimeExternal5VEnableAttempted = true; + runtimeExternal5VEnableSuccess = applyRuntimeExternal5VEnable(); + coreS3PowerExternal5VReadyState = runtimeExternal5VEnableSuccess; + + Serial.printf( + "[CoreS3_Power][EXT5V] Runtime External 5V enable: %s BUS_EN=%s BOOST_EN=%s P0=0x%02X P1=0x%02X\n", + runtimeExternal5VEnableSuccess ? "APPLIED" : "FAILED", + busEnabled ? "ON" : "OFF", + boostEnabled ? "ON" : "OFF", + p0After, + p1After + ); + } + + bool configureRuntimePowerKeyMonitor() + { + runtimePowerKeyMonitorAttempted = true; + uint8_t chipId = 0; + + if (!readRuntimeRegister(AXP2101_ADDR, AXP2101_REG_CHIP_ID, chipId)) { + Serial.println(F("[CoreS3_Power] Runtime power key: M5GFX I2C1 AXP2101 read FAILED")); + return false; + } + + if (chipId != 0x4A) { + Serial.printf("[CoreS3_Power] Runtime power key: unexpected AXP2101 chip ID 0x%02X\n", chipId); + return false; + } + + if (!runtimePowerKeyBusReadyLogged) { + runtimePowerKeyBusReadyLogged = true; + Serial.printf("[CoreS3_Power] Runtime I2C: M5GFX I2C_NUM_1 AXP2101 READY (ID=0x%02X)\n", chipId); + } + + if (!readRuntimeRegister(AXP2101_ADDR, AXP2101_REG_IRQ_ENABLE_1, axpIrqEnableBefore)) return false; + + uint8_t newIrqEnable = axpIrqEnableBefore | AXP2101_PKEY_IRQ_ENABLE_MASK; + if (!writeRuntimeRegister(AXP2101_ADDR, AXP2101_REG_IRQ_ENABLE_1, newIrqEnable)) return false; + if (!readRuntimeRegister(AXP2101_ADDR, AXP2101_REG_IRQ_ENABLE_1, axpIrqEnableAfter)) return false; + + if ((axpIrqEnableAfter & AXP2101_PKEY_IRQ_ENABLE_MASK) != AXP2101_PKEY_IRQ_ENABLE_MASK) return false; + + uint8_t staleStatus = 0; + if (readRuntimeRegister(AXP2101_ADDR, AXP2101_REG_IRQ_STATUS_1, staleStatus)) { + uint8_t stalePowerKeyFlags = staleStatus & AXP2101_PKEY_EVENT_MASK; + + if (stalePowerKeyFlags != 0) { + Serial.printf( + "[CoreS3_Power][BOOT] Stale PKEY IRQ before clear: 0x%02X%s%s%s%s\n", + stalePowerKeyFlags, + (stalePowerKeyFlags & AXP2101_PKEY_POSITIVE_MASK) ? " RELEASE" : "", + (stalePowerKeyFlags & AXP2101_PKEY_NEGATIVE_MASK) ? " PRESS" : "", + (stalePowerKeyFlags & AXP2101_PKEY_LONG_MASK) ? " LONG" : "", + (stalePowerKeyFlags & AXP2101_PKEY_SHORT_MASK) ? " SHORT" : "" + ); + + writeRuntimeRegister(AXP2101_ADDR, AXP2101_REG_IRQ_STATUS_1, stalePowerKeyFlags); + } + } + + powerKeyPressed = false; + powerKeyPressedAt = 0; + lastPowerKeyPoll = millis(); + + Serial.printf("[CoreS3_Power] Runtime PKEY IRQEN1: 0x%02X -> 0x%02X\n", axpIrqEnableBefore, axpIrqEnableAfter); + Serial.println(F("[CoreS3_Power] Runtime power key monitor: ARMED on M5GFX I2C1")); + return true; + } + + // ------------------------------------------------------------ + // CoreS3 DCDC3 Always-PWM stability measure + // + // Keep DCDC1 in its existing mode and force DCDC3 to Always-PWM. + // REG0x81 is updated read-modify-write so unrelated bits are preserved. + // DCDC voltages/enables, charger/input/ADC settings, and REG0x23 + // DCDC OVP/UVP protection are not changed. + // ------------------------------------------------------------ + void serviceDcdc3StabilityMode() + { + if (dcdc3StabilityAttempted || !powerKeyMonitorReady) return; + + dcdc3StabilityAttempted = true; + + if (!readRuntimeRegister(AXP2101_ADDR, AXP2101_REG_DCDC_FORCE_PWM, dcdcModeBefore)) { + Serial.println(F("[CoreS3_Power][DCDC3] REG81 read FAILED - mode unchanged")); + return; + } + + // R5A minimal stability change: + // set DCDC3 Always-PWM only; preserve DCDC1 and every unrelated REG81 bit. + const uint8_t target = dcdcModeBefore | AXP2101_DCDC3_ALWAYS_PWM_MASK; + + if (!writeRuntimeRegister(AXP2101_ADDR, AXP2101_REG_DCDC_FORCE_PWM, target)) { + Serial.println(F("[CoreS3_Power][DCDC3] REG81 write FAILED")); + return; + } + + if (!readRuntimeRegister(AXP2101_ADDR, AXP2101_REG_DCDC_FORCE_PWM, dcdcModeAfter)) { + Serial.println(F("[CoreS3_Power][DCDC3] REG81 verify read FAILED")); + return; + } + + dcdc3StabilityApplied = + (dcdcModeAfter & AXP2101_DCDC3_ALWAYS_PWM_MASK) != 0; + + Serial.printf( + "[CoreS3_Power][DCDC3] REG81 0x%02X -> 0x%02X result=%s DCDC1=%s DCDC3=%s OVP_PROTECTION=UNCHANGED\n", + dcdcModeBefore, + dcdcModeAfter, + dcdc3StabilityApplied ? "APPLIED" : "VERIFY_FAILED", + (dcdcModeAfter & AXP2101_DCDC1_ALWAYS_PWM_MASK) ? "ALWAYS_PWM" : "AUTO", + (dcdcModeAfter & AXP2101_DCDC3_ALWAYS_PWM_MASK) ? "ALWAYS_PWM" : "AUTO" + ); + } + + void serviceRuntimePowerHealth() + { + if (!powerKeyMonitorReady || safeShutdownBlankActive) return; + + const unsigned long now = millis(); + if ( + lastRuntimePowerHealthPoll != 0 && + now - lastRuntimePowerHealthPoll < RUNTIME_POWER_HEALTH_POLL_MS + ) return; + + lastRuntimePowerHealthPoll = now; + + uint8_t p0 = 0; + uint8_t p1 = 0; + const bool p0Ok = readRuntimeRegister(AW9523B_ADDR, REG_OUTPUT_P0, p0); + const bool p1Ok = readRuntimeRegister(AW9523B_ADDR, REG_OUTPUT_P1, p1); + + if (!p0Ok || !p1Ok) { + coreS3PowerExternal5VReadyState = false; + if (!runtimeExternal5VViolationLogged) { + runtimeExternal5VViolationLogged = true; + Serial.println(F("[CoreS3_Power][EXT5V] WARNING: runtime state read failed")); + } + return; + } + + busEnabled = (p0 & BUS_EN_MASK) != 0; + boostEnabled = (p1 & BOOST_EN_MASK) != 0; + const bool healthy = busEnabled && boostEnabled; + coreS3PowerExternal5VReadyState = healthy; + + if (!healthy && !runtimeExternal5VViolationLogged) { + runtimeExternal5VViolationLogged = true; + Serial.printf( + "[CoreS3_Power][EXT5V] WARNING: runtime path changed BUS_EN=%s BOOST_EN=%s P0=0x%02X P1=0x%02X\n", + busEnabled ? "ON" : "OFF", + boostEnabled ? "ON" : "OFF", + p0, + p1 + ); + } + else if (healthy) { + runtimeExternal5VViolationLogged = false; + } + } + + void waitForLedOutputComplete() + { + unsigned long waitStart = millis(); + while (strip.isUpdating() && millis() - waitStart < SAFE_SHUTDOWN_SHOW_WAIT_MS) { + delay(1); + } + } + + void beginSafeShutdownBlank(uint8_t triggerStatus) + { + if (safeShutdownBlankActive) return; + + safeShutdownLastCanceled = false; + safeShutdownEverTriggered = true; + lastSafeShutdownTriggerStatus = triggerStatus; + savedLogicalBrightness = bri; + + Serial.printf( + "[CoreS3_Power] Safe shutdown trigger: IRQ=0x%02X%s%s\n", + triggerStatus, + (triggerStatus & AXP2101_PKEY_LONG_MASK) ? " LONG" : "", + (triggerStatus & AXP2101_PKEY_NEGATIVE_MASK) ? " PRESS" : "" + ); + + Serial.printf( + "[CoreS3_Power] Safe shutdown: WLED bri=%u, strip=%u -> physical 0\n", + savedLogicalBrightness, + strip.getBrightness() + ); + + strip.waitForIt(); + strip.setBrightness(0, true); + strip.show(); + waitForLedOutputComplete(); + strip.suspend(); + strip.waitForIt(); + + safeShutdownBlankActive = true; + lastShutdownBlackRefresh = millis(); + + Serial.println(F("[CoreS3_Power] Safe shutdown: BLACK frame sent and strip suspended")); + } + + void maintainSafeShutdownBlank(unsigned long now) + { + if (!safeShutdownBlankActive || !powerKeyPressed) return; + if (now - lastShutdownBlackRefresh < SAFE_SHUTDOWN_BLACK_REFRESH_MS) return; + + lastShutdownBlackRefresh = now; + strip.setBrightness(0, true); + strip.show(); + waitForLedOutputComplete(); + } + + void cancelSafeShutdownBlank() + { + if (!safeShutdownBlankActive) return; + + Serial.println(F("[CoreS3_Power] Safe shutdown canceled: restoring LED output")); + strip.resume(); + + uint8_t restoreBrightness = bri; + if (restoreBrightness == 0 && savedLogicalBrightness > 0) { + restoreBrightness = savedLogicalBrightness; + } + + strip.setBrightness(restoreBrightness, true); + strip.show(); + waitForLedOutputComplete(); + + safeShutdownBlankActive = false; + safeShutdownLastCanceled = true; + strip.trigger(); + + Serial.printf("[CoreS3_Power] Safe shutdown canceled: restored bri=%u\n", restoreBrightness); + } + + uint16_t getPendingPhysicalLedCount() const + { + uint32_t total = 0; + + for (const auto &bc : busConfigs) { + if (Bus::isVirtual(bc.type)) continue; + total += bc.count; + } + + if (total > 65535UL) total = 65535UL; + return (uint16_t)total; + } + + // ------------------------------------------------------------ + // Per-physical-bus shrink detection. + // + // Match each existing physical digital Bus to the pending BusConfig by + // primary data pin. Any output that becomes shorter or disappears requires + // old-range BLACK preparation before finalizeInit(). + // ------------------------------------------------------------ + bool pendingConfigShrinksAnyPhysicalBus() + { + const size_t oldBusCount = BusManager::getNumBusses(); + + for (size_t oldIndex = 0; oldIndex < oldBusCount; ++oldIndex) { + Bus* oldBus = BusManager::getBus(oldIndex); + if (oldBus == nullptr) continue; + if (!oldBus->isDigital() || oldBus->isVirtual() || oldBus->isPlaceholder()) continue; + + uint8_t oldPins[OUTPUT_MAX_PINS] = {255, 255, 255, 255, 255}; + const size_t oldPinCount = oldBus->getPins(oldPins); + if (oldPinCount == 0 || oldPins[0] == 255) continue; + + const uint8_t oldPrimaryPin = oldPins[0]; + const uint16_t oldLength = oldBus->getLength(); + + bool matchingPendingBusFound = false; + uint16_t pendingLength = 0; + + for (const auto &bc : busConfigs) { + if (Bus::isVirtual(bc.type)) continue; + if (!Bus::isDigital(bc.type)) continue; + if (bc.pins[0] != oldPrimaryPin) continue; + + matchingPendingBusFound = true; + pendingLength = bc.count; + break; + } + + if (!matchingPendingBusFound) { + Serial.printf( + "[CoreS3_Power][LED] Bus shrink detected: pin=%u %u->removed\n", + oldPrimaryPin, + oldLength + ); + return true; + } + + if (pendingLength < oldLength) { + Serial.printf( + "[CoreS3_Power][LED] Bus shrink detected: pin=%u %u->%u\n", + oldPrimaryPin, + oldLength, + pendingLength + ); + return true; + } + } + + return false; + } + + void captureLedShrinkReinitRequest() + { + if (ledShrinkSaveState != LedShrinkSaveState::IDLE) return; + if (!doInitBusses) return; + + // A previous hook in this same main-loop may already have approved this + // exact re-init request. Avoid duplicate pass-through logging/preparation. + if (coreS3PowerBusReinitPreparedState) return; + + // Safe-shutdown owns LED output. Do not start the shrink OFF/ON sequence; + // simply let normal WLED re-init proceed if a settings request arrives. + if (safeShutdownBlankActive) { + coreS3PowerMarkBusReinitPrepared(); + return; + } + + const uint16_t oldPhysical = strip.getLengthPhysical(); + const uint16_t targetPhysical = getPendingPhysicalLedCount(); + + // Keep an explicit safe pass-through for initialization / an empty pending + // configuration. The normal LED Hardware UI used in this CoreS3 build has + // real physical buses and non-zero counts, so this is only a guardrail. + if ( + BusManager::getNumBusses() == 0 || + oldPhysical == 0 || + targetPhysical == 0 + ) { + coreS3PowerMarkBusReinitPrepared(); + return; + } + + // The decision is now PER OLD PHYSICAL BUS, not aggregate total length. + // This correctly catches e.g. 5/10/15 -> 15/10/5 (total 30 -> 30). + const bool anyPhysicalBusShrinks = pendingConfigShrinksAnyPhysicalBus(); + + if (!anyPhysicalBusShrinks) { + coreS3PowerMarkBusReinitPrepared(); + return; + } + + ledShrinkOldPhysical = oldPhysical; + ledShrinkTargetPhysical = targetPhysical; + ledShrinkWasOn = (bri > 0); + ledShrinkOriginalBrightness = bri; + ledShrinkOriginalBriLast = briLast; + ledShrinkBlackOverlayFrames = 0; + ledShrinkLastBlackTriggerAt = 0; + + // Critical: prevent WLED's doInitBusses block later in this SAME main + // loop. The pending busConfigs vector remains intact for the delayed + // finalizeInit() call. + doInitBusses = false; + ledShrinkSaveState = LedShrinkSaveState::OFF_REQUEST_PENDING; + + Serial.printf( + "[CoreS3_Power][LED] Safe bus re-init start: %u->%u state=%s\n", + ledShrinkOldPhysical, + ledShrinkTargetPhysical, + ledShrinkWasOn ? "ON" : "OFF" + ); + } + + bool waitForLedOutputIdle(unsigned long timeoutMs) + { + const unsigned long startedAt = millis(); + + while (!BusManager::canAllShow()) { + if (millis() - startedAt >= timeoutMs) return false; + delay(1); + } + + return true; + } + + void serviceLedShrinkSaveOffCycle() + { + const unsigned long now = millis(); + + switch (ledShrinkSaveState) { + case LedShrinkSaveState::IDLE: + return; + + case LedShrinkSaveState::OFF_REQUEST_PENDING: + { + // Keep the pending bus rebuild blocked while the OLD physical count + // is still alive. + doInitBusses = false; + + ledShrinkOffRequestedAt = now; + ledShrinkOffConfirmedAt = 0; + + if (ledShrinkWasOn) { + // Use the exact WLED power path proven manually 5/5. + toggleOnOff(); + stateUpdated(CALL_MODE_BUTTON); + strip.trigger(); + } else { + ledShrinkOffConfirmedAt = now; + } + + // Do NOT suspend yet. Normal strip.service() must render the OFF state + // across the complete OLD LED range. + ledShrinkSaveState = LedShrinkSaveState::WAIT_OFF; + return; + } + + case LedShrinkSaveState::WAIT_OFF: + { + doInitBusses = false; + + if (ledShrinkOffConfirmedAt == 0) { + if (bri == 0 && strip.getBrightness() == 0) { + ledShrinkOffConfirmedAt = now; + + } else if (now - ledShrinkOffRequestedAt >= LED_REINIT_OFF_CONFIRM_TIMEOUT_MS) { + // Do not deadlock configuration forever. Keep rendering OFF and + // start the conservative settle window even if the runtime + // brightness report did not reach zero as expected. + ledShrinkOffConfirmedAt = now; + strip.trigger(); + + Serial.printf( + "[CoreS3_Power][LED] WARNING: bus re-init OFF timeout bri=%u strip=%u\n", + bri, + strip.getBrightness() + ); + } + + return; + } + + // Time alone is not enough. Earlier testing showed internal + // brightness=0 did not prove that a complete OLD-range BLACK frame + // had actually passed through WLED's final show path. + // + // handleOverlayDraw() explicitly replaces the full OLD logical + // range with BLACK immediately before WLED paints the frame to + // BusManager and calls show(). Require several such frames. + if (ledShrinkBlackOverlayFrames < LED_REINIT_REQUIRED_BLACK_FRAMES) { + if (now - ledShrinkLastBlackTriggerAt >= LED_REINIT_BLACK_FRAME_TRIGGER_MS) { + ledShrinkLastBlackTriggerAt = now; + strip.trigger(); + } + return; + } + + // Keep a conservative OFF settle window in addition to actual + // overlay/show-frame evidence. + if (now - ledShrinkOffConfirmedAt < LED_REINIT_OFF_SETTLE_MS) return; + + strip.waitForIt(); + + if (!waitForLedOutputIdle(LED_REINIT_OUTPUT_WAIT_MS)) { + Serial.println(F("[CoreS3_Power][LED] WARNING: old LED output busy; bus re-init deferred")); + return; + } + + // NeoPixelBus/LCD output is asynchronous; preserve old buses briefly + // after the final confirmed BLACK show before destroying them. + delay(LED_REINIT_POST_BLACK_GUARD_MS); + + // The complete OLD range has now been explicitly BLACK in multiple + // real WLED show frames. Freeze drawing, then release normal rebuild. + strip.suspend(); + strip.waitForIt(); + + // The old physical range is now safely BLACK. + // Authorize wled.cpp to consume doInitBusses and call finalizeInit(). + coreS3PowerMarkBusReinitPrepared(); + doInitBusses = true; + ledShrinkSaveState = LedShrinkSaveState::WAIT_REINIT_COMPLETE; + + return; + } + + case LedShrinkSaveState::WAIT_REINIT_COMPLETE: + { + // WLED clears doInitBusses immediately before strip.finalizeInit(). + // false here means the previous main-loop iteration completed rebuild. + if (doInitBusses) return; + + strip.resume(); + + if (ledShrinkWasOn) { + if (bri == 0) { + toggleOnOff(); + stateUpdated(CALL_MODE_BUTTON); + } else { + // Defensive fallback if another component changed WLED state. + bri = ledShrinkOriginalBrightness; + briLast = ledShrinkOriginalBriLast; + stateUpdated(CALL_MODE_BUTTON); + } + } else { + // Preserve original OFF state. + bri = 0; + briLast = ledShrinkOriginalBriLast; + stateUpdated(CALL_MODE_BUTTON); + } + + strip.trigger(); + + Serial.printf( + "[CoreS3_Power][LED] Safe bus re-init complete: %u->%u state=%s\n", + ledShrinkOldPhysical, + strip.getLengthPhysical(), + bri > 0 ? "ON" : "OFF" + ); + + ledShrinkSaveState = LedShrinkSaveState::IDLE; + ledShrinkWasOn = false; + ledShrinkOriginalBrightness = 0; + ledShrinkOriginalBriLast = 0; + ledShrinkOldPhysical = 0; + ledShrinkTargetPhysical = 0; + ledShrinkOffRequestedAt = 0; + ledShrinkOffConfirmedAt = 0; + ledShrinkBlackOverlayFrames = 0; + ledShrinkLastBlackTriggerAt = 0; + return; + } + } + } + + void servicePhysicalPowerKey() + { + unsigned long now = millis(); + + if (!powerKeyMonitorReady) { + if (!runtimePowerKeyMonitorAttempted || now - lastRuntimeI2CFailureLog >= 1000) { + if (configureRuntimePowerKeyMonitor()) { + powerKeyMonitorReady = true; + coreS3PowerSafeShutdownMonitorReadyState = true; + } + else { + lastRuntimeI2CFailureLog = now; + } + } + return; + } + + if (now - lastPowerKeyPoll < POWER_KEY_POLL_INTERVAL_MS) { + maintainSafeShutdownBlank(now); + return; + } + + lastPowerKeyPoll = now; + uint8_t status = 0; + + if (!readRuntimeRegister(AXP2101_ADDR, AXP2101_REG_IRQ_STATUS_1, status)) { + if (now - lastRuntimeI2CFailureLog >= 1000) { + lastRuntimeI2CFailureLog = now; + Serial.println(F("[CoreS3_Power] Runtime PKEY status read FAILED on M5GFX I2C1")); + } + maintainSafeShutdownBlank(now); + return; + } + + uint8_t powerKeyStatus = status & AXP2101_PKEY_EVENT_MASK; + + if (powerKeyStatus != 0) { + lastPowerKeyStatus = powerKeyStatus; + + Serial.printf( + "[CoreS3_Power] PKEY IRQ: 0x%02X%s%s%s%s\n", + powerKeyStatus, + (powerKeyStatus & AXP2101_PKEY_POSITIVE_MASK) ? " RELEASE" : "", + (powerKeyStatus & AXP2101_PKEY_NEGATIVE_MASK) ? " PRESS" : "", + (powerKeyStatus & AXP2101_PKEY_LONG_MASK) ? " LONG" : "", + (powerKeyStatus & AXP2101_PKEY_SHORT_MASK) ? " SHORT" : "" + ); + + writeRuntimeRegister(AXP2101_ADDR, AXP2101_REG_IRQ_STATUS_1, powerKeyStatus); + } + + if (powerKeyStatus & AXP2101_PKEY_NEGATIVE_MASK) { + powerKeyPressed = true; + powerKeyPressedAt = now; + } + + if ((powerKeyStatus & AXP2101_PKEY_LONG_MASK) && !safeShutdownBlankActive) { + powerKeyPressed = true; + if (powerKeyPressedAt == 0) powerKeyPressedAt = now; + beginSafeShutdownBlank(powerKeyStatus); + } + + if ( + powerKeyPressed && + !safeShutdownBlankActive && + powerKeyPressedAt > 0 && + now - powerKeyPressedAt >= SAFE_SHUTDOWN_FALLBACK_HOLD_MS + ) { + Serial.println(F("[CoreS3_Power] Safe shutdown: PRESS timer fallback")); + beginSafeShutdownBlank(AXP2101_PKEY_NEGATIVE_MASK); + } + + if (powerKeyStatus & AXP2101_PKEY_POSITIVE_MASK) { + powerKeyPressed = false; + powerKeyPressedAt = 0; + cancelSafeShutdownBlank(); + return; + } + + maintainSafeShutdownBlank(now); + } + +public: + void setup() override + { + coreS3PowerInitializationCompleteState = false; + coreS3PowerExternal5VReadyState = false; + coreS3PowerSafeShutdownMonitorReadyState = false; + coreS3PowerBusReinitPreparedState = false; + + bootResetReason = esp_reset_reason(); + + Serial.println(); + Serial.println(F("[CoreS3_Power][BUILD] CoreS3 Power v0.1.0")); + Serial.println(F("[CoreS3_Power] Initialization start")); + Serial.printf("[CoreS3_Power] I2C SDA=%d SCL=%d\n", i2c_sda, i2c_scl); + Serial.printf( + "[CoreS3_Power][BOOT] ESP reset reason: %s (%d)\n", + resetReasonText(bootResetReason), + (int)bootResetReason + ); + + if (i2c_sda != 12 || i2c_scl != 11) { + Serial.println(F("[CoreS3_Power] ERROR: Invalid CoreS3 I2C pins")); + coreS3PowerInitializationCompleteState = true; + return; + } + + aw9523Found = probeI2C(AW9523B_ADDR); + axp2101Found = probeI2C(AXP2101_ADDR); + + Serial.printf("[CoreS3_Power] AW9523B (0x58): %s\n", aw9523Found ? "FOUND" : "NOT FOUND"); + Serial.printf("[CoreS3_Power] AXP2101 (0x34): %s\n", axp2101Found ? "FOUND" : "NOT FOUND"); + + if (!aw9523Found || !axp2101Found) { + Serial.println(F("[CoreS3_Power] External 5V enable canceled")); + coreS3PowerInitializationCompleteState = true; + return; + } + + captureBootAxpDiagnostics(); + + external5VEnableSuccess = enableExternal5VAtStartup(); + coreS3PowerExternal5VReadyState = external5VEnableSuccess; + + powerKeyMonitorReady = false; + runtimePowerKeyMonitorAttempted = false; + + Serial.println(F("[CoreS3_Power] Power key monitor: DEFERRED until M5GFX I2C1 is active")); + Serial.println(F("[CoreS3_Power] Safe shutdown: AXP2101 LONG IRQ primary trigger")); + Serial.printf("[CoreS3_Power] Safe shutdown: PRESS fallback >= %lu ms\n", SAFE_SHUTDOWN_FALLBACK_HOLD_MS); + Serial.printf("[CoreS3_Power] External 5V: %s\n", external5VEnableSuccess ? "ENABLED" : "FAILED"); + + coreS3PowerInitializationCompleteState = true; + + Serial.println(F("[CoreS3_Power] LED runtime re-init: READY - per-bus shrink guard")); + Serial.println(F("[CoreS3_Power] Initialization complete")); + Serial.println(); + } + + void loop() override + { + captureLedShrinkReinitRequest(); + serviceLedShrinkSaveOffCycle(); + + servicePhysicalPowerKey(); + serviceRuntimeExternal5VEnable(); + serviceDcdc3StabilityMode(); + serviceRuntimePowerHealth(); + } + + // WLED calls this after effects render and immediately before the frame + // is painted to BusManager/show(). This is both the second re-init capture + // point and the final BLACK override for the complete old logical range. + void handleOverlayDraw() override + { + captureLedShrinkReinitRequest(); + if ( + ledShrinkSaveState == LedShrinkSaveState::WAIT_OFF && + bri == 0 && + strip.getBrightness() == 0 && + ledShrinkOldPhysical > 0 + ) { + strip.setRange(0, ledShrinkOldPhysical - 1, 0); + + if (ledShrinkBlackOverlayFrames < 255) { + ledShrinkBlackOverlayFrames++; + } + + } + } + + void addToJsonInfo(JsonObject& root) override + { + JsonObject user = root["u"]; + if (user.isNull()) user = root.createNestedObject("u"); + + JsonArray i2cInfo = user.createNestedArray("CoreS3 I2C"); + i2cInfo.add((i2c_sda == 12 && i2c_scl == 11) ? "GPIO12 / GPIO11 OK" : "I2C PIN ERROR"); + + JsonArray awInfo = user.createNestedArray("CoreS3 AW9523B"); + awInfo.add(aw9523Found ? "Found (0x58)" : "Not found"); + + JsonArray axpInfo = user.createNestedArray("CoreS3 AXP2101"); + axpInfo.add(axp2101Found ? "Found (0x34)" : "Not found"); + + JsonArray stabilityInfo = user.createNestedArray("CoreS3 Power Stability"); + stabilityInfo.add("DCDC3 Always-PWM"); + + JsonArray ledReinitInfo = user.createNestedArray("CoreS3 LED Runtime Reinit"); + switch (ledShrinkSaveState) { + case LedShrinkSaveState::IDLE: + ledReinitInfo.add("READY - per-bus shrink guard"); + break; + case LedShrinkSaveState::OFF_REQUEST_PENDING: + ledReinitInfo.add("Bus shrink detected - preparing OFF"); + break; + case LedShrinkSaveState::WAIT_OFF: + ledReinitInfo.add("Clearing old LED range"); + break; + case LedShrinkSaveState::WAIT_REINIT_COMPLETE: + ledReinitInfo.add("Rebuilding LED buses"); + break; + } + + JsonArray pwmInfo = user.createNestedArray("CoreS3 DCDC3 Mode"); + if (!dcdc3StabilityAttempted) { + pwmInfo.add("PENDING"); + } + else if (!dcdc3StabilityApplied) { + pwmInfo.add("FAILED / NOT APPLIED"); + } + else { + pwmInfo.add((dcdcModeAfter & AXP2101_DCDC1_ALWAYS_PWM_MASK) ? "DCDC1 Always PWM" : "DCDC1 Auto PWM/PFM"); + pwmInfo.add((dcdcModeAfter & AXP2101_DCDC3_ALWAYS_PWM_MASK) ? "DCDC3 Always PWM" : "DCDC3 Auto PWM/PFM"); + pwmInfo.add("OVP protection unchanged"); + } + + JsonArray shutdownInfo = user.createNestedArray("CoreS3 Safe Shutdown"); + if (!powerKeyMonitorReady) { + shutdownInfo.add(runtimePowerKeyMonitorAttempted ? "Runtime M5GFX I2C1 monitor unavailable" : "Runtime M5GFX I2C1 monitor pending"); + } + else if (safeShutdownBlankActive) { + shutdownInfo.add("BLACK output active - waiting for PMIC off"); + } + else if (safeShutdownLastCanceled) { + shutdownInfo.add("ARMED - last shutdown hold canceled"); + } + else if (safeShutdownEverTriggered) { + shutdownInfo.add("ARMED - shutdown BLACK previously triggered"); + } + else { + shutdownInfo.add("ARMED"); + } + + shutdownInfo.add("Trigger: AXP2101 Long Press IRQ"); + + char fallbackText[48]; + snprintf(fallbackText, sizeof(fallbackText), "PRESS fallback: %lu ms", SAFE_SHUTDOWN_FALLBACK_HOLD_MS); + shutdownInfo.add(fallbackText); + + char lastIrqText[40]; + snprintf(lastIrqText, sizeof(lastIrqText), "Last IRQ status: 0x%02X", lastPowerKeyStatus); + shutdownInfo.add(lastIrqText); + + char triggerIrqText[40]; + snprintf(triggerIrqText, sizeof(triggerIrqText), "Last BLACK trigger: 0x%02X", lastSafeShutdownTriggerStatus); + shutdownInfo.add(triggerIrqText); + + char irqText[48]; + snprintf(irqText, sizeof(irqText), "IRQEN1 0x%02X -> 0x%02X", axpIrqEnableBefore, axpIrqEnableAfter); + shutdownInfo.add(irqText); + + JsonArray powerInfo = user.createNestedArray("CoreS3 Ext 5V"); + if (!external5VEnableAttempted) { + powerInfo.add("Enable not attempted"); + } + else if (external5VEnableSuccess) { + powerInfo.add("ENABLED - ON"); + } + else { + powerInfo.add("Enable FAILED"); + } + + JsonArray comparisonInfo = user.createNestedArray("CoreS3 Ext 5V Runtime"); + if (!runtimeExternal5VEnableAttempted) { + comparisonInfo.add("Startup enabled; runtime confirmation pending"); + } + else if (runtimeExternal5VEnableSuccess && busEnabled && boostEnabled) { + comparisonInfo.add("ACTIVE - BUS_EN ON / BOOST_EN ON"); + } + else { + comparisonInfo.add("FAILED / external 5V path changed"); + } + + JsonArray busInfo = user.createNestedArray("CoreS3 BUS_EN"); + busInfo.add(busEnabled ? "ON" : "OFF"); + + JsonArray boostInfo = user.createNestedArray("CoreS3 BOOST_EN"); + boostInfo.add(boostEnabled ? "ON" : "OFF"); + + char p0Text[32]; + snprintf(p0Text, sizeof(p0Text), "0x%02X -> 0x%02X", p0Before, p0After); + JsonArray p0Info = user.createNestedArray("CoreS3 AW P0"); + p0Info.add(p0Text); + + char p1Text[32]; + snprintf(p1Text, sizeof(p1Text), "0x%02X -> 0x%02X", p1Before, p1After); + JsonArray p1Info = user.createNestedArray("CoreS3 AW P1"); + p1Info.add(p1Text); + + JsonArray resetInfo = user.createNestedArray("CoreS3 Last ESP Reset"); + resetInfo.add(resetReasonText(bootResetReason)); + + JsonArray offInfo = user.createNestedArray("CoreS3 Last PMIC Off"); + if (bootPowerOffStatusValid) { + char offText[24]; + snprintf(offText, sizeof(offText), "PWROFF 0x%02X", bootPowerOffStatus); + offInfo.add(offText); + } + else { + offInfo.add("Unavailable"); + } + + JsonArray onInfo = user.createNestedArray("CoreS3 PMIC Power On"); + if (bootPowerOnStatusValid) { + char onText[24]; + snprintf(onText, sizeof(onText), "PWRON 0x%02X", bootPowerOnStatus); + onInfo.add(onText); + } + else { + onInfo.add("Unavailable"); + } + } +}; + +static CoreS3PowerUsermod coreS3PowerUsermod; +REGISTER_USERMOD(coreS3PowerUsermod); diff --git a/usermods/CoreS3_Power/library.json b/usermods/CoreS3_Power/library.json new file mode 100644 index 0000000000..4d8c8c8228 --- /dev/null +++ b/usermods/CoreS3_Power/library.json @@ -0,0 +1,11 @@ +{ + "name": "CoreS3_Power", + "version": "0.1.0", + "description": "M5Stack CoreS3 power management usermod for WLED", + "build": { + "libArchive": false + }, + "dependencies": { + "M5GFX": "https://github.com/m5stack/M5GFX.git#0.2.26" + } +} diff --git a/usermods/audioreactive/audio_reactive.cpp b/usermods/audioreactive/audio_reactive.cpp index c257b4e59c..4ddb0daa6f 100644 --- a/usermods/audioreactive/audio_reactive.cpp +++ b/usermods/audioreactive/audio_reactive.cpp @@ -201,6 +201,58 @@ static FFTsampleType* windowFFT = nullptr; // use audio source class (ESP32 specific) #include "audio_source.h" + +// ----------------------------------------------------------------------------- +// M5Stack CoreS3 built-in ES7210 microphone integration +// +// Board-specific responsibilities in this file are limited to: +// - fixed internal audio pin selection +// - GPIO0 button ownership release for MCLK +// - deferred I2S source startup after codec readiness +// - CoreS3-specific sample rate / FFT mapping +// +// ES7210 codec configuration itself remains owned by CoreS3_Audio. +// ----------------------------------------------------------------------------- +#if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) +extern "C" bool coreS3AudioCodecReady(); + +static constexpr int8_t CORES3_AUDIO_SD_PIN = 14; +static constexpr int8_t CORES3_AUDIO_WS_PIN = 33; +static constexpr int8_t CORES3_AUDIO_BCLK_PIN = 34; +static constexpr int8_t CORES3_AUDIO_MCLK_PIN = 0; + +static void coreS3ApplyFixedAudioPins( + int8_t& sdPin, + int8_t& wsPin, + int8_t& bclkPin, + int8_t& mclkPin +) { + sdPin = CORES3_AUDIO_SD_PIN; + wsPin = CORES3_AUDIO_WS_PIN; + bclkPin = CORES3_AUDIO_BCLK_PIN; + mclkPin = CORES3_AUDIO_MCLK_PIN; +} + +static void coreS3ReleaseMclkButtonOwnership() +{ + // GPIO0 is the CoreS3 ES7210 MCLK. + if (PinManager::getPinOwner(CORES3_AUDIO_MCLK_PIN) == PinOwner::Button) { + PinManager::deallocatePin(CORES3_AUDIO_MCLK_PIN, PinOwner::Button); + } + + // Also neutralize any persisted/default WLED button configuration on GPIO0. + for (auto& button : buttons) { + if (button.pin == CORES3_AUDIO_MCLK_PIN) { + button.pin = -1; + button.type = BTN_TYPE_NONE; + button.pressedBefore = false; + button.longPressed = false; + button.pressedTime = 0; + button.waitTime = 0; + } + } +} +#endif constexpr i2s_port_t I2S_PORT = I2S_NUM_0; // I2S port to use (do not change !) constexpr int BLOCK_SIZE = 128; // I2S buffer size (samples) @@ -239,6 +291,12 @@ const float agcSampleSmooth[AGC_NUM_PRESETS] = { 1/12.f, 1/6.f, 1/16.f}; // // AGC presets end static AudioSource *audioSource = nullptr; +#if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) +extern "C" bool coreS3AudioReactiveSourceReady() +{ + return audioSource && audioSource->isInitialized(); +} +#endif static bool useBandPassFilter = false; // if true, enables a hard cutoff bandpass filter. Applies after FFT. static bool useMicFilter = false; // if true, enables a IIR bandpass filter 80Hz-20Khz to remove noise. Applies before FFT. //////////////////// @@ -270,11 +328,19 @@ static float fftResultMax[NUM_GEQ_CHANNELS] = {0.0f}; // A table #endif // audio source parameters and constant +#if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) +constexpr SRate_t SAMPLE_RATE = 16000; // Hardware-verified CoreS3 ES7210 sample rate +#else constexpr SRate_t SAMPLE_RATE = 22050; // Base sample rate in Hz - 22Khz is a standard rate. Physical sample time -> 23ms +#endif //constexpr SRate_t SAMPLE_RATE = 16000; // 16kHz - use if FFTtask takes more than 20ms. Physical sample time -> 32ms //constexpr SRate_t SAMPLE_RATE = 20480; // Base sample rate in Hz - 20Khz is experimental. Physical sample time -> 25ms //constexpr SRate_t SAMPLE_RATE = 10240; // Base sample rate in Hz - previous default. Physical sample time -> 50ms +#if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) +#define FFT_MIN_CYCLE 30 // CoreS3: 16kHz / 512 samples ~= 32ms physical window +#else #define FFT_MIN_CYCLE 21 // minimum time before FFT task is repeated. Use with 22Khz sampling +#endif //#define FFT_MIN_CYCLE 30 // Use with 16Khz sampling //#define FFT_MIN_CYCLE 23 // minimum time before FFT task is repeated. Use with 20Khz sampling //#define FFT_MIN_CYCLE 46 // minimum time before FFT task is repeated. Use with 10Khz sampling @@ -282,6 +348,12 @@ constexpr SRate_t SAMPLE_RATE = 22050; // Base sample rate in Hz - 22Khz // FFT Constants constexpr uint16_t samplesFFT = 512; // Samples in an FFT batch - This value MUST ALWAYS be a power of 2 constexpr uint16_t samplesFFT_2 = 256; // meaningfull part of FFT results - only the "lower half" contains useful information. + +#if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) +static_assert(SAMPLE_RATE == 16000, "CoreS3 FFT calibration requires 16 kHz sampling"); +static_assert(samplesFFT == 512, "CoreS3 FFT calibration requires 512 FFT samples"); +constexpr float CORES3_FFT_BIN_HZ = (float)SAMPLE_RATE / (float)samplesFFT; // 31.25 Hz/bin +#endif // the following are observed values, supported by a bit of "educated guessing" //#define FFT_DOWNSCALE 0.65f // 20kHz - downscaling factor for FFT results - "Flat-Top" window @20Khz, old freq channels #ifdef FFT_PREFER_EXACT_PEAKS @@ -548,6 +620,46 @@ void FFTcode(void * parameter) fftCalc[13] = fftAddAvg(111,147); // 2220 - 2960 fftCalc[14] = fftAddAvg(147,194); // 2940 - 3900 fftCalc[15] = fftAddAvg(194,250); // 3880 - 5000 // avoid the last 5 bins, which are usually inaccurate +#else +#if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + /* + * CoreS3 ES7210 mapping calibrated for 16 kHz / 512 samples. + * + * Bin width = 16000 / 512 = 31.25 Hz. + * Bands 0..14 preserve approximately the same physical frequency + * regions as WLED's standard 22.05 kHz mapping. Band 15 is + * Nyquist-limited and stops at bin 250 (7.8125 kHz), deliberately + * avoiding the final five FFT bins. + * + * This branch changes only CoreS3. All other AudioReactive sources + * continue to use the original WLED 22.05 kHz mapping below. + */ + if (useBandPassFilter) { + // preserve the standard >~100 Hz band-pass behavior + fftCalc[ 0] = 0.8f * fftAddAvg(4,6); // 125 - 188 Hz + fftCalc[ 1] = 0.9f * fftAddAvg(6,7); // 188 - 219 Hz + fftCalc[ 2] = fftAddAvg(7,8); // 219 - 250 Hz + fftCalc[ 3] = fftAddAvg(8,10); // 250 - 313 Hz + fftCalc[15] = fftAddAvg(227,250) * 0.75f; // 7094 - 7813 Hz, Nyquist-limited + } else { + fftCalc[ 0] = fftAddAvg(1,3); // 31 - 94 Hz sub-bass + fftCalc[ 1] = fftAddAvg(3,4); // 94 - 125 Hz bass + fftCalc[ 2] = fftAddAvg(4,7); // 125 - 219 Hz bass + fftCalc[ 3] = fftAddAvg(7,10); // 219 - 313 Hz bass + midrange + fftCalc[15] = fftAddAvg(227,250) * 0.70f; // 7094 - 7813 Hz, Nyquist-limited + } + + fftCalc[ 4] = fftAddAvg(10,14); // 313 - 438 Hz midrange + fftCalc[ 5] = fftAddAvg(14,18); // 438 - 563 Hz midrange + fftCalc[ 6] = fftAddAvg(18,26); // 563 - 813 Hz midrange + fftCalc[ 7] = fftAddAvg(26,36); // 813 - 1125 Hz midrange; 1 kHz centered + fftCalc[ 8] = fftAddAvg(36,45); // 1125 - 1406 Hz midrange + fftCalc[ 9] = fftAddAvg(45,61); // 1406 - 1906 Hz midrange + fftCalc[10] = fftAddAvg(61,77); // 1906 - 2406 Hz midrange + high mid + fftCalc[11] = fftAddAvg(77,96); // 2406 - 3000 Hz high mid + fftCalc[12] = fftAddAvg(96,119); // 3000 - 3719 Hz high mid + fftCalc[13] = fftAddAvg(119,143); // 3719 - 4469 Hz high mid + fftCalc[14] = fftAddAvg(143,227) * 0.88f; // 4469 - 7094 Hz high mid + high #else /* new mapping, optimized for 22050 Hz by softhack007 */ // bins frequency range @@ -578,6 +690,7 @@ void FFTcode(void * parameter) fftCalc[12] = fftAddAvg(70,86); // 16 3015 - 3704 high mid fftCalc[13] = fftAddAvg(86,104); // 18 3704 - 4479 high mid fftCalc[14] = fftAddAvg(104,165) * 0.88f; // 61 4479 - 7106 high mid + high -- with slight damping +#endif #endif } else { // noise gate closed - just decay old values for (int i=0; i < NUM_GEQ_CHANNELS; i++) { @@ -830,6 +943,12 @@ class AudioReactive : public Usermod { #else int8_t mclkPin = MCLK_PIN; #endif + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + static constexpr uint8_t CORES3_I2S_INIT_MAX_ATTEMPTS = 5; + static constexpr unsigned long CORES3_I2S_INIT_RETRY_MS = 1000; + uint8_t coreS3I2sInitAttempts = 0; + unsigned long coreS3LastI2sInitAttemptMs = 0; + #endif #endif // new "V2" audiosync struct - 44 Bytes @@ -1414,9 +1533,18 @@ class AudioReactive : public Usermod { #ifdef ARDUINO_ARCH_ESP32 - // Reset I2S peripheral for good measure - i2s_driver_uninstall(I2S_NUM_0); // E (696) I2S: i2s_driver_uninstall(2006): I2S port 0 has not installed - #if !defined(CONFIG_IDF_TARGET_ESP32C3) && (ESP_IDF_VERSION_MAJOR < 5) + // Reset the selected I2S peripheral for good measure. + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + if (dmType == 7) { + // CoreS3 I2S1 is deferred and has no driver to uninstall at boot. + // CoreS3ES7210Source becomes the sole owner after ES7210 is READY. + } else { + i2s_driver_uninstall(I2S_NUM_0); + } + #else + i2s_driver_uninstall(I2S_NUM_0); + #endif + #if !defined(WLED_M5STACK_CORES3_AUDIO) && !defined(CONFIG_IDF_TARGET_ESP32C3) && (ESP_IDF_VERSION_MAJOR < 5) delay(100); periph_module_reset(PERIPH_I2S0_MODULE); // not possible on -C3, neither on esp-idf V5 #endif @@ -1477,6 +1605,23 @@ class AudioReactive : public Usermod { delay(100); if (audioSource) audioSource->initialize(i2swsPin, i2ssdPin, i2sckPin, mclkPin); break; + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + case 7: + DEBUGSR_PRINTLN(F("AR: M5Stack CoreS3 ES7210 built-in dual microphone")); + coreS3ApplyFixedAudioPins(i2ssdPin, i2swsPin, i2sckPin, mclkPin); + coreS3ReleaseMclkButtonOwnership(); + audioSource = new CoreS3ES7210Source(SAMPLE_RATE, BLOCK_SIZE); + useMicFilter = true; + coreS3I2sInitAttempts = 0; + coreS3LastI2sInitAttemptMs = 0; + + if (audioSource && coreS3AudioCodecReady()) { + coreS3I2sInitAttempts = 1; + coreS3LastI2sInitAttemptMs = millis(); + audioSource->initialize(); + } + break; + #endif #if defined(CONFIG_IDF_TARGET_ESP32) && (ESP_IDF_VERSION_MAJOR < 5) // legacy ADC driver is not available any more in esp-idf V5.x.y // ADC over I2S is only possible on "classic" ESP32 @@ -1507,12 +1652,21 @@ class AudioReactive : public Usermod { delay(250); // give microphone enough time to initialise if (!audioSource && (dmType != SR_DMTYPE_NETWORK_ONLY)) enabled = false;// audio failed to initialise + + bool coreS3SourcePending = false; + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + coreS3SourcePending = (dmType == 7) && audioSource && !audioSource->isInitialized(); + #endif #endif - if (enabled) onUpdateBegin(false); // create FFT task, and initialize network + #ifdef ARDUINO_ARCH_ESP32 + if (enabled && !coreS3SourcePending) onUpdateBegin(false); // create FFT task when input is ready + #else + if (enabled) onUpdateBegin(false); + #endif #ifdef ARDUINO_ARCH_ESP32 - if (audioSource && FFT_Task == nullptr) enabled = false; // FFT task creation failed + if (audioSource && FFT_Task == nullptr && !coreS3SourcePending) enabled = false; // FFT task creation failed if((!audioSource) || (!audioSource->isInitialized())) { // audio source failed to initialize. Still stay "enabled", as there might be input arriving via UDP Sound Sync #ifdef WLED_DEBUG #define AR_INIT_DEBUG_PRINT DEBUG_PRINTLN @@ -1528,7 +1682,11 @@ class AudioReactive : public Usermod { disableSoundProcessing = true; } #endif + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + if (enabled && !coreS3SourcePending) disableSoundProcessing = false; + #else if (enabled) disableSoundProcessing = false; // all good - enable audio processing + #endif if (enabled) connectUDPSoundSync(); if (enabled && addPalettes) createAudioPalettes(); initDone = true; @@ -1570,6 +1728,39 @@ class AudioReactive : public Usermod { { static unsigned long lastUMRun = millis(); +#if defined(ARDUINO_ARCH_ESP32) && defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + if (enabled && dmType == 7 && audioSource && !audioSource->isInitialized() + && coreS3AudioCodecReady() + && coreS3I2sInitAttempts < CORES3_I2S_INIT_MAX_ATTEMPTS + && (coreS3I2sInitAttempts == 0 + || millis() - coreS3LastI2sInitAttemptMs >= CORES3_I2S_INIT_RETRY_MS)) { + + coreS3ApplyFixedAudioPins(i2ssdPin, i2swsPin, i2sckPin, mclkPin); + coreS3ReleaseMclkButtonOwnership(); + + coreS3I2sInitAttempts++; + coreS3LastI2sInitAttemptMs = millis(); + + DEBUGSR_PRINTF( + "AR: CoreS3 ES7210 I2S1 init attempt %u/%u.\n", + coreS3I2sInitAttempts, + CORES3_I2S_INIT_MAX_ATTEMPTS + ); + + audioSource->initialize(); + + if (audioSource->isInitialized()) { + DEBUGSR_PRINTLN(F("AR: CoreS3 ES7210 I2S1 source READY.")); + if (FFT_Task == nullptr) onUpdateBegin(false); + disableSoundProcessing = false; + lastUMRun = millis(); + } else { + DEBUGSR_PRINTLN(F("AR: CoreS3 ES7210 I2S1 source initialization FAILED.")); + disableSoundProcessing = true; + } + } +#endif + if (!enabled) { disableSoundProcessing = true; // keep processing suspended (FFT task) lastUMRun = millis(); // update time keeping @@ -1888,6 +2079,9 @@ class AudioReactive : public Usermod { infoArr.add(F("ADC analog")); } else { if (dmType == 5) infoArr.add(F("PDM digital")); // dmType 5 => generic PDM microphone + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + else if (dmType == 7) infoArr.add(F("CoreS3 ES7210 / I2S1")); + #endif else infoArr.add(F("I2S digital")); } // input level or "silence" @@ -1900,8 +2094,22 @@ class AudioReactive : public Usermod { } } else { // error during audio source setup - infoArr.add(F("not initialized")); - infoArr.add(F(" - check pin settings")); + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + if (dmType == 7) { + infoArr.add(F("CoreS3 ES7210 / I2S1")); + if (!coreS3AudioCodecReady()) { + infoArr.add(F(" - waiting codec")); + } else if (coreS3I2sInitAttempts >= CORES3_I2S_INIT_MAX_ATTEMPTS) { + infoArr.add(F(" - init failed")); + } else { + infoArr.add(F(" - initializing")); + } + } else + #endif + { + infoArr.add(F("not initialized")); + infoArr.add(F(" - check pin settings")); + } } } @@ -1913,6 +2121,16 @@ class AudioReactive : public Usermod { infoArr.add(F("suspended")); } + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + if (dmType == 7) { + infoArr = user.createNestedArray(F("CoreS3 FFT")); + infoArr.add(F("16kHz / 512 samples / 31.25Hz bin")); + + infoArr = user.createNestedArray(F("CoreS3 FFT Mapping")); + infoArr.add(F("16 bands calibrated / 7.81kHz max")); + } + #endif + // AGC or manual Gain if ((soundAgc==0) && (disableSoundProcessing == false) && !(audioSyncEnabled & 0x02)) { infoArr = user.createNestedArray(F("Manual Gain")); @@ -2064,11 +2282,17 @@ class AudioReactive : public Usermod { JsonObject dmic = top.createNestedObject(FPSTR(_digitalmic)); dmic["type"] = dmType; - JsonArray pinArray = dmic.createNestedArray("pin"); - pinArray.add(i2ssdPin); - pinArray.add(i2swsPin); - pinArray.add(i2sckPin); - pinArray.add(mclkPin); + + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + if (dmType != 7) + #endif + { + JsonArray pinArray = dmic.createNestedArray("pin"); + pinArray.add(i2ssdPin); + pinArray.add(i2swsPin); + pinArray.add(i2sckPin); + pinArray.add(mclkPin); + } JsonObject cfg = top.createNestedObject(FPSTR(_config)); cfg[F("squelch")] = soundSquelch; @@ -2137,10 +2361,17 @@ class AudioReactive : public Usermod { if (dmType == 5) dmType = SR_DMTYPE; // MCU does not support PDM #endif - configComplete &= getJsonValue(top[FPSTR(_digitalmic)]["pin"][0], i2ssdPin); - configComplete &= getJsonValue(top[FPSTR(_digitalmic)]["pin"][1], i2swsPin); - configComplete &= getJsonValue(top[FPSTR(_digitalmic)]["pin"][2], i2sckPin); - configComplete &= getJsonValue(top[FPSTR(_digitalmic)]["pin"][3], mclkPin); + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + if (dmType == 7) { + coreS3ApplyFixedAudioPins(i2ssdPin, i2swsPin, i2sckPin, mclkPin); + } else + #endif + { + configComplete &= getJsonValue(top[FPSTR(_digitalmic)]["pin"][0], i2ssdPin); + configComplete &= getJsonValue(top[FPSTR(_digitalmic)]["pin"][1], i2swsPin); + configComplete &= getJsonValue(top[FPSTR(_digitalmic)]["pin"][2], i2sckPin); + configComplete &= getJsonValue(top[FPSTR(_digitalmic)]["pin"][3], mclkPin); + } configComplete &= getJsonValue(top[FPSTR(_config)][F("squelch")], soundSquelch); configComplete &= getJsonValue(top[FPSTR(_config)][F("gain")], sampleGain); @@ -2190,6 +2421,9 @@ class AudioReactive : public Usermod { uiScript.print(F("addOption(dd,'Generic PDM',5);")); #endif uiScript.print(F("addOption(dd,'ES8388',6);")); + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + uiScript.print(F("addOption(dd,'M5Stack CoreS3 ES7210',7);")); + #endif uiScript.print(F("addOption(dd,'None - network receive only',")); uiScript.print(SR_DMTYPE_NETWORK_ONLY); uiScript.print(F(");")); @@ -2221,15 +2455,25 @@ class AudioReactive : public Usermod { #endif uiScript.print(F("addOption(dd,'Receive',2);")); #ifdef ARDUINO_ARCH_ESP32 - uiScript.print(F("addInfo(ux+':digitalmic:type',1,'requires reboot!');")); // 0 is field type, 1 is actual field - uiScript.print(F("addInfo(uxp,0,'sd/data/dout','I2S SD');")); - uiScript.print(F("addInfo(uxp,1,'ws/clk/lrck','I2S WS');")); - uiScript.print(F("addInfo(uxp,2,'sck/bclk','I2S SCK');")); - #if defined(CONFIG_IDF_TARGET_ESP32) - uiScript.print(F("addInfo(uxp,3,'only use -1, 0, 1 or 3','I2S MCLK');")); - #else - uiScript.print(F("addInfo(uxp,3,'master clock','I2S MCLK');")); - #endif + #if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) + if (dmType == 7) { + uiScript.print(F( + "addInfo(ux+':digitalmic:type',1," + "'fixed internal pins: DIN14 / WS33 / BCLK34 / MCLK0; requires reboot!');" + )); + } else + #endif + { + uiScript.print(F("addInfo(ux+':digitalmic:type',1,'requires reboot!');")); // 0 is field type, 1 is actual field + uiScript.print(F("addInfo(uxp,0,'sd/data/dout','I2S SD');")); + uiScript.print(F("addInfo(uxp,1,'ws/clk/lrck','I2S WS');")); + uiScript.print(F("addInfo(uxp,2,'sck/bclk','I2S SCK');")); + #if defined(CONFIG_IDF_TARGET_ESP32) + uiScript.print(F("addInfo(uxp,3,'only use -1, 0, 1 or 3','I2S MCLK');")); + #else + uiScript.print(F("addInfo(uxp,3,'master clock','I2S MCLK');")); + #endif + } #endif } diff --git a/usermods/audioreactive/audio_source.h b/usermods/audioreactive/audio_source.h index c2e67c2be5..03347301ac 100644 --- a/usermods/audioreactive/audio_source.h +++ b/usermods/audioreactive/audio_source.h @@ -168,8 +168,18 @@ class AudioSource { */ class I2SSource : public AudioSource { public: - I2SSource(SRate_t sampleRate, int blockSize, float sampleScale = 1.0f) : - AudioSource(sampleRate, blockSize, sampleScale) { + I2SSource( + SRate_t sampleRate, + int blockSize, + float sampleScale = 1.0f, + i2s_port_t i2sPort = I2S_NUM_0, + i2s_channel_t channelMode = I2S_CHANNEL_MONO, + bool managePins = true + ) : + AudioSource(sampleRate, blockSize, sampleScale), + _i2sPort(i2sPort), + _channelMode(channelMode), + _managePins(managePins) { _config = { .mode = i2s_mode_t(I2S_MODE_MASTER | I2S_MODE_RX), .sample_rate = _sampleRate, @@ -195,7 +205,7 @@ class I2SSource : public AudioSource { virtual void initialize(int8_t i2swsPin = I2S_PIN_NO_CHANGE, int8_t i2ssdPin = I2S_PIN_NO_CHANGE, int8_t i2sckPin = I2S_PIN_NO_CHANGE, int8_t mclkPin = I2S_PIN_NO_CHANGE) { DEBUGSR_PRINTLN(F("I2SSource:: initialize().")); - if (i2swsPin != I2S_PIN_NO_CHANGE && i2ssdPin != I2S_PIN_NO_CHANGE) { + if (_managePins && i2swsPin != I2S_PIN_NO_CHANGE && i2ssdPin != I2S_PIN_NO_CHANGE) { if (!PinManager::allocatePin(i2swsPin, true, PinOwner::UM_Audioreactive) || !PinManager::allocatePin(i2ssdPin, false, PinOwner::UM_Audioreactive)) { // #206 DEBUGSR_PRINTF("\nAR: Failed to allocate I2S pins: ws=%d, sd=%d\n", i2swsPin, i2ssdPin); @@ -205,7 +215,7 @@ class I2SSource : public AudioSource { // i2ssckPin needs special treatment, since it might be unused on PDM mics if (i2sckPin != I2S_PIN_NO_CHANGE) { - if (!PinManager::allocatePin(i2sckPin, true, PinOwner::UM_Audioreactive)) { + if (_managePins && !PinManager::allocatePin(i2sckPin, true, PinOwner::UM_Audioreactive)) { DEBUGSR_PRINTF("\nAR: Failed to allocate I2S pins: sck=%d\n", i2sckPin); return; } @@ -250,13 +260,16 @@ class I2SSource : public AudioSource { #endif #endif - // Reserve the master clock pin if provided + // Reserve the master clock pin if provided. + // CoreS3 internal audio pins are fixed board resources and intentionally + // bypass WLED PinManager; generic AudioReactive sources keep the + // original PinManager behavior. _mclkPin = mclkPin; if (mclkPin != I2S_PIN_NO_CHANGE) { - if(!PinManager::allocatePin(mclkPin, true, PinOwner::UM_Audioreactive)) { - DEBUGSR_PRINTF("\nAR: Failed to allocate I2S pin: MCLK=%d\n", mclkPin); + if (_managePins && !PinManager::allocatePin(mclkPin, true, PinOwner::UM_Audioreactive)) { + DEBUGSR_PRINTF("\nAR: Failed to allocate I2S pin: MCLK=%d\n", mclkPin); return; - } else + } _routeMclk(mclkPin); } @@ -272,32 +285,32 @@ class I2SSource : public AudioSource { //DEBUGSR_PRINTF("[AR] I2S: SD=%d, WS=%d, SCK=%d, MCLK=%d\n", i2ssdPin, i2swsPin, i2sckPin, mclkPin); - esp_err_t err = i2s_driver_install(I2S_NUM_0, &_config, 0, nullptr); + esp_err_t err = i2s_driver_install(_i2sPort, &_config, 0, nullptr); if (err != ESP_OK) { DEBUGSR_PRINTF("AR: Failed to install i2s driver: %d\n", err); return; } - DEBUGSR_PRINTF("AR: I2S#0 driver %s aPLL; fixed_mclk=%d.\n", _config.use_apll? "uses":"without", _config.fixed_mclk); + DEBUGSR_PRINTF("AR: I2S#%d driver %s aPLL; fixed_mclk=%d.\n", (int)_i2sPort, _config.use_apll? "uses":"without", _config.fixed_mclk); DEBUGSR_PRINTF("AR: %d bits, Sample scaling factor = %6.4f\n", _config.bits_per_sample, _sampleScale); if (_config.mode & I2S_MODE_PDM) { - DEBUGSR_PRINTLN(F("AR: I2S#0 driver installed in PDM MASTER mode.")); + DEBUGSR_PRINTF("AR: I2S#%d driver installed in PDM MASTER mode.\n", (int)_i2sPort); } else { - DEBUGSR_PRINTLN(F("AR: I2S#0 driver installed in MASTER mode.")); + DEBUGSR_PRINTF("AR: I2S#%d driver installed in MASTER mode.\n", (int)_i2sPort); } - err = i2s_set_pin(I2S_NUM_0, &_pinConfig); + err = i2s_set_pin(_i2sPort, &_pinConfig); if (err != ESP_OK) { DEBUGSR_PRINTF("AR: Failed to set i2s pin config: %d\n", err); - i2s_driver_uninstall(I2S_NUM_0); // uninstall already-installed driver + i2s_driver_uninstall(_i2sPort); // uninstall already-installed driver return; } #if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 2, 0) - err = i2s_set_clk(I2S_NUM_0, _sampleRate, I2S_SAMPLE_RESOLUTION, I2S_CHANNEL_MONO); // set bit clocks. Also takes care of MCLK routing if needed. + err = i2s_set_clk(_i2sPort, _sampleRate, _config.bits_per_sample, _channelMode); // set bit clocks. Also takes care of MCLK routing if needed. if (err != ESP_OK) { DEBUGSR_PRINTF("AR: Failed to configure i2s clocks: %d\n", err); - i2s_driver_uninstall(I2S_NUM_0); // uninstall already-installed driver + i2s_driver_uninstall(_i2sPort); // uninstall already-installed driver return; } #endif @@ -306,16 +319,18 @@ class I2SSource : public AudioSource { virtual void deinitialize() { _initialized = false; - esp_err_t err = i2s_driver_uninstall(I2S_NUM_0); + esp_err_t err = i2s_driver_uninstall(_i2sPort); if (err != ESP_OK) { DEBUGSR_PRINTF("Failed to uninstall i2s driver: %d\n", err); return; } - if (_pinConfig.ws_io_num != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_pinConfig.ws_io_num, PinOwner::UM_Audioreactive); - if (_pinConfig.data_in_num != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_pinConfig.data_in_num, PinOwner::UM_Audioreactive); - if (_pinConfig.bck_io_num != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_pinConfig.bck_io_num, PinOwner::UM_Audioreactive); - // Release the master clock pin - if (_mclkPin != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_mclkPin, PinOwner::UM_Audioreactive); + if (_managePins) { + if (_pinConfig.ws_io_num != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_pinConfig.ws_io_num, PinOwner::UM_Audioreactive); + if (_pinConfig.data_in_num != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_pinConfig.data_in_num, PinOwner::UM_Audioreactive); + if (_pinConfig.bck_io_num != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_pinConfig.bck_io_num, PinOwner::UM_Audioreactive); + // Release the master clock pin + if (_mclkPin != I2S_PIN_NO_CHANGE) PinManager::deallocatePin(_mclkPin, PinOwner::UM_Audioreactive); + } } virtual void getSamples(FFTsampleType *buffer, uint16_t num_samples) { @@ -324,7 +339,7 @@ class I2SSource : public AudioSource { size_t bytes_read = 0; /* Counter variable to check if we actually got enough data */ I2S_datatype newSamples[num_samples]; /* Intermediary sample storage */ - err = i2s_read(I2S_NUM_0, (void *)newSamples, sizeof(newSamples), &bytes_read, portMAX_DELAY); + err = i2s_read(_i2sPort, (void *)newSamples, sizeof(newSamples), &bytes_read, portMAX_DELAY); if (err != ESP_OK) { DEBUGSR_PRINTF("Failed to get samples: %d\n", err); return; @@ -396,8 +411,116 @@ class I2SSource : public AudioSource { i2s_config_t _config; i2s_pin_config_t _pinConfig; int8_t _mclkPin; + i2s_port_t _i2sPort; + i2s_channel_t _channelMode; + bool _managePins; }; + +#if defined(WLED_M5STACK_CORES3_AUDIO) && defined(CONFIG_IDF_TARGET_ESP32S3) +/* + * M5Stack CoreS3 ES7210 source + * + * ES7210 codec setup is performed by CoreS3_Audio. This source waits until + * the codec-ready signal is published, then becomes the sole owner of + * I2S_NUM_1 for PCM sampling. + * + * Hardware-verified CoreS3 audio path: + * MCLK GPIO0 + * BCLK GPIO34 + * WS GPIO33 + * DIN GPIO14 + * I2S_NUM_1 + * Stereo 16-bit / 16000 Hz + */ +class CoreS3ES7210Source : public I2SSource { + public: + static constexpr uint16_t MAX_MONO_SAMPLES = 512; + CoreS3ES7210Source(SRate_t sampleRate, int blockSize) : + I2SSource( + sampleRate, + blockSize, + 1.0f / 16.0f, + I2S_NUM_1, + I2S_CHANNEL_STEREO, + false + ) { + _config.mode = i2s_mode_t(I2S_MODE_MASTER | I2S_MODE_RX); + _config.sample_rate = _sampleRate; + _config.bits_per_sample = I2S_BITS_PER_SAMPLE_16BIT; + _config.channel_format = I2S_CHANNEL_FMT_RIGHT_LEFT; + _config.communication_format = i2s_comm_format_t(I2S_COMM_FORMAT_STAND_I2S); + _config.intr_alloc_flags = ESP_INTR_FLAG_LEVEL1; + _config.dma_buf_count = 8; + _config.dma_buf_len = _blockSize; + _config.use_apll = false; + _config.tx_desc_auto_clear = false; + _config.fixed_mclk = 0; +#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(4, 4, 0) + _config.mclk_multiple = I2S_MCLK_MULTIPLE_256; + _config.bits_per_chan = I2S_BITS_PER_CHAN_16BIT; +#endif + } + + void initialize( + int8_t = I2S_PIN_NO_CHANGE, + int8_t = I2S_PIN_NO_CHANGE, + int8_t = I2S_PIN_NO_CHANGE, + int8_t = I2S_PIN_NO_CHANGE + ) override { + DEBUGSR_PRINTLN(F("CoreS3ES7210Source:: initialize fixed internal pins;")); + + // CoreS3 ES7210 pins are board-internal fixed resources. + // Ignore Usermod pin dropdown values and bypass WLED PinManager. + I2SSource::initialize( + 33, // WS / LRCK + 14, // SD / DIN + 34, // BCLK + 0 // MCLK + ); + } + + void getSamples(FFTsampleType *buffer, uint16_t num_samples) override { + if (buffer == nullptr || num_samples == 0) return; + memset(buffer, 0, num_samples * sizeof(FFTsampleType)); + + if (!_initialized || num_samples > MAX_MONO_SAMPLES) return; + + int16_t stereoSamples[MAX_MONO_SAMPLES * 2]; + const size_t requestedBytes = (size_t)num_samples * 2U * sizeof(int16_t); + size_t bytesRead = 0; + + esp_err_t err = i2s_read( + _i2sPort, + stereoSamples, + requestedBytes, + &bytesRead, + portMAX_DELAY + ); + + if (err != ESP_OK) { + DEBUGSR_PRINTF("AR: CoreS3 ES7210 sample read failed: %d\n", err); + return; + } + + const size_t framesRead = bytesRead / (sizeof(int16_t) * 2U); + const size_t framesToCopy = min((size_t)num_samples, framesRead); + + for (size_t i = 0; i < framesToCopy; i++) { + const int32_t left = stereoSamples[i * 2U]; + const int32_t right = stereoSamples[i * 2U + 1U]; + const float mono = ((float)(left + right) * 0.5f) * _sampleScale; + +#if defined(UM_AUDIOREACTIVE_USE_INTEGER_FFT) + buffer[i] = (int16_t)constrain((int32_t)lroundf(mono), (int32_t)INT16_MIN, (int32_t)INT16_MAX); +#else + buffer[i] = mono; +#endif + } + } +}; +#endif + /* ES7243 Microphone This is an I2S microphone that requires initialization over I2C before I2S data can be received diff --git a/wled00/wled.cpp b/wled00/wled.cpp index f049ecaaa5..25841c4959 100644 --- a/wled00/wled.cpp +++ b/wled00/wled.cpp @@ -25,6 +25,14 @@ #endif extern "C" void usePWMFixedNMI(); +// Optional usermod bus re-init gate. +// The weak default preserves standard WLED behavior. CoreS3_Power provides +// the strong implementation used to protect old LED output before bus rebuild. +extern "C" bool __attribute__((weak)) coreS3PowerShouldDeferBusReinit() +{ + return false; +} + // millis()-rollover counter (millis() wraps every ~50 days) - previously // WLED_GLOBAL. json.cpp and usermods only ever read it for uptime reporting, // so it gets a by-value getter rather than a mutable reference: an accidental @@ -237,14 +245,18 @@ void WLED::loop() //LED settings have been saved, re-init busses //This code block causes severe FPS drop on ESP32 with the original "if (busConfigs[0] != nullptr)" conditional. Investigate! if (doInitBusses) { - doInitBusses = false; - DEBUG_PRINTLN(F("Re-init busses.")); - bool aligned = strip.checkSegmentAlignment(); //see if old segments match old bus(ses) - strip.finalizeInit(); // will create buses and also load default ledmap if present - if (aligned) strip.makeAutoSegments(); - else strip.fixInvalidSegments(); - BusManager::setBrightness(scaledBri(bri)); // fix re-initialised bus' brightness #4005 and #4824 - configNeedsWrite = true; + // Allow a usermod to defer this rebuild until the old LED output is safe. + // The weak default returns false, preserving standard WLED behavior. + if (!coreS3PowerShouldDeferBusReinit()) { + doInitBusses = false; + DEBUG_PRINTLN(F("Re-init busses.")); + bool aligned = strip.checkSegmentAlignment(); //see if old segments match old bus(ses) + strip.finalizeInit(); // will create buses and also load default ledmap if present + if (aligned) strip.makeAutoSegments(); + else strip.fixInvalidSegments(); + BusManager::setBrightness(scaledBri(bri)); // fix re-initialised bus' brightness #4005 and #4824 + configNeedsWrite = true; + } } if (loadLedmap >= 0) { strip.deserializeMap(loadLedmap);