diff --git a/usermods/CoreS3_Display/CoreS3_Display.cpp b/usermods/CoreS3_Display/CoreS3_Display.cpp new file mode 100644 index 0000000000..868f451eac --- /dev/null +++ b/usermods/CoreS3_Display/CoreS3_Display.cpp @@ -0,0 +1,7290 @@ +#include "wled.h" +#include +#include + +#include "M5StackDisplayHardwareBackend.h" +#include "M5StackDisplayUI.h" +#include "M5StackDisplayTouchState.h" +#include "M5StackDisplayTouchHelpers.h" +#include "M5StackDisplayTouchContext.h" + +#include "CoreS3_WLED_Logo.h" + +// AI: below section was generated by an AI + +// =========================================================== +// M5Stack CoreS3 Display Controller Usermod +// +// Current verified runtime +// - M5Stack CoreS3 +// - 320 x 240 display +// - Touch input +// - LCD brightness control +// +// 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 +// +// Architecture +// UI and WLED-state logic are kept separate from the thin +// CoreS3 Display / Touch / brightness hardware-access boundary. +// +// 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. +#if defined(WLED_M5STACK_CORES3) && defined(CONFIG_IDF_TARGET_ESP32S3) +bool coreS3PowerInitializationComplete(); +bool coreS3PowerExternal5VReady(); +bool coreS3PowerSafeShutdownMonitorReady(); +#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; + + // ========================================================= + // 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 global Wire / I2C0 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; + + // ========================================================= + // 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. + // ========================================================= + + 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; + + DEBUG_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(); + + DEBUG_PRINTF( "[CoreS3_Display] " "Preset cache ready: %u preset(s)\n", (unsigned)presetCacheCount ); + + if ( presetsModifiedTime != presetCacheSourceModifiedTime ) { + DEBUG_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(); + + DEBUG_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(); + + DEBUG_PRINTLN( F( "[CoreS3_Display] " "Preset save could not be queued" ) ); + + return false; + } + + presetSaveOperationState = PRESET_SAVE_OP_WAIT_WLED; + + presetSaveResultStartMs = 0; + + lastUserActivityMs = millis(); + + drawPresetSaveOperationStatus(); + + DEBUG_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(); + + DEBUG_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(); + + DEBUG_PRINTLN( F( "[CoreS3_Display] " "Preset overwrite could not be queued" ) ); + + return false; + } + + presetSaveOperationState = PRESET_SAVE_OP_WAIT_WLED; + + presetSaveResultStartMs = 0; + + lastUserActivityMs = millis(); + + drawPresetSaveOperationStatus(); + + DEBUG_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(); + + DEBUG_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(); + + DEBUG_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(); + + DEBUG_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 ) { + DEBUG_PRINTLN( F( "[CoreS3_Display] " "Boot Preset clear request" ) ); + } + else { + DEBUG_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; + + DEBUG_PRINTF( "[CoreS3_Display] " "%s verified: %u (%s)\n", presetSaveOperationIsOverwrite ? "Preset overwrite" : "Preset save", presetSaveCandidateId, presetSaveCandidateName.c_str() ); + } + else { + presetSaveOperationState = PRESET_SAVE_OP_FAILED; + + DEBUG_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; + + DEBUG_PRINTLN( F( "[CoreS3_Display] " "Deleted Boot Preset cleared" ) ); + } + + presetDeleteOperationState = PRESET_DELETE_OP_SUCCESS; + + DEBUG_PRINTF( "[CoreS3_Display] " "Preset delete verified: %u (%s)\n", presetDeleteTargetId, presetDeleteTargetName.c_str() ); + } + else { + presetDeleteOperationState = PRESET_DELETE_OP_FAILED; + + DEBUG_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 ) { + DEBUG_PRINTLN( F( "[CoreS3_Display] " "Boot Preset clear verified" ) ); + } + else { + DEBUG_PRINTF( "[CoreS3_Display] " "Boot Preset verified: %u (%s)\n", presetBootTargetId, presetBootTargetName.c_str() ); + } + } + else { + presetBootOperationState = PRESET_BOOT_OP_FAILED; + + DEBUG_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 ) { +#if defined(WLED_M5STACK_CORES3) && defined(CONFIG_IDF_TARGET_ESP32S3) + 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; + } +#endif + + 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 ); + + DEBUG_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 ); + + DEBUG_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; + + DEBUG_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 ); + + DEBUG_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(); + + DEBUG_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 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(); + + + char dimensionText[16]; + char audioText[8]; + + getEffectDimensionText( capability, dimensionText, sizeof(dimensionText) ); + getEffectAudioText( capability, audioText, sizeof(audioText) ); + + if ( incompatibleWithCurrentSegment ) { + if ( audioText[0] != '\0' ) { + snprintf( text, textSize, "2D REQUIRED | %s", audioText ); + } + else { + snprintf( text, textSize, "2D REQUIRED" ); + } + + 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 + ? 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 + ? 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 ); + } + + DEBUG_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; + + DEBUG_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" + + // ========================================================= + // Page-specific runtime display synchronization + // + // These helpers contain only UI/WLED state synchronization. + // Hardware-specific display/touch access remains isolated in the backend. + // ========================================================= + + 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; + } + } + } + + 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() ) { + DEBUG_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 ) { + DEBUG_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 { + DEBUG_PRINTLN(""); + + DEBUG_PRINTLN( F( "[CoreS3_Display][BUILD] CoreS3 Display v0.1.0" ) ); + DEBUG_PRINTLN( F( "[CoreS3_Display] Initialization start" ) ); + + DEBUG_PRINTLN( F( "[CoreS3_Display] Hardware: M5Stack CoreS3" ) ); + + DEBUG_PRINTF( "[CoreS3_Display] " "Settings: " "Sleep=%u sec, " "LCD=%u, " "Fade=%s, " "FadeDuration=%u ms\n", sleepTimeoutSec, lcdBrightness, fadeEnabled ? "ON" : "OFF", fadeDurationMs ); + + 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; + + DEBUG_PRINTLN( F( "[CoreS3_Display] Initialization complete" ) ); + + DEBUG_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(); + + + 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 displayInfo = user.createNestedArray( "CoreS3 Display" ); + + if ( displayReady ) { + char text[32]; + + snprintf( text, sizeof(text), "READY (%d x %d)", screenWidth, screenHeight ); + + displayInfo.add( text ); + } + else { + displayInfo.add( "FAILED" ); + } + + JsonArray touchInfo = user.createNestedArray( "CoreS3 Display Touch" ); + + 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); + +// AI: end 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..2ce48a9442 --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayHardwareBackend.cpp @@ -0,0 +1,804 @@ +#include "wled.h" + +#include "M5StackDisplayHardwareBackend.h" + +#include +#include + +#include + +// AI: below section was generated by an AI + +#if defined(WLED_M5STACK_CORES3) && defined(CONFIG_IDF_TARGET_ESP32S3) +#include +#include +#include +#include + +// =========================================================== +// Phase I2C0-R1-C CoreS3 display runtime +// +// CoreS3 Display / Touch / backlight use WLED's already initialized global +// Wire / I2C0 bus. M5GFX board autodetection is intentionally bypassed so +// the usermod does not create, own, or reinitialize a separate I2C bus. +// +// The panel, touch, and backlight behavior remains aligned with the +// M5GFX 0.2.26 CoreS3 implementation while I2C access stays on global Wire. +// =========================================================== + +namespace { + +static constexpr uint8_t CORES3_GLOBAL_WIRE_AW9523B_ADDR = 0x58; +static constexpr uint8_t CORES3_GLOBAL_WIRE_AXP2101_ADDR = 0x34; +static constexpr uint8_t CORES3_GLOBAL_WIRE_FT5X06_ADDR = 0x38; + +static constexpr uint8_t CORES3_AW9523B_OUTPUT_P0 = 0x02; +static constexpr uint8_t CORES3_AW9523B_OUTPUT_P1 = 0x03; +static constexpr uint8_t CORES3_AW9523B_CONFIG_P0 = 0x04; +static constexpr uint8_t CORES3_AW9523B_CONFIG_P1 = 0x05; +static constexpr uint8_t CORES3_AW9523B_LEDMODE_P0 = 0x12; + +static constexpr uint8_t CORES3_AW9523B_TOUCH_RST_MASK = 0x01; +static constexpr uint8_t CORES3_AW9523B_LCD_RST_MASK = 0x02; +static constexpr uint8_t CORES3_AW9523B_TOUCH_INT_MASK = 0x04; + +static constexpr uint8_t CORES3_AXP2101_LDO_ENABLE_REG = 0x90; +static constexpr uint8_t CORES3_AXP2101_DLDO1_ENABLE_MASK = 0x80; +static constexpr uint8_t CORES3_AXP2101_DLDO1_VOLTAGE_REG = 0x99; + +static bool coreS3GlobalWireReadBytes( + uint8_t address, + uint8_t reg, + uint8_t* data, + size_t length +) { + if ( data == nullptr || length == 0 ) { + return false; + } + + Wire.beginTransmission( address ); + Wire.write( reg ); + + if ( Wire.endTransmission( false ) != 0 ) { + return false; + } + + const size_t received = Wire.requestFrom( address, length, true ); + + if ( received != length ) { + while ( Wire.available() ) { + Wire.read(); + } + + return false; + } + + for ( size_t index = 0; index < length; ++index ) { + if ( !Wire.available() ) { + return false; + } + + data[index] = (uint8_t)Wire.read(); + } + + return true; +} + +static bool coreS3GlobalWireReadRegister8( + uint8_t address, + uint8_t reg, + uint8_t& value +) { + return coreS3GlobalWireReadBytes( + address, + reg, + &value, + 1 + ); +} + +static bool coreS3GlobalWireWriteRegister8( + uint8_t address, + uint8_t reg, + uint8_t value +) { + Wire.beginTransmission( address ); + Wire.write( reg ); + Wire.write( value ); + + return Wire.endTransmission() == 0; +} + +static bool coreS3GlobalWireUpdateRegister8( + uint8_t address, + uint8_t reg, + uint8_t mask, + uint8_t value +) { + uint8_t current = 0; + + if ( !coreS3GlobalWireReadRegister8( address, reg, current ) ) { + return false; + } + + const uint8_t updated = + ( current & static_cast( ~mask ) ) | + ( value & mask ); + + if ( updated == current ) { + return true; + } + + return coreS3GlobalWireWriteRegister8( + address, + reg, + updated + ); +} + + +class CoreS3PanelGlobalWire : public lgfx::Panel_ILI9342 { + public: + + CoreS3PanelGlobalWire() { + _cfg.pin_cs = GPIO_NUM_3; + _cfg.invert = true; + _cfg.offset_rotation = 3; + + _rotation = 1; + } + + protected: + + void rst_control( bool level ) override { + coreS3GlobalWireUpdateRegister8( + CORES3_GLOBAL_WIRE_AW9523B_ADDR, + CORES3_AW9523B_OUTPUT_P1, + CORES3_AW9523B_LCD_RST_MASK, + level ? CORES3_AW9523B_LCD_RST_MASK : 0 + ); + } + + void cs_control( bool level ) override { + lgfx::Panel_ILI9342::cs_control( level ); + + // CoreS3 shares GPIO35 between LCD MISO and LCD D/C. + // Preserve the same GPIO-matrix switching used by M5GFX 0.2.26. + *(volatile uint32_t*)GPIO_FUNC35_OUT_SEL_CFG_REG = + level ? FSPIQ_OUT_IDX : SIG_GPIO_OUT_IDX; + + *(volatile uint32_t*)( + level + ? GPIO_ENABLE1_W1TC_REG + : GPIO_ENABLE1_W1TS_REG + ) = 1u << ( GPIO_NUM_35 & 31 ); + } +}; + + +class CoreS3LightGlobalWire : public lgfx::ILight { + public: + + bool init( uint8_t brightness ) override { + setBrightness( brightness ); + + return true; + } + + void setBrightness( uint8_t brightness ) override { + uint8_t ldoEnable = 0; + + if ( + !coreS3GlobalWireReadRegister8( + CORES3_GLOBAL_WIRE_AXP2101_ADDR, + CORES3_AXP2101_LDO_ENABLE_REG, + ldoEnable + ) + ) { + return; + } + + uint8_t dldo1Voltage = 0; + + if ( brightness > 0 ) { + ldoEnable |= CORES3_AXP2101_DLDO1_ENABLE_MASK; + dldo1Voltage = (uint8_t)( ( brightness + 641u ) >> 5 ); + } + else { + ldoEnable &= (uint8_t)~CORES3_AXP2101_DLDO1_ENABLE_MASK; + } + + if ( + !coreS3GlobalWireWriteRegister8( + CORES3_GLOBAL_WIRE_AXP2101_ADDR, + CORES3_AXP2101_LDO_ENABLE_REG, + ldoEnable + ) + ) { + return; + } + + coreS3GlobalWireWriteRegister8( + CORES3_GLOBAL_WIRE_AXP2101_ADDR, + CORES3_AXP2101_DLDO1_VOLTAGE_REG, + dldo1Voltage + ); + } +}; + + +class CoreS3TouchGlobalWire : public lgfx::ITouch { + private: + + static constexpr uint8_t FT5X06_TOUCH_DATA_REG = 0x02; + static constexpr uint8_t FT5X06_CIPHER_REG = 0xA3; + static constexpr uint8_t FT5X06_INTMODE_REG = 0xA4; + static constexpr uint8_t FT5X06_POWER_REG = 0xA5; + + static constexpr uint8_t FT5X06_MONITOR = 0x01; + static constexpr uint8_t FT5X06_SLEEP_IN = 0x03; + + static constexpr uint8_t MAX_TOUCH_POINTS = 5; + static constexpr size_t MAX_TOUCH_FRAME_LENGTH = + MAX_TOUCH_POINTS * 6 - 1; + + bool released = false; + + bool writeRegister( uint8_t reg, uint8_t value ) { + return coreS3GlobalWireWriteRegister8( + CORES3_GLOBAL_WIRE_FT5X06_ADDR, + reg, + value + ); + } + + bool readRegisters( + uint8_t reg, + uint8_t* data, + size_t length + ) { + return coreS3GlobalWireReadBytes( + CORES3_GLOBAL_WIRE_FT5X06_ADDR, + reg, + data, + length + ); + } + + size_t readTouchFrame( uint8_t* data ) { + uint8_t pointCount = 0; + + if ( + data == nullptr || + !readRegisters( + FT5X06_TOUCH_DATA_REG, + &pointCount, + 1 + ) + ) { + return 0; + } + + uint8_t points = pointCount & 0x0F; + + if ( points > MAX_TOUCH_POINTS ) { + points = MAX_TOUCH_POINTS; + } + + if ( points == 0 ) { + data[0] = pointCount; + + return 1; + } + + const size_t frameLength = + static_cast( points ) * 6 - 1; + + if ( + !readRegisters( + FT5X06_TOUCH_DATA_REG, + data, + frameLength + ) + ) { + return 0; + } + + return frameLength; + } + + void clearAw9523Interrupt() { + uint8_t ignored = 0; + + coreS3GlobalWireReadRegister8( + CORES3_GLOBAL_WIRE_AW9523B_ADDR, + 0x00, + ignored + ); + + coreS3GlobalWireReadRegister8( + CORES3_GLOBAL_WIRE_AW9523B_ADDR, + 0x01, + ignored + ); + } + + bool prepareTouchHardware() { + // CoreS3 routes FT6336U TOUCH_RST through AW9523B P0_0. + // Reproduce the touch-specific state without creating/reinitializing + // another I2C controller. WLED global Wire / I2C0 remains the owner. + if ( + !coreS3GlobalWireUpdateRegister8( + CORES3_GLOBAL_WIRE_AW9523B_ADDR, + CORES3_AW9523B_CONFIG_P0, + CORES3_AW9523B_TOUCH_RST_MASK, + 0 + ) + ) { + return false; + } + + if ( + !coreS3GlobalWireUpdateRegister8( + CORES3_GLOBAL_WIRE_AW9523B_ADDR, + CORES3_AW9523B_LEDMODE_P0, + CORES3_AW9523B_TOUCH_RST_MASK, + CORES3_AW9523B_TOUCH_RST_MASK + ) + ) { + return false; + } + + if ( + !coreS3GlobalWireUpdateRegister8( + CORES3_GLOBAL_WIRE_AW9523B_ADDR, + CORES3_AW9523B_CONFIG_P1, + CORES3_AW9523B_TOUCH_INT_MASK, + CORES3_AW9523B_TOUCH_INT_MASK + ) + ) { + return false; + } + + // FT6336U requires RESET low for at least 5 ms and at least 300 ms + // between reset release and the first I2C access. + // Pulse reset explicitly so cold, warm, and software boots behave + // consistently while WLED global Wire / I2C0 remains active. + if ( + !coreS3GlobalWireUpdateRegister8( + CORES3_GLOBAL_WIRE_AW9523B_ADDR, + CORES3_AW9523B_OUTPUT_P0, + CORES3_AW9523B_TOUCH_RST_MASK, + 0 + ) + ) { + return false; + } + + delay( 10 ); + + if ( + !coreS3GlobalWireUpdateRegister8( + CORES3_GLOBAL_WIRE_AW9523B_ADDR, + CORES3_AW9523B_OUTPUT_P0, + CORES3_AW9523B_TOUCH_RST_MASK, + CORES3_AW9523B_TOUCH_RST_MASK + ) + ) { + return false; + } + + delay( 350 ); + + return true; + } + + public: + + bool isReady() const { + return _inited; + } + + CoreS3TouchGlobalWire() { + _cfg.pin_int = GPIO_NUM_21; + _cfg.pin_rst = -1; + + _cfg.pin_sda = GPIO_NUM_12; + _cfg.pin_scl = GPIO_NUM_11; + _cfg.i2c_addr = CORES3_GLOBAL_WIRE_FT5X06_ADDR; + _cfg.i2c_port = I2C_NUM_0; + _cfg.freq = 400000; + + _cfg.x_min = 0; + _cfg.x_max = 319; + _cfg.y_min = 0; + _cfg.y_max = 239; + + _cfg.offset_rotation = 0; + _cfg.bus_shared = false; + } + + bool init() override { + _inited = false; + released = false; + + if ( _cfg.pin_int >= 0 ) { + pinMode( _cfg.pin_int, INPUT_PULLUP ); + } + + if ( !prepareTouchHardware() ) { + return false; + } + + const bool workModeOk = writeRegister( 0x00, 0x00 ); + + uint8_t identity[6] = { 0 }; + const bool identityReadOk = readRegisters( + FT5X06_CIPHER_REG, + identity, + sizeof( identity ) + ); + + const bool intModeOk = writeRegister( + FT5X06_INTMODE_REG, + 0x00 + ); + + const bool vendorIdPresent = + identityReadOk && + identity[5] != 0; + + _inited = + workModeOk && + identityReadOk && + intModeOk && + vendorIdPresent; + + return _inited; + } + + void wakeup() override { + if ( !_inited && !init() ) { + return; + } + + if ( _cfg.pin_int >= 0 ) { + pinMode( _cfg.pin_int, INPUT_PULLDOWN ); + delayMicroseconds( 512 ); + pinMode( _cfg.pin_int, INPUT_PULLUP ); + } + + writeRegister( + FT5X06_POWER_REG, + FT5X06_MONITOR + ); + } + + void sleep() override { + if ( !_inited && !init() ) { + return; + } + + writeRegister( + FT5X06_POWER_REG, + FT5X06_SLEEP_IN + ); + } + + uint_fast8_t getTouchRaw( + lgfx::touch_point_t* points, + uint_fast8_t count + ) override { + if ( + points == nullptr || + count == 0 || + !_inited + ) { + return 0; + } + + if ( _cfg.pin_int >= 0 ) { + const bool currentReleased = + digitalRead( _cfg.pin_int ) != LOW; + + if ( released != currentReleased ) { + released = currentReleased; + + writeRegister( + FT5X06_INTMODE_REG, + 0x00 + ); + } + + if ( released ) { + return 0; + } + } + + if ( count > MAX_TOUCH_POINTS ) { + count = MAX_TOUCH_POINTS; + } + + uint8_t frame[2][MAX_TOUCH_FRAME_LENGTH] = {}; + size_t frameLength[2] = { + readTouchFrame( frame[0] ), + 0 + }; + + if ( frameLength[0] <= 1 ) { + clearAw9523Interrupt(); + + return 0; + } + + bool stableFrame = false; + + for ( uint8_t retry = 0; retry < 5; ++retry ) { + frameLength[1] = readTouchFrame( frame[1] ); + + if ( + frameLength[0] == frameLength[1] && + frameLength[0] > 1 && + memcmp( + frame[0], + frame[1], + frameLength[0] + ) == 0 + ) { + stableFrame = true; + break; + } + + if ( + frameLength[1] > 0 && + frameLength[1] <= MAX_TOUCH_FRAME_LENGTH + ) { + memcpy( + frame[0], + frame[1], + frameLength[1] + ); + + frameLength[0] = frameLength[1]; + } + } + + if ( !stableFrame ) { + clearAw9523Interrupt(); + + return 0; + } + + uint8_t detectedPoints = frame[0][0] & 0x0F; + + if ( detectedPoints > MAX_TOUCH_POINTS ) { + detectedPoints = MAX_TOUCH_POINTS; + } + + if ( count > detectedPoints ) { + count = detectedPoints; + } + + for ( uint_fast8_t index = 0; index < count; ++index ) { + const uint8_t* data = + &frame[0][static_cast( index ) * 6]; + + points[index].size = 1; + points[index].x = + ( ( data[1] & 0x0F ) << 8 ) | + data[2]; + + points[index].y = + ( ( data[3] & 0x0F ) << 8 ) | + data[4]; + + points[index].id = + data[3] >> 4; + } + + return count; + } +}; + + +class CoreS3DisplayGlobalWireRuntime { + private: + + lgfx::Bus_SPI bus; + CoreS3PanelGlobalWire panel; + CoreS3TouchGlobalWire touch; + CoreS3LightGlobalWire light; + + public: + + CoreS3DisplayGlobalWireRuntime() { + auto busConfig = bus.config(); + + busConfig.spi_host = SPI2_HOST; + busConfig.dma_channel = SPI_DMA_CH_AUTO; + + busConfig.freq_write = 40000000; + busConfig.freq_read = 16000000; + + busConfig.pin_mosi = GPIO_NUM_37; + busConfig.pin_miso = GPIO_NUM_35; + busConfig.pin_sclk = GPIO_NUM_36; + busConfig.pin_dc = GPIO_NUM_35; + + busConfig.spi_mode = 0; + busConfig.spi_3wire = true; + + bus.config( busConfig ); + + panel.bus( &bus ); + panel.setLight( &light ); + panel.setTouch( &touch ); + } + + bool begin( M5GFX& display ) { + return display.init( &panel ); + } + + bool isTouchReady() const { + return touch.isReady(); + } +}; + + +static CoreS3DisplayGlobalWireRuntime& +coreS3DisplayGlobalWireRuntime() { + static CoreS3DisplayGlobalWireRuntime runtime; + + return runtime; +} + +} // namespace +#endif + + +// CoreS3 is the only hardware target in this usermod. +static constexpr uint8_t CORES3_DISPLAY_ROTATION = 1; + +// =========================================================== +// CoreS3 runtime battery telemetry +// +// CoreS3 uses AXP2101 at 0x34 on the same WLED-owned global Wire / I2C0 +// bus used by CoreS3 Power, Display / Touch, and ES7210 Audio. +// +// AXP2101: +// 0x00 bit3 = battery present +// 0x01 6:5 = battery current direction (01 = charging) +// 0xA4 = fuel-gauge battery percentage +// =========================================================== + +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 ) { + Wire.beginTransmission( CORES3_AXP2101_ADDR ); + Wire.write( reg ); + + if ( Wire.endTransmission( false ) != 0 ) { + return false; + } + + const size_t received = + Wire.requestFrom( CORES3_AXP2101_ADDR, (size_t)1, true ); + + if ( received != 1 || !Wire.available() ) { + return false; + } + + value = (uint8_t)Wire.read(); + + return true; +} + +M5StackDisplayHardwareBackend::M5StackDisplayHardwareBackend( M5GFX& displayRef ) + : display( displayRef ) { + } + +bool M5StackDisplayHardwareBackend::initializeDisplay( int16_t& screenWidth, int16_t& screenHeight, bool& touchReady ) { +#if defined(WLED_M5STACK_CORES3) && defined(CONFIG_IDF_TARGET_ESP32S3) + CoreS3DisplayGlobalWireRuntime& coreS3Runtime = + coreS3DisplayGlobalWireRuntime(); + + DEBUG_PRINTLN( + F( "[CoreS3_Display] Display backend: global Wire / I2C0" ) + ); + + if ( !coreS3Runtime.begin( display ) ) { + DEBUG_PRINTLN( + F( "[CoreS3_Display] ERROR: CoreS3 global Wire display initialization failed" ) + ); + + return false; + } + + touchReady = coreS3Runtime.isTouchReady(); +#else + DEBUG_PRINTLN( + F( "[CoreS3_Display] ERROR: Display runtime has no active hardware backend" ) + ); + + return false; +#endif + + display.setRotation( CORES3_DISPLAY_ROTATION ); + + screenWidth = display.width(); + screenHeight = display.height(); + + DEBUG_PRINTF( "[CoreS3_Display] " "Display size: %d x %d\n", screenWidth, screenHeight ); + + if ( screenWidth <= 0 || screenHeight <= 0 ) { + DEBUG_PRINTLN( F( "[CoreS3_Display] " "ERROR: Display not detected" ) ); + + return false; + } + + DEBUG_PRINTF( "[CoreS3_Display] " "Touch: %s\n", touchReady ? "READY" : "NOT FOUND" ); + + return true; + } + +bool M5StackDisplayHardwareBackend::readTouch( int16_t& touchX, int16_t& touchY ) { +#if defined(WLED_M5STACK_CORES3) && defined(CONFIG_IDF_TARGET_ESP32S3) + return ( display.getTouch( &touchX, &touchY ) > 0 ); +#else + (void)touchX; + (void)touchY; + + return false; +#endif + } + +void M5StackDisplayHardwareBackend::writeBrightness( uint8_t value ) { +#if defined(WLED_M5STACK_CORES3) && defined(CONFIG_IDF_TARGET_ESP32S3) + display.setBrightness( value ); +#else + (void)value; +#endif + } + + +bool M5StackDisplayHardwareBackend::readBatteryStatus( M5StackBatteryStatus& status ) { + status = M5StackBatteryStatus(); + +#if !defined(WLED_M5STACK_CORES3) || !defined(CONFIG_IDF_TARGET_ESP32S3) + return false; +#else + 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; +#endif + } + +// AI: end diff --git a/usermods/CoreS3_Display/M5StackDisplayHardwareBackend.h b/usermods/CoreS3_Display/M5StackDisplayHardwareBackend.h new file mode 100644 index 0000000000..e3ba9e7b24 --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayHardwareBackend.h @@ -0,0 +1,42 @@ +#pragma once + +#include + +// AI: below section was generated by an AI + +// CoreS3 display hardware boundary. +// +// This class contains only CoreS3 board-dependent Display / Touch / +// brightness / battery access. 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: + + M5GFX& display; + + public: + + explicit M5StackDisplayHardwareBackend( M5GFX& displayRef ); + + bool initializeDisplay( + int16_t& screenWidth, + int16_t& screenHeight, + bool& touchReady + ); + + bool readTouch( int16_t& touchX, int16_t& touchY ); + void writeBrightness( uint8_t value ); + + // Read battery state through the CoreS3 hardware backend. + // The UI consumes only this generic status object; PMIC/register details + // stay outside CoreS3_Display.cpp. + bool readBatteryStatus( M5StackBatteryStatus& status ); +}; + +// AI: end diff --git a/usermods/CoreS3_Display/M5StackDisplayTouchContext.h b/usermods/CoreS3_Display/M5StackDisplayTouchContext.h new file mode 100644 index 0000000000..804a9ec53a --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayTouchContext.h @@ -0,0 +1,24 @@ +#pragma once + +#include "M5StackDisplayTouchState.h" + +// AI: below section was generated by an AI + +// 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; +}; + +// AI: end diff --git a/usermods/CoreS3_Display/M5StackDisplayTouchHelpers.h b/usermods/CoreS3_Display/M5StackDisplayTouchHelpers.h new file mode 100644 index 0000000000..2326184410 --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayTouchHelpers.h @@ -0,0 +1,89 @@ +#pragma once + +#include "M5StackDisplayUI.h" +#include "M5StackDisplayTouchState.h" + +// AI: below section was generated by an AI + +// 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 + +// AI: end diff --git a/usermods/CoreS3_Display/M5StackDisplayTouchState.h b/usermods/CoreS3_Display/M5StackDisplayTouchState.h new file mode 100644 index 0000000000..7b20949721 --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayTouchState.h @@ -0,0 +1,197 @@ +#pragma once + +#include + +// AI: below section was generated by an AI + +// 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; +}; + +// AI: end diff --git a/usermods/CoreS3_Display/M5StackDisplayTouchStateMachine.inc b/usermods/CoreS3_Display/M5StackDisplayTouchStateMachine.inc new file mode 100644 index 0000000000..5891934f3a --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayTouchStateMachine.inc @@ -0,0 +1,1724 @@ + // AI: below section was generated by an AI + + // ========================================================= + // 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 ); + } + + // AI: end diff --git a/usermods/CoreS3_Display/M5StackDisplayUI.h b/usermods/CoreS3_Display/M5StackDisplayUI.h new file mode 100644 index 0000000000..99c65725d9 --- /dev/null +++ b/usermods/CoreS3_Display/M5StackDisplayUI.h @@ -0,0 +1,72 @@ +#pragma once + +#include + +// AI: below section was generated by an AI + +// 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 }; + +// AI: end 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..9380a65988 --- /dev/null +++ b/usermods/CoreS3_Display/platformio_override.ini.sample @@ -0,0 +1,69 @@ +; ============================================================================= +; 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 +; +; 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 + +; Preserve normal WLED build scripts. +extra_scripts = + ${scripts_defaults.extra_scripts} + +custom_usermods = + CoreS3_Power + CoreS3_Display + audioreactive + +build_unflags = + ${env:esp32s3dev_16MB_opi.build_unflags} + +build_flags = + ${env:esp32s3dev_16MB_opi.build_flags} + + ; CoreS3 target identification. + -D WLED_M5STACK_CORES3=1 + + ; CoreS3 internal I2C bus. + -D I2CSDAPIN=12 + -D I2CSCLPIN=11 + + ; CoreS3 built-in ES7210 / AudioReactive integration. + -D UM_AUDIOREACTIVE_ENABLE + -D SR_DMTYPE=10 + -D I2S_SDPIN=14 + -D I2S_WSPIN=33 + -D I2S_CKPIN=34 + -D MCLK_PIN=0 + + ; 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..0397b59fdf --- /dev/null +++ b/usermods/CoreS3_Display/readme.md @@ -0,0 +1,443 @@ +# 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 + +--- + +## Screenshots + +The following images show the CoreS3 LCD user interface. + +### Main + +

+ CoreS3 main control screen +

+ +The MAIN screen provides LED Power, Brightness, Effect, Color, Preset, network status, and battery status. + +### Effect + + + + + + + + + + +
CoreS3 standard effect screenCoreS3 Audio Reactive effect screen
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 + + + + + + + + + + + + +
CoreS3 C1 color editing screenCoreS3 C1 and C2 color editing screenCoreS3 color not used screen
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 + + + + + + + + + + + + + + + + + + + + + + +
CoreS3 preset selection screenCoreS3 preset management screenCoreS3 save new preset screen
PresetPreset ManageSave New
CoreS3 delete preset screenCoreS3 boot preset screen
Delete PresetBoot Preset
+ +In addition to recalling Presets, CoreS3 can perform SAVE NEW / OVERWRITE / DELETE / BOOT PRESET operations. + +--- + +## 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 +``` + +The generic WLED `audioreactive` usermod is also enabled to provide ES7210 support for the CoreS3 built-in microphones. + +Audio Reactive definitions: + +```text +UM_AUDIOREACTIVE_ENABLE +SR_DMTYPE=10 +``` + +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, and Audio Reactive definitions. + +## 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. + + +--- + +## 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. + +Audio Reactive initializes ES7210 and uses the standard WLED I2S0 / PCM / FFT processing path. + +Main settings: + +```text +Codec ES7210 +I2S I2S0 +Sample Rate 22050 Hz +Codec Format 24-bit I2S +I2S Input Mono / WLED 32-bit container +MCLK GPIO0 +DIN GPIO14 +``` + +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 CoreS3-specific usermods are `CoreS3_Power` and `CoreS3_Display`. +The generic WLED `audioreactive` usermod also contains the ES7210 source used by the CoreS3 configuration. + +```text +WLED/ +├─ platformio_override.ini +└─ usermods/ + ├─ CoreS3_Power/ + ├─ CoreS3_Display/ + └─ 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 + +### audioreactive + +The generic WLED Audio Reactive usermod includes ES7210 support used by the CoreS3 built-in microphones through the standard WLED Audio Reactive I2S path. + +--- + +## 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. +- DCDC OVP protection is not disabled. + +--- + +## 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..813070e596 --- /dev/null +++ b/usermods/CoreS3_Display/readme_jp.md @@ -0,0 +1,443 @@ +# 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 状態を復元 + +--- + +## Screenshots + +以下は、CoreS3 本体の LCD ユーザーインターフェースの実画面です。 + +### Main + +

+ CoreS3 メイン操作画面 +

+ +MAIN 画面では、LED Power、Brightness、Effect、Color、Preset、およびネットワーク / バッテリー状態を確認できます。 + +### Effect + + + + + + + + + + +
CoreS3 標準エフェクト画面CoreS3 Audio Reactive エフェクト画面
Standard EffectAudio Reactive Effect
+ +Effect ごとに使用可能なパラメータだけを表示します。
+Audio Reactive Effect では、通常 Effect と異なるパラメータ構成にも追従します。 + +### Color + + + + + + + + + + + + +
CoreS3 C1 カラー編集画面CoreS3 C1・C2 カラー編集画面CoreS3 Color Not Used 画面
C1 EditC1 / C2Color Not Used
+ +C1 / C2 / C3 を個別に選択して編集できます。
+Effect が Color Slot を使用しない場合は `COLOR NOT USED` と表示します。 + +### Preset + + + + + + + + + + + + + + + + + + + + + + +
CoreS3 Preset 選択画面CoreS3 Preset 管理画面CoreS3 新規 Preset 保存画面
PresetPreset ManageSave New
CoreS3 Preset 削除画面CoreS3 Boot Preset 設定画面
Delete PresetBoot Preset
+ +Preset の呼び出しだけでなく、SAVE NEW / OVERWRITE / DELETE / BOOT PRESET まで CoreS3 から操作できます。 + +--- + +## 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 +``` + +WLED 標準の汎用 `audioreactive` Usermod も有効化し、CoreS3 内蔵マイクの ES7210 対応に使用します。 + +Audio Reactive 用: + +```text +UM_AUDIOREACTIVE_ENABLE +SR_DMTYPE=10 +``` + +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 定義が含まれています。 + +## 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 前に閉じてください。 + + +--- + +## 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 Reactive が ES7210 を初期化し、WLED 標準の I2S0 / PCM / FFT 処理経路を使用します。 + +主な仕様: + +```text +Codec ES7210 +I2S I2S0 +Sample Rate 22050 Hz +Codec Format 24-bit I2S +I2S Input Mono / WLED 32-bit container +MCLK GPIO0 +DIN GPIO14 +``` + +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 固有 Usermod は `CoreS3_Power` と `CoreS3_Display` です。 +WLED 標準の汎用 `audioreactive` Usermod には、CoreS3 構成で使用する ES7210 source も含まれます。 + +```text +WLED/ +├─ platformio_override.ini +└─ usermods/ + ├─ CoreS3_Power/ + ├─ CoreS3_Display/ + └─ 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 + +### audioreactive + +WLED 標準の汎用 Audio Reactive Usermod に含まれる ES7210 対応を、CoreS3 内蔵マイクで使用します。 + +--- + +## Current Limitations / Notes + +- Local UI では Segment 管理を行いません
+ Segment 設定は WLED Web UI を使用します。 +- 120 LED を CoreS3 から直接高輝度で給電する構成は推奨しません。 +- WLED 標準 Brightness 128 は変更していません。 +- CoreS3 では Brightness 64 前後からの使用を推奨します。 +- DCDC OVP 保護は無効化していません。 + +--- + +## 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..e2cb25fecc --- /dev/null +++ b/usermods/CoreS3_Power/CoreS3_Power.cpp @@ -0,0 +1,884 @@ +#include "wled.h" + +// AI: below section was generated by an AI + +#if defined(WLED_M5STACK_CORES3) && defined(CONFIG_IDF_TARGET_ESP32S3) + +#include +#include + +/* + * CoreS3 Power Production Baseline + * + * Production baseline for M5Stack CoreS3 power handling. + * + * 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 / 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; + +bool coreS3PowerInitializationComplete() +{ + return coreS3PowerInitializationCompleteState; +} + +bool coreS3PowerExternal5VReady() +{ + return coreS3PowerExternal5VReadyState; +} + +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 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; + + 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; + + 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) + { + DEBUG_PRINTF("[CoreS3_Power][BOOT] AXP2101 PWROFF_STATUS=0x%02X\n", status); + + if (status == 0) { + DEBUG_PRINTLN(F("[CoreS3_Power][BOOT] Power-off cause: NONE LATCHED / UNKNOWN")); + return; + } + + if (status & AXP2101_PWROFF_OVER_TEMP_MASK) + DEBUG_PRINTLN(F("[CoreS3_Power][BOOT] Power-off cause: PMIC DIE OVER TEMPERATURE")); + if (status & AXP2101_PWROFF_DCDC_OV_MASK) + DEBUG_PRINTLN(F("[CoreS3_Power][BOOT] Power-off cause: DCDC OVER VOLTAGE")); + if (status & AXP2101_PWROFF_DCDC_UV_MASK) + DEBUG_PRINTLN(F("[CoreS3_Power][BOOT] Power-off cause: DCDC UNDER VOLTAGE")); + if (status & AXP2101_PWROFF_VBUS_OV_MASK) + DEBUG_PRINTLN(F("[CoreS3_Power][BOOT] Power-off cause: VBUS OVER VOLTAGE")); + if (status & AXP2101_PWROFF_VSYS_UV_MASK) + DEBUG_PRINTLN(F("[CoreS3_Power][BOOT] Power-off cause: VSYS UNDER VOLTAGE")); + if (status & AXP2101_PWROFF_PWRON_LOW_MASK) + DEBUG_PRINTLN(F("[CoreS3_Power][BOOT] Power-off cause: PWRON HELD LOW / EN MODE")); + if (status & AXP2101_PWROFF_SOFTWARE_MASK) + DEBUG_PRINTLN(F("[CoreS3_Power][BOOT] Power-off cause: SOFTWARE POWER OFF")); + if (status & AXP2101_PWROFF_PWRON_PULLDOWN_MASK) + DEBUG_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) + { + return readRegister(address, reg, value); + } + + bool writeRuntimeRegister(uint8_t address, uint8_t reg, uint8_t value) + { + return writeRegister(address, reg, value); + } + + void captureBootAxpDiagnostics() + { + bootPowerOnStatusValid = + readRegister(AXP2101_ADDR, AXP2101_REG_PWRON_STATUS, bootPowerOnStatus); + bootPowerOffStatusValid = + readRegister(AXP2101_ADDR, AXP2101_REG_PWROFF_STATUS, bootPowerOffStatus); + + if (bootPowerOnStatusValid) { + DEBUG_PRINTF("[CoreS3_Power][BOOT] AXP2101 PWRON_STATUS=0x%02X\n", bootPowerOnStatus); + } + else { + DEBUG_PRINTLN(F("[CoreS3_Power][BOOT] AXP2101 PWRON_STATUS read FAILED")); + } + + if (bootPowerOffStatusValid) { + printPowerOffSource(bootPowerOffStatus); + } + else { + DEBUG_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 through WLED's global Wire / I2C0 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; + + DEBUG_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)) { + DEBUG_PRINTLN(F("[CoreS3_Power] Runtime power key: global Wire / I2C0 AXP2101 read FAILED")); + return false; + } + + if (chipId != 0x4A) { + DEBUG_PRINTF("[CoreS3_Power] Runtime power key: unexpected AXP2101 chip ID 0x%02X\n", chipId); + return false; + } + + if (!runtimePowerKeyBusReadyLogged) { + runtimePowerKeyBusReadyLogged = true; + DEBUG_PRINTF("[CoreS3_Power] Runtime I2C: global Wire / I2C0 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) { + DEBUG_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(); + + DEBUG_PRINTF("[CoreS3_Power] Runtime PKEY IRQEN1: 0x%02X -> 0x%02X\n", axpIrqEnableBefore, axpIrqEnableAfter); + DEBUG_PRINTLN(F("[CoreS3_Power] Runtime power key monitor: ARMED on global Wire / I2C0")); + 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)) { + DEBUG_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)) { + DEBUG_PRINTLN(F("[CoreS3_Power][DCDC3] REG81 write FAILED")); + return; + } + + if (!readRuntimeRegister(AXP2101_ADDR, AXP2101_REG_DCDC_FORCE_PWM, dcdcModeAfter)) { + DEBUG_PRINTLN(F("[CoreS3_Power][DCDC3] REG81 verify read FAILED")); + return; + } + + dcdc3StabilityApplied = + (dcdcModeAfter & AXP2101_DCDC3_ALWAYS_PWM_MASK) != 0; + + DEBUG_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; + DEBUG_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; + DEBUG_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; + + DEBUG_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" : "" + ); + + DEBUG_PRINTF( + "[CoreS3_Power] Safe shutdown: WLED bri=%u, strip=%u -> physical 0\n", + savedLogicalBrightness, + strip.getBrightness() + ); + + // Keep physical output BLACK without taking ownership of WLED's strip + // suspend state. + strip.waitForIt(); + strip.setBrightness(0, true); + strip.show(); + waitForLedOutputComplete(); + + safeShutdownBlankActive = true; + lastShutdownBlackRefresh = millis(); + + DEBUG_PRINTLN(F("[CoreS3_Power] Safe shutdown: BLACK frame sent; physical brightness held at 0")); + } + + 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; + + DEBUG_PRINTLN(F("[CoreS3_Power] Safe shutdown canceled: restoring LED output")); + + uint8_t restoreBrightness = bri; + if (restoreBrightness == 0 && savedLogicalBrightness > 0) { + restoreBrightness = savedLogicalBrightness; + } + + strip.setBrightness(restoreBrightness, true); + strip.show(); + waitForLedOutputComplete(); + + safeShutdownBlankActive = false; + safeShutdownLastCanceled = true; + + DEBUG_PRINTF("[CoreS3_Power] Safe shutdown canceled: restored bri=%u\n", restoreBrightness); + } + + 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; + DEBUG_PRINTLN(F("[CoreS3_Power] Runtime PKEY status read FAILED on global Wire / I2C0")); + } + maintainSafeShutdownBlank(now); + return; + } + + uint8_t powerKeyStatus = status & AXP2101_PKEY_EVENT_MASK; + + if (powerKeyStatus != 0) { + lastPowerKeyStatus = powerKeyStatus; + + DEBUG_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 + ) { + DEBUG_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; + + bootResetReason = esp_reset_reason(); + + DEBUG_PRINTLN(""); + DEBUG_PRINTLN(F("[CoreS3_Power][BUILD] CoreS3 Power v0.1.0")); + DEBUG_PRINTLN(F("[CoreS3_Power] Initialization start")); + DEBUG_PRINTF("[CoreS3_Power] I2C SDA=%d SCL=%d\n", i2c_sda, i2c_scl); + DEBUG_PRINTF( + "[CoreS3_Power][BOOT] ESP reset reason: %s (%d)\n", + resetReasonText(bootResetReason), + (int)bootResetReason + ); + + if (i2c_sda != 12 || i2c_scl != 11) { + DEBUG_PRINTLN(F("[CoreS3_Power] ERROR: Invalid CoreS3 I2C pins")); + coreS3PowerInitializationCompleteState = true; + return; + } + + aw9523Found = probeI2C(AW9523B_ADDR); + axp2101Found = probeI2C(AXP2101_ADDR); + + DEBUG_PRINTF("[CoreS3_Power] AW9523B (0x58): %s\n", aw9523Found ? "FOUND" : "NOT FOUND"); + DEBUG_PRINTF("[CoreS3_Power] AXP2101 (0x34): %s\n", axp2101Found ? "FOUND" : "NOT FOUND"); + + if (!aw9523Found || !axp2101Found) { + DEBUG_PRINTLN(F("[CoreS3_Power] External 5V enable canceled")); + coreS3PowerInitializationCompleteState = true; + return; + } + + captureBootAxpDiagnostics(); + + external5VEnableSuccess = enableExternal5VAtStartup(); + coreS3PowerExternal5VReadyState = external5VEnableSuccess; + + powerKeyMonitorReady = false; + runtimePowerKeyMonitorAttempted = false; + + DEBUG_PRINTLN(F("[CoreS3_Power] Power key monitor: DEFERRED to runtime loop on global Wire / I2C0")); + DEBUG_PRINTLN(F("[CoreS3_Power] Safe shutdown: AXP2101 LONG IRQ primary trigger")); + DEBUG_PRINTF("[CoreS3_Power] Safe shutdown: PRESS fallback >= %lu ms\n", SAFE_SHUTDOWN_FALLBACK_HOLD_MS); + DEBUG_PRINTF("[CoreS3_Power] External 5V: %s\n", external5VEnableSuccess ? "ENABLED" : "FAILED"); + + coreS3PowerInitializationCompleteState = true; + + DEBUG_PRINTLN(F("[CoreS3_Power] Initialization complete")); + DEBUG_PRINTLN(""); + } + + void loop() override + { + servicePhysicalPowerKey(); + serviceRuntimeExternal5VEnable(); + serviceDcdc3StabilityMode(); + serviceRuntimePowerHealth(); + } + + 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 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 Wire / I2C0 monitor unavailable" : "Runtime Wire / I2C0 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); + +#else + +/* + * CoreS3_Power is hardware-specific. + * + * Generic usermod CI compiles each usermod on several ESP32 targets. + * Keep a minimal registered module on non-CoreS3 targets so the build and + * module validation can run without compiling CoreS3-only power code. + */ +class CoreS3PowerUsermod : public Usermod +{ +public: + void setup() override {} + void loop() override {} +}; + +static CoreS3PowerUsermod coreS3PowerUsermod; +REGISTER_USERMOD(coreS3PowerUsermod); + +#endif // WLED_M5STACK_CORES3 && CONFIG_IDF_TARGET_ESP32S3 + +// AI: end 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..8460fe5af3 100644 --- a/usermods/audioreactive/audio_reactive.cpp +++ b/usermods/audioreactive/audio_reactive.cpp @@ -1478,6 +1478,17 @@ class AudioReactive : public Usermod { if (audioSource) audioSource->initialize(i2swsPin, i2ssdPin, i2sckPin, mclkPin); break; + #if ESP_IDF_VERSION_MAJOR >= 5 + // AI: below section was generated by an AI + case 10: + DEBUGSR_PRINTLN(F("AR: ES7210 Source")); + audioSource = new ES7210Source(SAMPLE_RATE, BLOCK_SIZE); + delay(100); + if (audioSource) audioSource->initialize(i2swsPin, i2ssdPin, i2sckPin, mclkPin); + break; + // AI: end + #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 case 0: @@ -2190,6 +2201,9 @@ class AudioReactive : public Usermod { uiScript.print(F("addOption(dd,'Generic PDM',5);")); #endif uiScript.print(F("addOption(dd,'ES8388',6);")); + #if ESP_IDF_VERSION_MAJOR >= 5 + uiScript.print(F("addOption(dd,'ES7210',10);")); + #endif uiScript.print(F("addOption(dd,'None - network receive only',")); uiScript.print(SR_DMTYPE_NETWORK_ONLY); uiScript.print(F(");")); diff --git a/usermods/audioreactive/audio_source.h b/usermods/audioreactive/audio_source.h index c2e67c2be5..b683638ad7 100644 --- a/usermods/audioreactive/audio_source.h +++ b/usermods/audioreactive/audio_source.h @@ -398,6 +398,179 @@ class I2SSource : public AudioSource { int8_t _mclkPin; }; +/* + * ES7210 Microphone + * + * Generic ES7210 I2S ADC source for AudioReactive. + * + * WLED samples at 22050 Hz. The ES7210 is configured for: + * - 22050 Hz LRCK + * - 11.2896 MHz MCLK (512 * sample rate) + * - 24-bit I2S output + * - MIC1 / MIC2 enabled + * + * Board-specific pin assignments remain in the build configuration. + */ +#ifndef ES7210_ADDR +#define ES7210_ADDR 0x40 +#endif + +#if defined(SR_DMTYPE) && (SR_DMTYPE == 10) && (ESP_IDF_VERSION_MAJOR < 5) +#error "ES7210 audio source requires ESP-IDF 5.x or newer" +#endif + +#if ESP_IDF_VERSION_MAJOR >= 5 +// AI: below section was generated by an AI +class ES7210Source : public I2SSource { + private: + struct RegisterValue { + uint8_t reg; + uint8_t value; + }; + + bool _es7210I2cWrite(uint8_t reg, uint8_t value) { + Wire.beginTransmission(ES7210_ADDR); + Wire.write(reg); + Wire.write(value); + + uint8_t i2cErr = Wire.endTransmission(); + + if (i2cErr != 0) { + DEBUGSR_PRINTF("AR: ES7210 I2C write failed with error=%d (addr=0x%X, reg=0x%X, val=0x%X).\n", + i2cErr, ES7210_ADDR, reg, value); + return false; + } + + return true; + } + + bool _es7210InitAdc() { + if (_sampleRate != 22050) { + DEBUGSR_PRINTF("AR: ES7210 requires 22050 Hz sampling; requested %u Hz.\n", (unsigned)_sampleRate); + return false; + } + + // Reset before applying the ES7210 ADC profile. + if (!_es7210I2cWrite(0x00, 0xFF)) { + return false; + } + + delay(1); + + /* + * The CoreS3 hardware-validated ES7210 sequence is retained for + * analog power, microphone bias, HPF and MIC1/MIC2 selection. + * + * The clock and sample-format registers are changed from the old + * 16 kHz / 16-bit profile to WLED's standard: + * + * 22050 Hz + * MCLK = 22050 * 512 = 11.2896 MHz + * 24-bit I2S + * + * ES7210 22.05 kHz coefficient: + * REG02 = 0x81 + * REG07 = 0x20 + * REG04 = 0x02 + * REG05 = 0x00 + * + * ES7210 24-bit sample format: + * REG11 = 0x00 + */ + 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 }, + + // 22.05 kHz with 11.2896 MHz MCLK. + { 0x02, 0x81 }, + { 0x04, 0x02 }, + { 0x05, 0x00 }, + + // 24-bit I2S output. + { 0x11, 0x00 }, + + // Analog / microphone configuration. + { 0x40, 0x42 }, + { 0x41, 0x70 }, + { 0x42, 0x70 }, + + // MIC1 / MIC2 enabled using the validated gain profile. + { 0x43, 0x1B }, + { 0x44, 0x1B }, + + // MIC3 / MIC4 remain unused. + { 0x45, 0x00 }, + { 0x46, 0x00 }, + { 0x47, 0x00 }, + { 0x48, 0x00 }, + { 0x49, 0x00 }, + { 0x4A, 0x00 }, + + { 0x4B, 0x00 }, + { 0x4C, 0xFF }, + + // Enable the required ES7210 clocks. + { 0x01, 0x14 } + }; + + for (const auto& item : registers) { + if (!_es7210I2cWrite(item.reg, item.value)) { + return false; + } + } + + return true; + } + + public: + ES7210Source(SRate_t sampleRate, int blockSize, float sampleScale = 1.0f) : I2SSource(sampleRate, blockSize, sampleScale) { + + /* + * ES7210 22.05 kHz uses an 11.2896 MHz master clock. + * 22050 * 512 = 11289600. + * + * Keep the standard WLED I2S0 source and request the supported + * 512x MCLK multiplier from the ESP-IDF legacy I2S driver. + */ + _config.mclk_multiple = I2S_MCLK_MULTIPLE_512; + } + + void initialize(int8_t i2swsPin, int8_t i2ssdPin, int8_t i2sckPin, int8_t mclkPin) override { + DEBUGSR_PRINTLN(F("ES7210Source:: initialize();")); + + if ((i2sckPin < 0) || (mclkPin < 0)) { + DEBUGSR_PRINTF("\nAR: invalid ES7210 I2S pin: SCK=%d, MCLK=%d\n", i2sckPin, mclkPin); + return; + } + + if (!_es7210InitAdc()) { + DEBUGSR_PRINTLN(F("AR: ES7210 codec initialization failed.")); + return; + } + + /* + * Standard I2SSource owns: + * - PinManager allocation + * - I2S_NUM_0 + * - I2S driver lifecycle + * - standard sample acquisition + */ + I2SSource::initialize(i2swsPin, i2ssdPin, i2sckPin, mclkPin); + } +}; +// AI: end +#endif // ESP_IDF_VERSION_MAJOR >= 5 + /* ES7243 Microphone This is an I2S microphone that requires initialization over I2C before I2S data can be received