diff --git a/usermods/FSEQ/README.md b/usermods/FSEQ/README.md new file mode 100644 index 0000000000..af6f49ccfa --- /dev/null +++ b/usermods/FSEQ/README.md @@ -0,0 +1,316 @@ +# FSEQ Player + FPP Connect usermods for WLED + +This package contains two WLED usermods: + +- **FSEQ** – local `.fseq` playback from the SD card as a WLED effect +- **FPP Connect** – FPP-compatible discovery, status, upload, and sync control + +Together they let WLED play FSEQ files locally, expose a small SD card manager UI, and behave like a lightweight FPP-controlled receiver. + +--- + +## What the current code does + +### Local FSEQ playback + +The **FSEQ Player** effect is added to WLED and plays `.fseq` files directly from the SD card. + +Effect controls: + +- **Index** → selects the file by index (`custom3` / `c3`) +- **Loop** → repeats playback continuously +- **Send Sync Multicast** → sends FPP sync packets to multicast while the local effect is playing +- **Send Sync Broadcast** → sends FPP sync packets to broadcast while the local effect is playing + +Effect definition in code: + +```cpp +"FSEQ Player@,,,,Index,Loop,Send Sync Multicast,Send Sync Broadcast;;;c3=0,o1=1,o2=0,o3=0" +``` + +When the selected file changes, the player loads the new sequence for the current segment and starts from the beginning. + +--- + +## File indexing + +The usermod builds an index of `.fseq` files found on the **root of the SD card**. + +Current behavior: + +- scans **root only** (`/`) +- ignores subfolders +- includes `.fseq` and `.FSEQ` +- sorts files **alphabetically, case-insensitive** +- caches up to **128** indexed FSEQ filenames internally + +Example: + +| Index | File | +|------:|------| +| 0 | `/00-snow.fseq` | +| 1 | `/01-christmas.fseq` | +| 2 | `/02-finale.fseq` | + +### Important local UI limitation + +The local WLED effect uses **`custom3` (`c3`)** for file selection. In practice this is intended for the usual WLED `c3` range, so **local manual selection is only practical for the first 32 entries**. + +The internal filename cache is larger so that: + +- FPP control can still start files by **filename** +- the web/API file list can still expose more entries + +So: + +- **local effect selection** → best for the first 32 indexed files +- **FPP/file-name control** → can still address files beyond that + +--- + +## Multi-segment behavior + +Local playback is **segment-aware**. + +Each segment can run its own FSEQ file with its own: + +- selected index +- loop state +- playback position + +That means you can save presets where multiple segments each play different local FSEQ files. + +### While FPP is active + +When FPP override is active, local segment playback is temporarily suppressed so realtime/FPP playback can take over. + +The override is considered active for about **3 seconds** after the last valid FPP control activity. + +--- + +## FPP integration + +The second usermod registers itself as **`FPP Connect`** and provides FPP-compatible discovery and control behavior. + +### UDP listeners + +The code listens on **UDP port `32320`** using both: + +- normal UDP/broadcast listener +- multicast listener on **`239.70.80.80:32320`** + +### Discovery / ping behavior + +After Wi-Fi connects, the usermod sends a short discovery burst and then continues periodic announcements. + +Current behavior: + +- sends discovery to **subnet broadcast** and **global broadcast** +- sends an initial burst of **5** announcements +- burst interval: **1 second** +- regular ping/discovery interval: **10 seconds** + +### Supported incoming UDP packet types + +The code reacts to: + +- **START** packets +- **SYNC** packets +- **PING** packets +- **BLANK** packets +- **STOP** packets + +### Realtime playback behavior + +Incoming FPP sync control uses the realtime FSEQ player. + +Highlights from the current implementation: + +- a **START** packet can start playback from a given filename/time +- a **SYNC** packet can also start playback if the current file is not already active +- sync updates use a **soft drift correction** / slew-based approach instead of constant hard jumps +- a **STOP** or **BLANK** packet stops realtime FPP playback + +### Local effect as sync source + +The local **FSEQ Player** effect can also send outgoing FPP sync packets while it is playing. + +Current behavior: + +- sends **START** when playback begins or settings change +- sends periodic **SYNC** updates every **500 ms** while enabled +- sends **STOP** when playback ends or sync sending is disabled +- can send to **multicast**, **broadcast**, or both depending on the effect checkboxes + +--- + +## FPP-compatible HTTP endpoints + +The code currently registers these endpoints: + +| Endpoint | Method | Purpose | +|---|---|---| +| `/api/system/info` | GET | FPP-style device info | +| `/api/system/status` | GET | FPP-style playback/status info | +| `/api/fppd/multiSyncSystems` | GET | multi-sync system info | +| `/fpp` | POST | file upload endpoint used by FPP | +| `/fseqfilelist` | GET | simple JSON list of `.fseq` files | + +The status/info responses identify the device as a WLED-based remote-style FPP target. + +--- + +## XLZ upload and auto-unpack support + +The code includes **XLZ** support using `unzipLIB`. + +### What happens with `.xlz` files + +When a file is uploaded through the **FPP upload endpoint** (`POST /fpp`): + +- `.fseq` uploads are stored directly +- `.xlz` uploads are stored first and unpacked later + +### Deferred unpack behavior + +For `.xlz` uploads received through `/fpp`: + +- unpacking is **deferred** +- it starts after **10 seconds of upload inactivity** +- unpacking only runs when **no playback is active** +- the extracted file is written as `.fseq` +- the original `.xlz` archive is removed after successful extraction + +### Boot-time XLZ scan + +On boot, the code also checks the SD card root once after a short delay. + +Current behavior: + +- waits about **2 seconds** after startup +- scans the SD root for pending `.xlz` files +- unpacks them only when no playback is currently active + +### XLZ extraction details + +The extractor currently: + +- scans the **SD root only** +- extracts **just the first file** from the archive +- normalizes names and rejects simple path traversal entries like `../...` +- checks available SD space before extracting + +### Important note about the web UI upload + +The built-in **web UI upload endpoint** (`/api/sd/upload`) uploads files **as-is**. + +So if you upload an `.xlz` file through the browser UI: + +- it is stored on the SD card +- it is **not immediately auto-unpacked by that endpoint** + +Automatic deferred XLZ handling is implemented in the **FPP upload path** (`/fpp`). + +--- + +## Web UI + +The FSEQ usermod adds an entry in the WLED **Info** page that opens: + +```text +/fsequi +``` + +This page is an SD manager for the usermod. + +### What the page shows + +- **SD Storage** usage bar +- **FSEQ Files** list with the indexed order used by the effect +- **Other SD Files** list +- **Upload** section +- **Delete** buttons for files +- short usage instructions + +### Web UI API endpoints + +These endpoints are registered by `WebUIManager`: + +| Endpoint | Method | Purpose | +|---|---|---| +| `/fsequi` | GET | HTML UI | +| `/api/sd/list` | GET | full SD overview: indexed FSEQ files, other files, storage usage | +| `/api/fseq/list` | GET | indexed FSEQ list only | +| `/api/sd/upload` | POST | browser upload to SD | +| `/api/sd/delete` | POST | delete a file from SD | + +--- + +## How to use local playback + +1. Copy one or more `.fseq` files to the **root** of the SD card. +2. In WLED, select the **FSEQ Player** effect. +3. Choose the file using the **Index** control. +4. Enable **Loop** if needed. +5. Optionally enable **Send Sync Multicast** and/or **Send Sync Broadcast**. +6. Save the setup as a preset if you want to reuse it or start it on boot. + +--- + +## Presets and boot preset + +Because playback is driven by the WLED effect engine, normal WLED presets can store the local setup, including: + +- selected effect +- selected index (`c3`) +- loop state +- sync send checkboxes +- segment configuration + +To auto-start a local sequence after boot: + +1. configure the effect +2. save it as a preset +3. assign that preset as the WLED **Boot preset** + +--- + +## Requirements + +Based on the code in this package, the usermod expects: + +- a working **SD card backend** in WLED +- the standard **`sd_card` usermod** or equivalent SD setup already handling card initialization +- WLED built for a target that provides either: + - `WLED_USE_SD_SPI`, or + - `WLED_USE_SD_MMC` +- `unzipLIB` for XLZ extraction + +This package uses `sd_adapter_compat.h` so it can talk to the available SD backend without directly depending on a specific SD object name in every source file. + +--- + +## Practical notes / limitations + +- Only the **SD root directory** is scanned for `.fseq` and `.xlz` files. +- Local effect selection is effectively best for the **first 32 indexed files**. +- The internal FSEQ index cache can hold up to **128** filenames. +- FPP realtime playback temporarily overrides local segmented playback. +- Browser upload and FPP upload behave differently for `.xlz` files. +- Sequence rendering maps FSEQ data as **RGB triplets** across LEDs. Extra channel data is ignored once the target segment/strip is full; missing data is cleared to black. + +--- + +## Summary + +This codebase currently provides: + +- local SD-card-based FSEQ playback as a WLED effect +- per-segment local playback +- optional outgoing FPP sync from the local effect +- incoming FPP discovery, ping, blank, start, stop, and sync handling +- FPP-style info/status HTTP endpoints +- a small SD management web UI +- deferred `.xlz` extraction for FPP uploads plus boot-time pending archive processing + +If you rename, add, or remove `.fseq` files on the SD card, the alphabetical index order can change, so presets that rely on a specific local index may need to be updated. diff --git a/usermods/FSEQ/fseq_effect.h b/usermods/FSEQ/fseq_effect.h new file mode 100644 index 0000000000..84f116ebe6 --- /dev/null +++ b/usermods/FSEQ/fseq_effect.h @@ -0,0 +1,164 @@ +#pragma once + +#include "wled.h" +#include "fseq_player.h" + +uint16_t FSEQ_getFileIndexCount(); +bool FSEQ_getFileNameByIndex(uint16_t index, String &outName); +bool FSEQ_isFppOverrideActive(); +bool FPP_sendEffectSyncMessage(uint8_t action, const String &fileName, + uint32_t currentFrame, float secondsElapsed, + bool sendMulticast, bool sendBroadcast); + +static uint16_t _fseq_lastIndex[MAX_NUM_SEGMENTS]; +static bool _fseq_lastLoop[MAX_NUM_SEGMENTS]; +static String _fseq_lastFileName[MAX_NUM_SEGMENTS]; +static bool _fseq_stateInit = false; +static uint32_t _fseq_syncStartMs[MAX_NUM_SEGMENTS]; +static uint32_t _fseq_lastSyncMs[MAX_NUM_SEGMENTS]; +static bool _fseq_wasPlaying[MAX_NUM_SEGMENTS]; +static bool _fseq_wasSyncing[MAX_NUM_SEGMENTS]; +static bool _fseq_lastSendMulticast[MAX_NUM_SEGMENTS]; +static bool _fseq_lastSendBroadcast[MAX_NUM_SEGMENTS]; + +static constexpr uint32_t FSEQ_EFFECT_SYNC_INTERVAL_MS = 500; + +static void resetFseqSyncState(uint8_t segId) { + _fseq_syncStartMs[segId] = 0; + _fseq_lastSyncMs[segId] = 0; + _fseq_wasPlaying[segId] = false; + _fseq_wasSyncing[segId] = false; + _fseq_lastSendMulticast[segId] = false; + _fseq_lastSendBroadcast[segId] = false; +} + +static void stopFseqSyncIfNeeded(uint8_t segId, const String &fileName = String()) { + if (!_fseq_wasSyncing[segId]) return; + + FPP_sendEffectSyncMessage(1, fileName, 0, 0.0f, + _fseq_lastSendMulticast[segId], + _fseq_lastSendBroadcast[segId]); + resetFseqSyncState(segId); +} + +static void mode_fseq_player(void) { + if (!_fseq_stateInit) { + for (uint8_t i = 0; i < MAX_NUM_SEGMENTS; i++) { + _fseq_lastIndex[i] = 0xFFFF; + _fseq_lastLoop[i] = false; + _fseq_lastFileName[i] = String(); + resetFseqSyncState(i); + } + _fseq_stateInit = true; + } + + const uint8_t segId = strip.getCurrSegmentId(); + + // While FPP is active, local segmented playback must stay out of the way. + if (FSEQ_isFppOverrideActive()) { + stopFseqSyncIfNeeded(segId); + _fseq_wasPlaying[segId] = false; + _fseq_syncStartMs[segId] = 0; + _fseq_lastSyncMs[segId] = 0; + SEGMENT.fill(BLACK); + return; + } + + const uint16_t fileCount = FSEQ_getFileIndexCount(); + const uint8_t selectedIndex = SEGMENT.custom3; + const bool loop = SEGMENT.check1; + const bool sendSyncMulticast = SEGMENT.check2; + const bool sendSyncBroadcast = SEGMENT.check3; + const bool sendSyncEnabled = sendSyncMulticast || sendSyncBroadcast; + + String currentFileName; + const bool hasValidFile = + (fileCount > 0) && + (selectedIndex < fileCount) && + FSEQ_getFileNameByIndex(selectedIndex, currentFileName) && + currentFileName.length() > 0; + + if (!hasValidFile) { + if (FSEQPlayer::isSegmentPlaying(segId)) { + FSEQPlayer::clearSegmentPlayback(segId); + } + stopFseqSyncIfNeeded(segId, currentFileName); + _fseq_wasPlaying[segId] = false; + _fseq_syncStartMs[segId] = 0; + _fseq_lastSyncMs[segId] = 0; + SEGMENT.fill(BLACK); + _fseq_lastIndex[segId] = selectedIndex; + _fseq_lastLoop[segId] = loop; + _fseq_lastFileName[segId] = String(); + return; + } + + const bool fileChanged = !_fseq_lastFileName[segId].equals(currentFileName); + + if (fileChanged) { + char path[256]; + currentFileName.toCharArray(path, sizeof(path)); + FSEQPlayer::loadRecordingForSegment(segId, path, 0.0f, loop); + _fseq_syncStartMs[segId] = millis(); + _fseq_lastSyncMs[segId] = 0; + } + + _fseq_lastIndex[segId] = selectedIndex; + _fseq_lastLoop[segId] = loop; + _fseq_lastFileName[segId] = currentFileName; + + FSEQPlayer::setSegmentLooping(segId, loop); + + if (!FSEQPlayer::isSegmentPlaying(segId)) { + stopFseqSyncIfNeeded(segId, currentFileName); + _fseq_wasPlaying[segId] = false; + _fseq_syncStartMs[segId] = 0; + _fseq_lastSyncMs[segId] = 0; + SEGMENT.fill(BLACK); + return; + } + + const uint32_t now = millis(); + if (!_fseq_wasPlaying[segId] || _fseq_syncStartMs[segId] == 0) { + _fseq_syncStartMs[segId] = now; + } + + if (!sendSyncEnabled) { + stopFseqSyncIfNeeded(segId, currentFileName); + } else { + const float secondsElapsed = + (float)(now - _fseq_syncStartMs[segId]) / 1000.0f; + const bool needsStart = fileChanged || !_fseq_wasPlaying[segId] || + !_fseq_wasSyncing[segId] || + (_fseq_lastSendMulticast[segId] != sendSyncMulticast) || + (_fseq_lastSendBroadcast[segId] != sendSyncBroadcast); + + if (needsStart) { + if (_fseq_wasSyncing[segId] && + ((_fseq_lastSendMulticast[segId] != sendSyncMulticast) || + (_fseq_lastSendBroadcast[segId] != sendSyncBroadcast))) { + FPP_sendEffectSyncMessage(1, currentFileName, 0, 0.0f, + _fseq_lastSendMulticast[segId], + _fseq_lastSendBroadcast[segId]); + } + FPP_sendEffectSyncMessage(0, currentFileName, 0, secondsElapsed, + sendSyncMulticast, sendSyncBroadcast); + _fseq_lastSyncMs[segId] = now; + _fseq_wasSyncing[segId] = true; + } else if ((now - _fseq_lastSyncMs[segId]) >= FSEQ_EFFECT_SYNC_INTERVAL_MS) { + FPP_sendEffectSyncMessage(2, currentFileName, 0, secondsElapsed, + sendSyncMulticast, sendSyncBroadcast); + _fseq_lastSyncMs[segId] = now; + _fseq_wasSyncing[segId] = true; + } + + _fseq_lastSendMulticast[segId] = sendSyncMulticast; + _fseq_lastSendBroadcast[segId] = sendSyncBroadcast; + } + + _fseq_wasPlaying[segId] = true; + FSEQPlayer::renderSegmentFrame(segId, SEGMENT); +} + +static const char _data_FX_MODE_FSEQ_PLAYER[] PROGMEM = + "FSEQ Player@,,,,Index,Loop,Send Sync Multicast,Send Sync Broadcast;;;c3=0,o1=1,o2=0,o3=0"; diff --git a/usermods/FSEQ/fseq_player.cpp b/usermods/FSEQ/fseq_player.cpp new file mode 100644 index 0000000000..9f7d92ee1e --- /dev/null +++ b/usermods/FSEQ/fseq_player.cpp @@ -0,0 +1,754 @@ +#include "wled.h" +#include "fseq_player.h" +#include "usermod_fseq.h" +#include "sd_adapter_compat.h" +#include + +// Static member definitions +const char UsermodFseq::_name[] PROGMEM = "FSEQ"; +uint8_t UsermodFseq::fseqEffectId = 0; + +FSEQPlayer::PlaybackState FSEQPlayer::realtimeState; +FSEQPlayer::PlaybackState FSEQPlayer::segmentStates[MAX_NUM_SEGMENTS]; + +namespace { +// Keep the cache larger than the effect UI range. +// The effect uses c3 (0..31) for easier local selection, +// but FPP/file-name based playback must still be able to +// find more files than that by name. +constexpr uint16_t FSEQ_MAX_INDEXED_FILES = 128; +constexpr uint32_t FSEQ_FPP_OVERRIDE_TIMEOUT_MS = 3000; +constexpr size_t FSEQ_SHARED_FRAME_BUFFER_SIZE = 1024; +constexpr uint32_t FSEQ_SYNC_DEBUG_INTERVAL_MS = 5000; +constexpr uint32_t FSEQ_REALTIME_LOCK_REFRESH_MS = 1000; + +uint32_t gLastSoftSyncDebugMs = 0; +uint32_t gLastRealtimeLockMs = 0; + +String gIndexedFseqFiles[FSEQ_MAX_INDEXED_FILES]; +uint16_t gIndexedFseqFileCount = 0; +bool gIndexedFseqFilesDirty = true; +uint32_t gFppLastControlMs = 0; +bool gFppOverrideActive = false; + +// Shared read buffer to avoid large stack allocations. +// Rendering is done sequentially in the main loop, so one shared buffer is fine. +static uint8_t gFseqFrameBuffer[FSEQ_SHARED_FRAME_BUFFER_SIZE]; + +bool isFseqFileName(const String &name) { + return name.endsWith(".fseq") || name.endsWith(".FSEQ"); +} + +int compareFseqName(const String &a, const String &b) { + String al = a; + String bl = b; + al.toLowerCase(); + bl.toLowerCase(); + return al.compareTo(bl); +} + +void insertSortedFseqName(const String &name) { + if (gIndexedFseqFileCount >= FSEQ_MAX_INDEXED_FILES) return; + + uint16_t insertPos = gIndexedFseqFileCount; + for (uint16_t i = 0; i < gIndexedFseqFileCount; i++) { + if (compareFseqName(name, gIndexedFseqFiles[i]) < 0) { + insertPos = i; + break; + } + } + + for (uint16_t i = gIndexedFseqFileCount; i > insertPos; i--) { + gIndexedFseqFiles[i] = gIndexedFseqFiles[i - 1]; + } + + gIndexedFseqFiles[insertPos] = name; + gIndexedFseqFileCount++; +} +} // namespace + +void FSEQ_invalidateFileIndexCache() { gIndexedFseqFilesDirty = true; } + +uint16_t FSEQ_refreshFileIndexCache() { + if (!gIndexedFseqFilesDirty) return gIndexedFseqFileCount; + + gIndexedFseqFileCount = 0; + + File root = SD_ADAPTER.open("/"); + if (!root || !root.isDirectory()) { + if (root) root.close(); + return 0; + } + + File file = root.openNextFile(); + while (file) { + if (!file.isDirectory()) { + String name = file.name(); + if (name.length() > 0 && !name.startsWith("/")) name = "/" + name; + if (isFseqFileName(name)) insertSortedFseqName(name); + } + + file.close(); + file = root.openNextFile(); + } + + root.close(); + gIndexedFseqFilesDirty = false; + return gIndexedFseqFileCount; +} + +uint16_t FSEQ_getFileIndexCount() { return FSEQ_refreshFileIndexCache(); } + +bool FSEQ_getFileNameByIndex(uint16_t index, String &outName) { + FSEQ_refreshFileIndexCache(); + if (index >= gIndexedFseqFileCount) { + outName = ""; + return false; + } + outName = gIndexedFseqFiles[index]; + return true; +} + +int16_t FSEQ_findFileIndexByName(const String &name) { + String normalized = name; + if (!normalized.startsWith("/")) normalized = "/" + normalized; + + FSEQ_refreshFileIndexCache(); + + for (uint16_t i = 0; i < gIndexedFseqFileCount; i++) { + if (gIndexedFseqFiles[i].equalsIgnoreCase(normalized)) { + return (int16_t)i; + } + } + + return -1; +} + +void FSEQ_markFppControlActivity() { + gFppLastControlMs = millis(); + gFppOverrideActive = true; +} + +void FSEQ_clearFppOverride() { gFppOverrideActive = false; } + +bool FSEQ_isFppOverrideActive() { + if (gFppOverrideActive && + (millis() - gFppLastControlMs > FSEQ_FPP_OVERRIDE_TIMEOUT_MS)) { + gFppOverrideActive = false; + } + return gFppOverrideActive; +} + +inline uint32_t FSEQPlayer::readUInt32(File &file) { + uint8_t buffer[4]; + if (file.read(buffer, 4) != 4) return 0; + return (uint32_t)buffer[0] | ((uint32_t)buffer[1] << 8) | + ((uint32_t)buffer[2] << 16) | ((uint32_t)buffer[3] << 24); +} + +inline uint32_t FSEQPlayer::readUInt24(File &file) { + uint8_t buffer[3]; + if (file.read(buffer, 3) != 3) return 0; + return (uint32_t)buffer[0] | ((uint32_t)buffer[1] << 8) | + ((uint32_t)buffer[2] << 16); +} + +inline uint16_t FSEQPlayer::readUInt16(File &file) { + uint8_t buffer[2]; + if (file.read(buffer, 2) != 2) return 0; + return (uint16_t)buffer[0] | ((uint16_t)buffer[1] << 8); +} + +inline uint8_t FSEQPlayer::readUInt8(File &file) { + int c = file.read(); + return (c < 0) ? 0 : (uint8_t)c; +} + +bool FSEQPlayer::fileOnSD(const char *filepath) { + uint8_t cardType = SD_ADAPTER.cardType(); + if (cardType == CARD_NONE) return false; + return SD_ADAPTER.exists(filepath); +} + +bool FSEQPlayer::fileOnFS(const char *filepath) { return false; } + +void FSEQPlayer::printHeaderInfo(const PlaybackState &state) { + DEBUG_PRINTLN("FSEQ file header:"); + DEBUG_PRINTF(" channel_data_offset = %d\n", state.file_header.channel_data_offset); + DEBUG_PRINTF(" minor_version = %d\n", state.file_header.minor_version); + DEBUG_PRINTF(" major_version = %d\n", state.file_header.major_version); + DEBUG_PRINTF(" header_length = %d\n", state.file_header.header_length); + DEBUG_PRINTF(" channel_count = %lu\n", (unsigned long)state.file_header.channel_count); + DEBUG_PRINTF(" frame_count = %lu\n", (unsigned long)state.file_header.frame_count); + DEBUG_PRINTF(" step_time = %d\n", state.file_header.step_time); + DEBUG_PRINTF(" flags = %d\n", state.file_header.flags); +} + +bool FSEQPlayer::ensureFrameDataPosition(PlaybackState &state) { + if (!state.frame_position_dirty) return true; + if (!state.recordingFile.seek(state.frame_data_offset)) { + DEBUG_PRINTLN("[FSEQ] Failed to seek to frame data"); + clearPlaybackState(state); + return false; + } + state.frame_position_dirty = false; + return true; +} + +bool FSEQPlayer::skipRemainingFrameData(PlaybackState &state, uint32_t bytesToSkip) { + if (bytesToSkip == 0) return true; + (void)bytesToSkip; + + const uint32_t newOffset = state.frame_data_offset + state.file_header.channel_count; + if (!state.recordingFile.seek(newOffset)) { + DEBUG_PRINTLN("[FSEQ] Failed to skip remaining frame data"); + clearPlaybackState(state); + return false; + } + return true; +} + +void FSEQPlayer::scheduleNextFrame(PlaybackState &state) { + const uint32_t step = state.file_header.step_time; + if (step == 0) { + state.next_time = 0; + return; + } + + state.next_time = state.playback_start_time + ((state.frame + 1U) * step); +} + +void FSEQPlayer::resetTimingFromCurrentFrame(PlaybackState &state, uint32_t now) { + const uint32_t step = state.file_header.step_time; + if (step == 0) { + state.playback_start_time = now; + state.next_time = 0; + return; + } + + const uint32_t frameOffsetMs = state.frame * step; + // Use wrap-safe unsigned timing so late-join can anchor to an elapsed time + // that is already larger than the local millis() uptime. + state.playback_start_time = now - frameOffsetMs; + scheduleNextFrame(state); +} + +void FSEQPlayer::alignFrameToLocalTime(PlaybackState &state, uint32_t now) { + if (!isStatePlaying(state)) return; + + const uint32_t step = state.file_header.step_time; + if (step == 0) return; + if (state.file_header.frame_count == 0) return; + + // When we already advanced past the last frame, let stopBecauseAtTheEnd() + // handle loop/stop logic instead of snapping back to the last frame again. + if (state.frame >= state.file_header.frame_count) return; + + const uint32_t maxFrame = state.file_header.frame_count - 1U; + uint32_t localFrame = (uint32_t)(now - state.playback_start_time) / step; + if (localFrame > maxFrame) localFrame = maxFrame; + + if (localFrame != state.frame) { + state.frame = localFrame; + state.frame_data_offset = + state.file_header.channel_data_offset + + ((uint32_t)state.file_header.channel_count * state.frame); + state.frame_position_dirty = true; + } + + scheduleNextFrame(state); +} + +void FSEQPlayer::processFrameDataForSegment(PlaybackState &state, Segment &segment) { + const uint32_t packetLength = state.file_header.channel_count; + const uint16_t segLen = segment.length(); + const uint16_t maxLeds = min((uint32_t)segLen, packetLength / 3U); + const uint32_t bytesToRender = min(packetLength, (uint32_t)maxLeds * 3U); + uint32_t bytesRemainingToRender = bytesToRender; + uint16_t index = 0; + + while (bytesRemainingToRender > 0) { + const uint16_t length = + (uint16_t)min(bytesRemainingToRender, (uint32_t)FSEQ_SHARED_FRAME_BUFFER_SIZE); + + const size_t bytesRead = + state.recordingFile.readBytes((char *)gFseqFrameBuffer, length); + if (bytesRead != length) { + DEBUG_PRINTF("[FSEQ] Short SD read in segment playback (%u/%u), stopping state\n", + (unsigned)bytesRead, (unsigned)length); + clearPlaybackState(state); + return; + } + + bytesRemainingToRender -= length; + + for (uint16_t offset = 0; offset + 2 < length; offset += 3) { + segment.setPixelColor( + index, + RGBW32(gFseqFrameBuffer[offset], gFseqFrameBuffer[offset + 1], + gFseqFrameBuffer[offset + 2], 0)); + if (++index >= maxLeds) break; + } + } + + for (uint16_t i = index; i < segLen; i++) { + segment.setPixelColor(i, BLACK); + } + + if (!skipRemainingFrameData(state, packetLength - bytesToRender)) return; + + state.frame_data_offset += state.file_header.channel_count; +} + +static inline void refreshRealtimeLockIfNeeded(uint32_t now) { + if (gLastRealtimeLockMs == 0 || + (uint32_t)(now - gLastRealtimeLockMs) >= FSEQ_REALTIME_LOCK_REFRESH_MS) { + realtimeLock(3000, REALTIME_MODE_FSEQ); + gLastRealtimeLockMs = now; + } +} + +void FSEQPlayer::processFrameDataRealtime(PlaybackState &state) { + const uint32_t packetLength = state.file_header.channel_count; + const uint16_t totalLen = strip.getLengthTotal(); + const uint16_t maxLeds = min((uint32_t)totalLen, packetLength / 3U); + const uint32_t bytesToRender = min(packetLength, (uint32_t)maxLeds * 3U); + uint32_t bytesRemainingToRender = bytesToRender; + uint16_t index = 0; + + while (bytesRemainingToRender > 0) { + const uint16_t length = + (uint16_t)min(bytesRemainingToRender, (uint32_t)FSEQ_SHARED_FRAME_BUFFER_SIZE); + + const size_t bytesRead = + state.recordingFile.readBytes((char *)gFseqFrameBuffer, length); + if (bytesRead != length) { + DEBUG_PRINTF("[FSEQ] Short SD read in realtime playback (%u/%u), stopping state\n", + (unsigned)bytesRead, (unsigned)length); + clearPlaybackState(state); + return; + } + + bytesRemainingToRender -= length; + + for (uint16_t offset = 0; offset + 2 < length; offset += 3) { + setRealtimePixel(index, gFseqFrameBuffer[offset], + gFseqFrameBuffer[offset + 1], + gFseqFrameBuffer[offset + 2], 0); + if (++index >= maxLeds) break; + } + } + + if (index < totalLen) { + for (uint16_t i = index; i < totalLen; i++) { + setRealtimePixel(i, 0, 0, 0, 0); + } + } + + if (!skipRemainingFrameData(state, packetLength - bytesToRender)) return; + + refreshRealtimeLockIfNeeded(state.now); + state.frame_data_offset += state.file_header.channel_count; +} + +bool FSEQPlayer::stopBecauseAtTheEnd(PlaybackState &state) { + if (state.frame >= state.file_header.frame_count) { + if (state.recordingRepeats == RECORDING_REPEAT_LOOP) { + state.frame = 0; + state.frame_data_offset = state.file_header.channel_data_offset; + state.frame_position_dirty = true; + state.syncErrorFilteredMs = 0.0f; + state.syncSlewMs = 0.0f; + state.syncCarryMs = 0.0f; + state.playback_start_time = millis(); + scheduleNextFrame(state); + return false; + } + + if (state.recordingRepeats > 0) { + state.recordingRepeats--; + state.frame = 0; + state.frame_data_offset = state.file_header.channel_data_offset; + state.frame_position_dirty = true; + state.syncErrorFilteredMs = 0.0f; + state.syncSlewMs = 0.0f; + state.syncCarryMs = 0.0f; + state.playback_start_time = millis(); + scheduleNextFrame(state); + DEBUG_PRINTF("Repeat recording again for: %d\n", state.recordingRepeats); + return false; + } + + DEBUG_PRINTLN("Finished playing recording"); + clearPlaybackState(state); + return true; + } + + return false; +} + + +void FSEQPlayer::playNextRecordingFrameForSegment(PlaybackState &state, Segment &segment) { + if (stopBecauseAtTheEnd(state)) return; + if (!ensureFrameDataPosition(state)) return; + + processFrameDataForSegment(state, segment); + if (!isStatePlaying(state)) return; + + state.frame++; + scheduleNextFrame(state); +} + +void FSEQPlayer::playNextRealtimeFrame(PlaybackState &state) { + if (stopBecauseAtTheEnd(state)) return; + if (!ensureFrameDataPosition(state)) return; + + processFrameDataRealtime(state); + if (!isStatePlaying(state)) return; + + state.frame++; + scheduleNextFrame(state); +} + +void FSEQPlayer::loadRecordingIntoState(PlaybackState &state, const char *filepath, + float secondsElapsed, bool loop) { + clearPlaybackState(state); + + DEBUG_PRINTF("FSEQ load animation: %s\n", filepath); + if (fileOnSD(filepath)) { + DEBUG_PRINTF("Read file from SD: %s\n", filepath); + state.recordingFile = SD_ADAPTER.open(filepath, "rb"); + } else if (fileOnFS(filepath)) { + DEBUG_PRINTF("Read file from FS: %s\n", filepath); + state.recordingFile = WLED_FS.open(filepath, "rb"); + } else { + DEBUG_PRINTF("File %s not found on SD or FS\n", filepath); + return; + } + + if (!state.recordingFile) { + DEBUG_PRINTF("Failed to open %s\n", filepath); + return; + } + + state.file_size = (uint32_t)state.recordingFile.size(); + state.currentFileName = String(filepath); + if (state.currentFileName.startsWith("/")) { + state.currentFileName = state.currentFileName.substring(1); + } + + if (state.file_size < sizeof(state.file_header)) { + DEBUG_PRINTF("Invalid file size: %lu\n", (unsigned long)state.file_size); + clearPlaybackState(state); + return; + } + + for (int i = 0; i < 4; i++) state.file_header.identifier[i] = readUInt8(state.recordingFile); + state.file_header.channel_data_offset = readUInt16(state.recordingFile); + state.file_header.minor_version = readUInt8(state.recordingFile); + state.file_header.major_version = readUInt8(state.recordingFile); + state.file_header.header_length = readUInt16(state.recordingFile); + state.file_header.channel_count = readUInt32(state.recordingFile); + state.file_header.frame_count = readUInt32(state.recordingFile); + state.file_header.step_time = readUInt8(state.recordingFile); + state.file_header.flags = readUInt8(state.recordingFile); + printHeaderInfo(state); + + if (state.file_header.identifier[0] != 'P' || state.file_header.identifier[1] != 'S' || + state.file_header.identifier[2] != 'E' || state.file_header.identifier[3] != 'Q') { + DEBUG_PRINTF("Error reading FSEQ file %s header, invalid identifier\n", filepath); + clearPlaybackState(state); + return; + } + + if (state.file_header.frame_count == 0 || state.file_header.channel_count == 0) { + DEBUG_PRINTF("Error reading FSEQ file %s header, empty data\n", filepath); + clearPlaybackState(state); + return; + } + + if (state.file_header.header_length > state.file_header.channel_data_offset) { + DEBUG_PRINTF("Error reading FSEQ file %s header, invalid header offsets\n", filepath); + clearPlaybackState(state); + return; + } + + const uint64_t requiredSize = + (uint64_t)state.file_header.channel_data_offset + + ((uint64_t)state.file_header.channel_count * (uint64_t)state.file_header.frame_count); + + if (requiredSize > state.file_size) { + DEBUG_PRINTF("Error reading FSEQ file %s header, truncated frame data\n", filepath); + clearPlaybackState(state); + return; + } + + if (requiredSize > UINT32_MAX) { + DEBUG_PRINTF("Error reading FSEQ file %s header, file too long (max 4gb)\n", filepath); + clearPlaybackState(state); + return; + } + + if (state.file_header.step_time < 1) { + DEBUG_PRINTF("Invalid step time %d, using default %d instead\n", + state.file_header.step_time, FSEQ_DEFAULT_STEP_TIME); + state.file_header.step_time = FSEQ_DEFAULT_STEP_TIME; + } + + state.recordingRepeats = loop ? RECORDING_REPEAT_LOOP : RECORDING_REPEAT_DEFAULT; + + secondsElapsed = max(0.0f, secondsElapsed); + const float startMs = secondsElapsed * 1000.0f; + const float stepMs = (float)state.file_header.step_time; + + state.frame = (uint32_t)(startMs / stepMs); + if (state.frame >= state.file_header.frame_count) { + state.frame = state.file_header.frame_count - 1U; + } + + state.frame_data_offset = + state.file_header.channel_data_offset + + (state.file_header.channel_count * state.frame); + state.frame_position_dirty = true; + + const uint32_t now = millis(); + const uint32_t startMsU32 = (uint32_t)startMs; + // Wrap-safe anchor: allows joining an already-running sequence even when + // local uptime is still smaller than the elapsed sequence time. + state.playback_start_time = now - startMsU32; + + state.secondsElapsed = secondsElapsed; + if (&state == &realtimeState) gLastRealtimeLockMs = 0; + state.syncErrorFilteredMs = 0.0f; + state.syncSlewMs = 0.0f; + state.syncCarryMs = 0.0f; + alignFrameToLocalTime(state, now); + scheduleNextFrame(state); +} + +void FSEQPlayer::clearPlaybackState(PlaybackState &state) { + state.frame = 0; + state.next_time = 0; + state.playback_start_time = 0; + state.now = 0; + state.secondsElapsed = 0; + state.recordingRepeats = RECORDING_REPEAT_DEFAULT; + state.file_size = 0; + state.frame_data_offset = 0; + state.frame_position_dirty = true; + state.file_header = {}; + state.syncErrorFilteredMs = 0.0f; + state.syncSlewMs = 0.0f; + state.syncCarryMs = 0.0f; + if (state.recordingFile) state.recordingFile.close(); + if (&state == &realtimeState) gLastRealtimeLockMs = 0; + state.currentFileName = ""; +} + +bool FSEQPlayer::isStatePlaying(const PlaybackState &state) { + return state.recordingFile && state.file_header.frame_count > 0 && + state.frame <= state.file_header.frame_count; +} + +void FSEQPlayer::setStateLooping(PlaybackState &state, bool loop) { + state.recordingRepeats = loop ? RECORDING_REPEAT_LOOP : RECORDING_REPEAT_DEFAULT; +} + +float FSEQPlayer::getElapsedSeconds(const PlaybackState &state) { + if (!isStatePlaying(state)) return 0; + + const uint32_t step = state.file_header.step_time; + if (step == 0) return 0; + + uint32_t now = millis(); + uint32_t elapsedMs = now - state.playback_start_time; + + const uint32_t maxMs = (state.file_header.frame_count - 1U) * step; + if (elapsedMs > maxMs) elapsedMs = maxMs; + return (float)elapsedMs / 1000.0f; +} + +void FSEQPlayer::loadRecordingForSegment(uint8_t segmentId, const char *filepath, + float secondsElapsed, bool loop) { + if (segmentId >= MAX_NUM_SEGMENTS) return; + loadRecordingIntoState(segmentStates[segmentId], filepath, secondsElapsed, loop); +} + +void FSEQPlayer::clearSegmentPlayback(uint8_t segmentId) { + if (segmentId >= MAX_NUM_SEGMENTS) return; + clearPlaybackState(segmentStates[segmentId]); +} + +void FSEQPlayer::setSegmentLooping(uint8_t segmentId, bool loop) { + if (segmentId >= MAX_NUM_SEGMENTS) return; + setStateLooping(segmentStates[segmentId], loop); +} + +bool FSEQPlayer::isSegmentPlaying(uint8_t segmentId) { + if (segmentId >= MAX_NUM_SEGMENTS) return false; + return isStatePlaying(segmentStates[segmentId]); +} + +bool FSEQPlayer::isAnySegmentPlaying() { + for (uint8_t i = 0; i < MAX_NUM_SEGMENTS; i++) { + if (isStatePlaying(segmentStates[i])) return true; + } + return false; +} + +bool FSEQPlayer::isAnyPlaybackActive() { + return isStatePlaying(realtimeState) || isAnySegmentPlaying(); +} + +void FSEQPlayer::renderSegmentFrame(uint8_t segmentId, Segment &segment) { + if (segmentId >= MAX_NUM_SEGMENTS) return; + PlaybackState &state = segmentStates[segmentId]; + state.now = millis(); + if (!isStatePlaying(state)) return; + + if (state.frame >= state.file_header.frame_count) { + if (stopBecauseAtTheEnd(state)) return; + } + + const bool forceRender = state.frame_position_dirty; + if (!forceRender && state.next_time != 0 && + (int32_t)(state.now - state.next_time) < 0) { + return; + } + + alignFrameToLocalTime(state, state.now); + playNextRecordingFrameForSegment(state, segment); +} + +void FSEQPlayer::loadRecording(const char *filepath, float secondsElapsed, bool loop) { + loadRecordingIntoState(realtimeState, filepath, secondsElapsed, loop); +} + +void FSEQPlayer::clearLastPlayback() { clearPlaybackState(realtimeState); } + +bool FSEQPlayer::isPlaying() { return isStatePlaying(realtimeState); } + +void FSEQPlayer::setLooping(bool loop) { setStateLooping(realtimeState, loop); } + +String FSEQPlayer::getFileName() { return realtimeState.currentFileName; } + +float FSEQPlayer::getElapsedSeconds() { return getElapsedSeconds(realtimeState); } + +void FSEQPlayer::renderRealtimeFrame() { + PlaybackState &state = realtimeState; + state.now = millis(); + if (!isStatePlaying(state)) return; + + if (state.frame >= state.file_header.frame_count) { + if (stopBecauseAtTheEnd(state)) return; + } + + const bool forceRender = state.frame_position_dirty; + if (!forceRender && state.next_time != 0 && + (int32_t)(state.now - state.next_time) < 0) { + return; + } + + alignFrameToLocalTime(state, state.now); + playNextRealtimeFrame(state); + if (!useMainSegmentOnly) strip.show(); + else strip.trigger(); +} + +void FSEQPlayer::syncPlayback(float secondsElapsed) { + PlaybackState &state = realtimeState; + + if (!isStatePlaying(state)) { + DEBUG_PRINTLN("[FSEQ] Sync: Playback not active, cannot sync."); + return; + } + + if (state.file_header.step_time == 0 || state.file_header.frame_count == 0) { + DEBUG_PRINTLN("[FSEQ] Sync: Invalid timing info."); + return; + } + + const float stepMs = (float)state.file_header.step_time; + const uint32_t now = millis(); + + float targetMs = secondsElapsed * 1000.0f; + const float maxMs = (float)(state.file_header.frame_count - 1U) * stepMs; + targetMs = constrain(targetMs, 0.0f, maxMs); + state.secondsElapsed = targetMs / 1000.0f; + + bool timingAdjusted = false; + if (state.playback_start_time == 0 && (state.frame != 0 || state.secondsElapsed > 0.0f)) { + const uint32_t targetMsU32 = (uint32_t)targetMs; + state.playback_start_time = now - targetMsU32; + timingAdjusted = true; + } + + const float localMs = (float)(uint32_t)(now - state.playback_start_time); + const float errorMs = targetMs - localMs; // >0 = wir sind hinten + + if (fabsf(errorMs) >= 150.0f) { + uint32_t targetFrame = (uint32_t)(targetMs / stepMs); + if (targetFrame >= state.file_header.frame_count) { + targetFrame = state.file_header.frame_count - 1U; + } + + state.frame = targetFrame; + state.frame_data_offset = + state.file_header.channel_data_offset + + ((uint32_t)state.file_header.channel_count * state.frame); + state.frame_position_dirty = true; + + const uint32_t targetMsU32 = (uint32_t)targetMs; + state.playback_start_time = now - targetMsU32; + state.syncErrorFilteredMs = 0.0f; + state.syncSlewMs = 0.0f; + state.syncCarryMs = 0.0f; + scheduleNextFrame(state); + + DEBUG_PRINTF("[FSEQ] HARD Sync -> frame=%lu err=%.2fms\n", + (unsigned long)targetFrame, errorMs); + return; + } + + if (fabsf(errorMs) < 1.0f) { + state.syncErrorFilteredMs *= 0.92f; + state.syncSlewMs *= 0.85f; + return; + } + + state.syncErrorFilteredMs = + state.syncErrorFilteredMs * 0.92f + errorMs * 0.08f; + + float desiredSlewMs = state.syncErrorFilteredMs * 0.02f; + desiredSlewMs = constrain(desiredSlewMs, -0.75f, 0.75f); + + state.syncSlewMs = state.syncSlewMs * 0.85f + desiredSlewMs * 0.15f; + state.syncCarryMs += state.syncSlewMs; + + int32_t adjustMs = 0; + if (state.syncCarryMs >= 1.0f) { + adjustMs = (int32_t)floorf(state.syncCarryMs); + } else if (state.syncCarryMs <= -1.0f) { + adjustMs = (int32_t)ceilf(state.syncCarryMs); + } + state.syncCarryMs -= (float)adjustMs; + + if (adjustMs != 0) { + state.playback_start_time = (uint32_t)(state.playback_start_time - adjustMs); + timingAdjusted = true; + } + + if (timingAdjusted) { + alignFrameToLocalTime(state, now); + } + + if ((uint32_t)(now - gLastSoftSyncDebugMs) >= FSEQ_SYNC_DEBUG_INTERVAL_MS) { + gLastSoftSyncDebugMs = now; + DEBUG_PRINTF("[FSEQ] Soft Sync err=%.2fms filt=%.2fms slew=%.3fms adj=%ldms\n", + errorMs, + state.syncErrorFilteredMs, + state.syncSlewMs, + (long)adjustMs); + } +} + diff --git a/usermods/FSEQ/fseq_player.h b/usermods/FSEQ/fseq_player.h new file mode 100644 index 0000000000..fbc7968c59 --- /dev/null +++ b/usermods/FSEQ/fseq_player.h @@ -0,0 +1,106 @@ +#ifndef FSEQ_PLAYER_H +#define FSEQ_PLAYER_H + +static constexpr int16_t RECORDING_REPEAT_LOOP = -1; +static constexpr int16_t RECORDING_REPEAT_DEFAULT = 0; + +#include "wled.h" +#include "sd_adapter_compat.h" + +class FSEQPlayer { +public: + struct FileHeader { + uint8_t identifier[4]; + uint16_t channel_data_offset; + uint8_t minor_version; + uint8_t major_version; + uint16_t header_length; + uint32_t channel_count; + uint32_t frame_count; + uint8_t step_time; + uint8_t flags; + }; + + static void loadRecordingForSegment(uint8_t segmentId, const char *filepath, + float secondsElapsed = 0.0f, + bool loop = false); + static void clearSegmentPlayback(uint8_t segmentId); + static void setSegmentLooping(uint8_t segmentId, bool loop); + static bool isSegmentPlaying(uint8_t segmentId); + static bool isAnySegmentPlaying(); + static bool isAnyPlaybackActive(); + static void renderSegmentFrame(uint8_t segmentId, Segment &segment); + + static void loadRecording(const char *filepath, + float secondsElapsed = 0.0f, + bool loop = false); + static void clearLastPlayback(); + static void syncPlayback(float secondsElapsed); + static bool isPlaying(); + static void setLooping(bool loop); + static String getFileName(); + static float getElapsedSeconds(); + static void renderRealtimeFrame(); + +private: + FSEQPlayer() {} + + struct PlaybackState { + File recordingFile; + String currentFileName = ""; + float secondsElapsed = 0.0f; + int32_t recordingRepeats = RECORDING_REPEAT_DEFAULT; + uint32_t now = 0; + uint32_t next_time = 0; // scheduler only + uint32_t playback_start_time = 0; + uint32_t frame = 0; + uint32_t file_size = 0; + uint32_t frame_data_offset = 0; + bool frame_position_dirty = true; + FileHeader file_header{}; + + float syncErrorFilteredMs = 0.0f; + float syncSlewMs = 0.0f; + float syncCarryMs = 0.0f; + }; + + static const int FSEQ_DEFAULT_STEP_TIME = 50; + static PlaybackState realtimeState; + static PlaybackState segmentStates[MAX_NUM_SEGMENTS]; + + static inline uint32_t readUInt32(File &file); + static inline uint32_t readUInt24(File &file); + static inline uint16_t readUInt16(File &file); + static inline uint8_t readUInt8(File &file); + + static bool fileOnSD(const char *filepath); + static bool fileOnFS(const char *filepath); + static void printHeaderInfo(const PlaybackState &state); + static void processFrameDataForSegment(PlaybackState &state, Segment &segment); + static void processFrameDataRealtime(PlaybackState &state); + static bool stopBecauseAtTheEnd(PlaybackState &state); + static bool ensureFrameDataPosition(PlaybackState &state); + static bool skipRemainingFrameData(PlaybackState &state, uint32_t bytesToSkip); + static void scheduleNextFrame(PlaybackState &state); + static void resetTimingFromCurrentFrame(PlaybackState &state, uint32_t now); + static void alignFrameToLocalTime(PlaybackState &state, uint32_t now); + static void playNextRecordingFrameForSegment(PlaybackState &state, Segment &segment); + static void playNextRealtimeFrame(PlaybackState &state); + static void loadRecordingIntoState(PlaybackState &state, const char *filepath, + float secondsElapsed, bool loop); + static void clearPlaybackState(PlaybackState &state); + static bool isStatePlaying(const PlaybackState &state); + static void setStateLooping(PlaybackState &state, bool loop); + static float getElapsedSeconds(const PlaybackState &state); +}; + +uint16_t FSEQ_refreshFileIndexCache(); +uint16_t FSEQ_getFileIndexCount(); +bool FSEQ_getFileNameByIndex(uint16_t index, String &outName); +int16_t FSEQ_findFileIndexByName(const String &name); +void FSEQ_invalidateFileIndexCache(); +void FSEQ_markFppControlActivity(); +void FSEQ_clearFppOverride(); +bool FSEQ_isFppOverrideActive(); + +#endif // FSEQ_PLAYER_H diff --git a/usermods/FSEQ/library.json b/usermods/FSEQ/library.json new file mode 100644 index 0000000000..9c01b63176 --- /dev/null +++ b/usermods/FSEQ/library.json @@ -0,0 +1,7 @@ +{ + "name": "FSEQ", + "build": {"libArchive": false}, + "dependencies": { + "bitbank2/unzipLIB":"1.0.0" + } +} \ No newline at end of file diff --git a/usermods/FSEQ/register_usermod.cpp b/usermods/FSEQ/register_usermod.cpp new file mode 100644 index 0000000000..af65e7f308 --- /dev/null +++ b/usermods/FSEQ/register_usermod.cpp @@ -0,0 +1,9 @@ +#include "usermod_fpp.h" +#include "usermod_fseq.h" +#include "wled.h" + +UsermodFseq usermodFseq; +REGISTER_USERMOD(usermodFseq); + +UsermodFPP usermodFpp; +REGISTER_USERMOD(usermodFpp); diff --git a/usermods/FSEQ/sd_adapter_compat.h b/usermods/FSEQ/sd_adapter_compat.h new file mode 100644 index 0000000000..3737e86dac --- /dev/null +++ b/usermods/FSEQ/sd_adapter_compat.h @@ -0,0 +1,105 @@ +#pragma once + +#include "wled.h" + +#if defined(WLED_USE_SD_SPI) + #include + #include +#elif defined(WLED_USE_SD_MMC) + #include +#endif + +#ifndef CARD_NONE +#define CARD_NONE 0 +#endif + +class FSEQSdAdapterCompat { +public: + uint8_t cardType() const { + #if defined(WLED_USE_SD_SPI) || defined(WLED_USE_SD_MMC) + return backend().cardType(); + #else + return CARD_NONE; + #endif + } + + bool exists(const char* path) const { + #if defined(WLED_USE_SD_SPI) || defined(WLED_USE_SD_MMC) + return backend().exists(path); + #else + (void)path; + return false; + #endif + } + + bool exists(const String& path) const { + return exists(path.c_str()); + } + + File open(const char* path, const char* mode = FILE_READ) const { + #if defined(WLED_USE_SD_SPI) || defined(WLED_USE_SD_MMC) + return backend().open(path, mode); + #else + (void)path; + (void)mode; + return File(); + #endif + } + + File open(const String& path, const char* mode = FILE_READ) const { + return open(path.c_str(), mode); + } + + bool remove(const char* path) const { + #if defined(WLED_USE_SD_SPI) || defined(WLED_USE_SD_MMC) + return backend().remove(path); + #else + (void)path; + return false; + #endif + } + + bool remove(const String& path) const { + return remove(path.c_str()); + } + + uint64_t totalBytes() const { + #if defined(WLED_USE_SD_SPI) || defined(WLED_USE_SD_MMC) + return backend().totalBytes(); + #else + return 0; + #endif + } + + uint64_t usedBytes() const { + #if defined(WLED_USE_SD_SPI) || defined(WLED_USE_SD_MMC) + return backend().usedBytes(); + #else + return 0; + #endif + } + + bool available() const { + #if defined(WLED_USE_SD_SPI) || defined(WLED_USE_SD_MMC) + return cardType() != CARD_NONE; + #else + return false; + #endif + } + +private: +#if defined(WLED_USE_SD_SPI) + static decltype(SD)& backend() { return SD; } +#elif defined(WLED_USE_SD_MMC) + static decltype(SD_MMC)& backend() { return SD_MMC; } +#endif +}; + +inline const FSEQSdAdapterCompat& fseqSdAdapter() { + static const FSEQSdAdapterCompat instance; + return instance; +} + +#ifndef SD_ADAPTER + #define SD_ADAPTER fseqSdAdapter() +#endif diff --git a/usermods/FSEQ/usermod_fpp.h b/usermods/FSEQ/usermod_fpp.h new file mode 100644 index 0000000000..c329d7f291 --- /dev/null +++ b/usermods/FSEQ/usermod_fpp.h @@ -0,0 +1,1155 @@ +#pragma once + +#include "usermod_fseq.h" +#include "xlz_unzip.h" +#include "wled.h" +#include "sd_adapter_compat.h" + +#include +#include + +uint16_t FSEQ_refreshFileIndexCache(); +int16_t FSEQ_findFileIndexByName(const String &name); +void FSEQ_markFppControlActivity(); +void FSEQ_clearFppOverride(); +bool FSEQ_isFppOverrideActive(); +void FSEQ_invalidateFileIndexCache(); + +class UsermodFPP; + +inline UsermodFPP *&FPP_usermodInstance() { + static UsermodFPP *instance = nullptr; + return instance; +} + +inline void FPP_write16(uint8_t *buffer, uint16_t value) { + buffer[0] = value & 0xFF; + buffer[1] = (value >> 8) & 0xFF; +} + +inline void FPP_write32(uint8_t *buffer, uint32_t value) { + buffer[0] = value & 0xFF; + buffer[1] = (value >> 8) & 0xFF; + buffer[2] = (value >> 16) & 0xFF; + buffer[3] = (value >> 24) & 0xFF; +} + +bool FPP_sendEffectSyncMessage(uint8_t action, const String &fileName, + uint32_t currentFrame, float secondsElapsed, + bool sendMulticast, bool sendBroadcast); + +class WriteBufferingStream : public Stream { +public: + WriteBufferingStream(Stream &upstream, size_t capacity) + : _upstream(upstream) { + _capacity = capacity; + _buffer = (uint8_t *)malloc(capacity); + _offset = 0; + if (!_buffer) { + DEBUG_PRINTLN(F("[WBS] ERROR: Buffer allocation failed")); + } + } + ~WriteBufferingStream() { + flush(); + if (_buffer) free(_buffer); + } + + size_t write(const uint8_t *buffer, size_t size) override { + if (!_buffer || _writeError) return 0; + + size_t total = 0; + while (size > 0) { + size_t space = _capacity - _offset; + size_t toCopy = (size < space) ? size : space; + memcpy(_buffer + _offset, buffer, toCopy); + _offset += toCopy; + buffer += toCopy; + size -= toCopy; + total += toCopy; + + if (_offset == _capacity && !flushBuffer()) { + _writeError = true; + break; + } + } + + return total; + } + + size_t write(uint8_t b) override { return write(&b, 1); } + + void flush() override { + if (!flushBuffer()) { + _writeError = true; + } + _upstream.flush(); + } + + bool hasWriteError() const { return _writeError; } + + int available() override { return _upstream.available(); } + int read() override { return _upstream.read(); } + int peek() override { return _upstream.peek(); } + +private: + bool flushBuffer() { + if (_offset == 0) return true; + + const size_t written = _upstream.write(_buffer, _offset); + if (written != _offset) { + DEBUG_PRINTF("[WBS] ERROR: Short SD write: %u/%u\n", + (unsigned)written, (unsigned)_offset); + _offset = 0; + return false; + } + + _offset = 0; + return true; + } + + Stream &_upstream; + uint8_t *_buffer = nullptr; + size_t _capacity = 0; + size_t _offset = 0; + bool _writeError = false; +}; + +#define FILE_UPLOAD_BUFFER_SIZE 8192 +#define CTRL_PKT_SYNC 1 +#define CTRL_PKT_PING 4 +#define CTRL_PKT_BLANK 3 + +class UsermodFPP : public Usermod { +private: + friend bool FPP_sendEffectSyncMessage(uint8_t action, const String &fileName, + uint32_t currentFrame, float secondsElapsed, + bool sendMulticast, bool sendBroadcast); + AsyncUDP udpListen; + AsyncUDP udpMulticast; + bool udpListenStarted = false; + bool udpMulticastStarted = false; + bool udpStarted = false; + const IPAddress multicastAddr = IPAddress(239, 70, 80, 80); + const uint16_t udpPort = 32320; + unsigned long lastPingTime = 0; + const unsigned long pingInterval = 30000; + bool announceBurstActive = false; + uint8_t announceBurstRemaining = 0; + unsigned long lastAnnounceBurstTime = 0; + const unsigned long announceBurstInterval = 1000; + wl_status_t lastWiFiStatus = WL_IDLE_STATUS; + + File currentUploadFile; + String currentUploadFileName = ""; + unsigned long uploadStartTime = 0; + WriteBufferingStream *uploadStream = nullptr; + const unsigned long uploadInactivityTimeout = 60000; + + bool xlzChecked = false; + unsigned long xlzStartTime = 0; + bool uploadSessionActive = false; + bool xlzPendingScan = false; + bool xlzProcessing = false; + unsigned long lastUploadActivity = 0; + unsigned long lastUploadFinished = 0; + + enum PendingCommandType : uint8_t { + PENDING_NONE = 0, + PENDING_START, + PENDING_STOP, + PENDING_SYNC, + PENDING_BLANK + }; + + portMUX_TYPE fppMux = portMUX_INITIALIZER_UNLOCKED; + volatile PendingCommandType pendingCommand = PENDING_NONE; + volatile bool pendingPingReply = false; + char pendingFileName[65] = {0}; + float pendingSecondsElapsed = 0.0f; + IPAddress lastFppSenderIP = IPAddress(0, 0, 0, 0); + IPAddress pendingPingReplyIP = IPAddress(0, 0, 0, 0); + + float lastFppSyncSeconds = 0.0f; + uint32_t lastFppSyncMillis = 0; + float lastStatusElapsedSeconds = 0.0f; + uint32_t lastStatusElapsedMillis = 0; + char lastSequenceName[65] = {0}; + + struct FppStatusSnapshot { + bool playbackActive = false; + char sequenceName[65] = {0}; + float elapsedSeconds = 0.0f; + uint32_t updatedMillis = 0; + }; + + FppStatusSnapshot statusSnapshot; + + String getDeviceName() { return String(serverDescription); } + + void copyNormalizedSequenceName(const char *fileName, char *dest, size_t destSize) { + if (!dest || destSize == 0) return; + memset(dest, 0, destSize); + if (!fileName || fileName[0] == '\0') return; + + const char *normalized = (fileName[0] == '/') ? fileName + 1 : fileName; + const size_t len = strnlen(normalized, destSize - 1); + memcpy(dest, normalized, len); + } + + void cacheSequenceNameLoopOnly(const char *fileName) { + copyNormalizedSequenceName(fileName, lastSequenceName, sizeof(lastSequenceName)); + } + + void rememberSyncProgressLoopOnly(const char *fileName, float secondsElapsed) { + if (fileName && fileName[0] != '\0') { + cacheSequenceNameLoopOnly(fileName); + } + + lastFppSyncSeconds = secondsElapsed; + if (lastFppSyncSeconds < 0.0f) lastFppSyncSeconds = 0.0f; + lastFppSyncMillis = millis(); + } + + void clearPlaybackStatusCacheLoopOnly(bool clearSequenceName = false) { + lastFppSyncSeconds = 0.0f; + lastFppSyncMillis = 0; + lastStatusElapsedSeconds = 0.0f; + lastStatusElapsedMillis = 0; + if (clearSequenceName) { + memset(lastSequenceName, 0, sizeof(lastSequenceName)); + } + } + + float getStableStatusElapsedSecondsLoopOnly() { + const uint32_t now = millis(); + float elapsed = FSEQPlayer::getElapsedSeconds(); + + if (elapsed <= 0.0f && lastFppSyncMillis != 0) { + const float syncElapsed = + lastFppSyncSeconds + ((float)(now - lastFppSyncMillis) / 1000.0f); + if (syncElapsed > elapsed) elapsed = syncElapsed; + } + + if (elapsed <= 0.0f && lastStatusElapsedMillis != 0) { + const float heldElapsed = + lastStatusElapsedSeconds + ((float)(now - lastStatusElapsedMillis) / 1000.0f); + if (heldElapsed > elapsed) elapsed = heldElapsed; + } + + if (elapsed > 0.0f) { + lastStatusElapsedSeconds = elapsed; + lastStatusElapsedMillis = now; + } + + return (elapsed > 0.0f) ? elapsed : 0.0f; + } + + void publishStatusSnapshotLoopOnly(bool playbackActive, + const char *fileName, + float elapsedSeconds) { + FppStatusSnapshot next; + next.playbackActive = playbackActive; + copyNormalizedSequenceName(fileName, next.sequenceName, sizeof(next.sequenceName)); + next.elapsedSeconds = elapsedSeconds > 0.0f ? elapsedSeconds : 0.0f; + next.updatedMillis = millis(); + + portENTER_CRITICAL(&fppMux); + statusSnapshot = next; + portEXIT_CRITICAL(&fppMux); + } + + FppStatusSnapshot getStatusSnapshot() { + FppStatusSnapshot copy; + portENTER_CRITICAL(&fppMux); + copy = statusSnapshot; + portEXIT_CRITICAL(&fppMux); + return copy; + } + + IPAddress getLastFppSenderIPSnapshot() { + IPAddress copy; + portENTER_CRITICAL(&fppMux); + copy = lastFppSenderIP; + portEXIT_CRITICAL(&fppMux); + return copy; + } + + void updatePlaybackStatusSnapshotLoopOnly() { + const bool playbackActive = FSEQPlayer::isPlaying() || + FSEQ_isFppOverrideActive() || + (realtimeMode == REALTIME_MODE_FSEQ && + lastSequenceName[0] != '\0'); + + if (!playbackActive) { + publishStatusSnapshotLoopOnly(false, nullptr, 0.0f); + return; + } + + const String activeFileName = FSEQPlayer::getFileName(); + if (activeFileName.length() > 0) { + cacheSequenceNameLoopOnly(activeFileName.c_str()); + } + + const float elapsed = getStableStatusElapsedSecondsLoopOnly(); + publishStatusSnapshotLoopOnly(true, lastSequenceName, elapsed); + } + + String buildSystemInfoJSON() { + DynamicJsonDocument doc(1024); + String devName = getDeviceName(); + String id = "WLED-" + WiFi.macAddress(); + id.replace(":", ""); + doc["HostName"] = id; + doc["HostDescription"] = devName; + doc["Platform"] = "ESP32"; + doc["Variant"] = "WLED"; + doc["Mode"] = "remote"; + doc["Version"] = versionString; + uint16_t major = 0, minor = 0; + String ver = versionString; + int dashPos = ver.indexOf('-'); + if (dashPos > 0) ver = ver.substring(0, dashPos); + int dotPos = ver.indexOf('.'); + if (dotPos > 0) { + major = ver.substring(0, dotPos).toInt(); + minor = ver.substring(dotPos + 1).toInt(); + } else { + major = ver.toInt(); + } + doc["majorVersion"] = major; + doc["minorVersion"] = minor; + doc["typeId"] = 195; + doc["UUID"] = WiFi.macAddress(); + doc["zip"] = true; + JsonObject utilization = doc.createNestedObject("Utilization"); + utilization["MemoryFree"] = ESP.getFreeHeap(); + utilization["Uptime"] = millis(); + doc["rssi"] = WiFi.RSSI(); + JsonArray ips = doc.createNestedArray("IPS"); + ips.add(WiFi.localIP().toString()); + String json; + serializeJson(doc, json); + return json; + } + + String buildSystemStatusJSON() { + DynamicJsonDocument doc(2048); + JsonObject mqtt = doc.createNestedObject("MQTT"); + mqtt["configured"] = false; + mqtt["connected"] = false; + + JsonObject currentPlaylist = doc.createNestedObject("current_playlist"); + currentPlaylist["count"] = "0"; + currentPlaylist["description"] = ""; + currentPlaylist["index"] = "0"; + currentPlaylist["playlist"] = ""; + currentPlaylist["type"] = ""; + + doc["volume"] = 70; + doc["media_filename"] = ""; + doc["fppd"] = "running"; + doc["current_song"] = ""; + + const FppStatusSnapshot snapshot = getStatusSnapshot(); + + if (snapshot.playbackActive) { + String fileName = String(snapshot.sequenceName); + const uint32_t elapsed = (uint32_t)snapshot.elapsedSeconds; + doc["current_sequence"] = fileName; + doc["playlist"] = ""; + doc["seconds_elapsed"] = String(elapsed); + doc["seconds_played"] = String(elapsed); + doc["seconds_remaining"] = "0"; + doc["sequence_filename"] = fileName; + uint32_t mins = elapsed / 60; + uint32_t secs = elapsed % 60; + char timeStr[16]; + snprintf(timeStr, sizeof(timeStr), "%02u:%02u", mins, secs); + doc["time_elapsed"] = timeStr; + doc["time_remaining"] = "00:00"; + doc["status"] = 1; + doc["status_name"] = "playing"; + doc["mode"] = 8; + doc["mode_name"] = "remote"; + } else { + doc["current_sequence"] = ""; + doc["playlist"] = ""; + doc["seconds_elapsed"] = "0"; + doc["seconds_played"] = "0"; + doc["seconds_remaining"] = "0"; + doc["sequence_filename"] = ""; + doc["time_elapsed"] = "00:00"; + doc["time_remaining"] = "00:00"; + doc["status"] = 0; + doc["status_name"] = "idle"; + doc["mode"] = 8; + doc["mode_name"] = "remote"; + } + + JsonObject adv = doc.createNestedObject("advancedView"); + String devName = getDeviceName(); + String id = "WLED-" + WiFi.macAddress(); + id.replace(":", ""); + adv["HostName"] = id; + adv["HostDescription"] = devName; + adv["Platform"] = "WLED"; + adv["Variant"] = "ESP32"; + adv["Mode"] = "remote"; + adv["Version"] = versionString; + uint16_t major = 0; + uint16_t minor = 0; + String ver = versionString; + int dashPos = ver.indexOf('-'); + if (dashPos > 0) ver = ver.substring(0, dashPos); + int dotPos = ver.indexOf('.'); + if (dotPos > 0) { + major = ver.substring(0, dotPos).toInt(); + minor = ver.substring(dotPos + 1).toInt(); + } else { + major = ver.toInt(); + } + adv["majorVersion"] = major; + adv["minorVersion"] = minor; + adv["typeId"] = 195; + adv["UUID"] = WiFi.macAddress(); + JsonObject util = adv.createNestedObject("Utilization"); + util["MemoryFree"] = ESP.getFreeHeap(); + util["Uptime"] = millis(); + adv["rssi"] = WiFi.RSSI(); + JsonArray ips = adv.createNestedArray("IPS"); + ips.add(WiFi.localIP().toString()); + String json; + serializeJson(doc, json); + return json; + } + + String buildFppdMultiSyncSystemsJSON() { + DynamicJsonDocument doc(1024); + JsonArray systems = doc.createNestedArray("systems"); + JsonObject sys = systems.createNestedObject(); + String devName = getDeviceName(); + String id = "WLED-" + WiFi.macAddress(); + id.replace(":", ""); + sys["hostname"] = devName; + sys["id"] = id; + sys["ip"] = WiFi.localIP().toString(); + sys["version"] = versionString; + sys["hardwareType"] = "WLED"; + sys["type"] = 195; + sys["num_chan"] = strip.getLength() * 3; + sys["NumPixelPort"] = 1; + sys["NumSerialPort"] = 0; + sys["mode"] = "remote"; + String json; + serializeJson(doc, json); + return json; + } + + IPAddress getBroadcastAddress() { + IPAddress ip = WiFi.localIP(); + IPAddress mask = WiFi.subnetMask(); + IPAddress broadcast; + for (uint8_t i = 0; i < 4; i++) { + broadcast[i] = (ip[i] & mask[i]) | (~mask[i] & 0xFF); + } + return broadcast; + } + + AsyncUDP *getTxUdp() { + if (udpListenStarted) return &udpListen; + if (udpMulticastStarted) return &udpMulticast; + return nullptr; + } + + bool sendUdpPacket(const uint8_t *data, size_t len, const IPAddress &destination) { + AsyncUDP *txUdp = getTxUdp(); + return txUdp ? txUdp->writeTo(data, len, destination, udpPort) : false; + } + + void stopUdpListeners() { + if (udpListenStarted) udpListen.close(); + if (udpMulticastStarted) udpMulticast.close(); + udpListenStarted = false; + udpMulticastStarted = false; + udpStarted = false; + } + + void startUdpIfNeeded() { + if (udpStarted || WiFi.status() != WL_CONNECTED) return; + + stopUdpListeners(); + + udpListenStarted = udpListen.listen(udpPort); + if (udpListenStarted) { + udpListen.onPacket([this](AsyncUDPPacket packet) { processUdpPacket(packet); }); + DEBUG_PRINTF("[FPP] UDP listener started on port %u\n", udpPort); + } else { + DEBUG_PRINTF("[FPP] UDP listener on port %u failed\n", udpPort); + } + + udpMulticastStarted = udpMulticast.listenMulticast(multicastAddr, udpPort); + if (udpMulticastStarted) { + udpMulticast.onPacket([this](AsyncUDPPacket packet) { processUdpPacket(packet); }); + DEBUG_PRINTF("[FPP] UDP multicast listener started on %s:%u\n", + multicastAddr.toString().c_str(), udpPort); + } else { + DEBUG_PRINTF("[FPP] UDP multicast listener failed on %s:%u\n", + multicastAddr.toString().c_str(), udpPort); + } + + udpStarted = udpListenStarted || udpMulticastStarted; + if (!udpStarted) { + DEBUG_PRINTLN(F("[FPP] Failed to start UDP listeners")); + return; + } + + announceBurstActive = true; + announceBurstRemaining = 5; + lastAnnounceBurstTime = 0; + lastPingTime = 0; + DEBUG_PRINTLN(F("[FPP] Discovery listeners active")); + } + + bool sendSyncMessage(uint8_t action, const String &fileName, + uint32_t currentFrame, float secondsElapsed, + bool sendMulticast, bool sendBroadcast) { + if (!udpStarted || WiFi.status() != WL_CONNECTED) return false; + if (!sendMulticast && !sendBroadcast) return false; + + char filename[65]; + memset(filename, 0, sizeof(filename)); + const String normalized = fileName.startsWith("/") ? fileName.substring(1) : fileName; + const size_t fileLen = min(strlen(normalized.c_str()), sizeof(filename) - 1); + if (fileLen > 0) { + memcpy(filename, normalized.c_str(), fileLen); + filename[fileLen] = '\0'; + } + + const uint16_t payloadLen = 10 + (uint16_t)fileLen + 1; + const size_t packetLen = 7 + payloadLen; + uint8_t packet[7 + 10 + 65]; + memset(packet, 0, sizeof(packet)); + + packet[0] = 'F'; + packet[1] = 'P'; + packet[2] = 'P'; + packet[3] = 'D'; + packet[4] = CTRL_PKT_SYNC; + FPP_write16(packet + 5, payloadLen); + packet[7] = action; + packet[8] = 0x00; + FPP_write32(packet + 9, currentFrame); + memcpy(packet + 13, &secondsElapsed, sizeof(secondsElapsed)); + memcpy(packet + 17, filename, fileLen); + packet[17 + fileLen] = '\0'; + + bool sent = false; + if (sendBroadcast) { + const IPAddress subnetBroadcast = getBroadcastAddress(); + const IPAddress globalBroadcast(255, 255, 255, 255); + sent |= sendUdpPacket(packet, packetLen, subnetBroadcast); + sent |= sendUdpPacket(packet, packetLen, globalBroadcast); + } + if (sendMulticast) { + sent |= sendUdpPacket(packet, packetLen, multicastAddr); + } + + DEBUG_PRINTF("[FPP] Sync %u file=%s sec=%.3f mc=%u bc=%u\n", + action, filename, secondsElapsed, + sendMulticast ? 1 : 0, + sendBroadcast ? 1 : 0); + return sent; + } + + void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { + uint8_t buf[301]; + memset(buf, 0, sizeof(buf)); + buf[0] = 'F'; buf[1] = 'P'; buf[2] = 'P'; buf[3] = 'D'; + buf[4] = 0x04; + uint16_t dataLen = 294; + buf[5] = dataLen & 0xFF; + buf[6] = (dataLen >> 8) & 0xFF; + buf[7] = 0x03; + buf[8] = 0x00; + buf[9] = 0xC3; + uint16_t versionMajor = 0, versionMinor = 0; + String ver = versionString; + int dashPos = ver.indexOf('-'); + if (dashPos > 0) ver = ver.substring(0, dashPos); + int dotPos = ver.indexOf('.'); + if (dotPos > 0) { + versionMajor = ver.substring(0, dotPos).toInt(); + versionMinor = ver.substring(dotPos + 1).toInt(); + } + buf[10] = (versionMajor >> 8) & 0xFF; + buf[11] = versionMajor & 0xFF; + buf[12] = (versionMinor >> 8) & 0xFF; + buf[13] = versionMinor & 0xFF; + buf[14] = 0x08; + IPAddress ip = WiFi.localIP(); + buf[15] = ip[0]; buf[16] = ip[1]; buf[17] = ip[2]; buf[18] = ip[3]; + String id = "WLED-" + WiFi.macAddress(); + id.replace(":", ""); + if (id.length() > 64) id = id.substring(0, 64); + for (int i = 0; i < 64; i++) buf[19 + i] = (i < id.length()) ? id[i] : 0; + String verStr = versionString; + for (int i = 0; i < 40; i++) buf[84 + i] = (i < verStr.length()) ? verStr[i] : 0; + String hwType = "WLED"; + for (int i = 0; i < 40; i++) buf[125 + i] = (i < hwType.length()) ? hwType[i] : 0; + for (int i = 0; i < 120; i++) buf[166 + i] = 0; + bool ok = sendUdpPacket(buf, sizeof(buf), destination); + DEBUG_PRINTF("[FPP] Ping %s -> %s:%u len=%u\n", + ok ? "sent" : "FAILED", + destination.toString().c_str(), + udpPort, + sizeof(buf)); + } + + void sendDiscoveryBurst() { + IPAddress subnetBroadcast = getBroadcastAddress(); + //IPAddress globalBroadcast(255, 255, 255, 255); + + sendPingPacket(subnetBroadcast); + //sendPingPacket(globalBroadcast); + } + + void queuePendingCommand(PendingCommandType cmd, + const char *fileName = nullptr, + float seconds = 0.0f, + IPAddress senderIP = IPAddress(0, 0, 0, 0)) { + portENTER_CRITICAL(&fppMux); + + const bool newHasFile = (fileName && fileName[0] != '\0'); + + // Keep a pending START alive until loop() has processed it. + // If more START/SYNC packets arrive before then, only refresh the target + // time/IP and optionally the filename. + if (pendingCommand == PENDING_START && + (cmd == PENDING_START || cmd == PENDING_SYNC)) { + pendingSecondsElapsed = seconds; + lastFppSenderIP = senderIP; + + if (newHasFile) { + memset(pendingFileName, 0, sizeof(pendingFileName)); + const size_t len = strnlen(fileName, sizeof(pendingFileName) - 1); + memcpy(pendingFileName, fileName, len); + } + + portEXIT_CRITICAL(&fppMux); + return; + } + + pendingCommand = cmd; + pendingSecondsElapsed = seconds; + lastFppSenderIP = senderIP; + + if (cmd == PENDING_STOP || cmd == PENDING_BLANK) { + memset(pendingFileName, 0, sizeof(pendingFileName)); + } else if (newHasFile) { + memset(pendingFileName, 0, sizeof(pendingFileName)); + const size_t len = strnlen(fileName, sizeof(pendingFileName) - 1); + memcpy(pendingFileName, fileName, len); + } else if (cmd == PENDING_START) { + // Never reuse an old filename for an explicit START if none was supplied. + memset(pendingFileName, 0, sizeof(pendingFileName)); + } + // For a SYNC without filename we intentionally keep the most recent pending + // filename so a late join can still start from the correct sequence. + + portEXIT_CRITICAL(&fppMux); + } + + void queuePendingPingReply(IPAddress senderIP) { + portENTER_CRITICAL(&fppMux); + pendingPingReplyIP = senderIP; + pendingPingReply = true; + portEXIT_CRITICAL(&fppMux); + } + + bool isUnsafeUploadPath(const String &path) { + if (path.length() == 0) return true; + if (path.indexOf("..") >= 0) return true; + if (path.indexOf('\\') >= 0) return true; + return false; + } + + String normalizeUploadPath(String path) { + path.trim(); + if (!path.startsWith("/")) path = "/" + path; + return path; + } + + void cleanupFppUploadState(bool removePartialFile) { + const String partialFile = currentUploadFileName; + + if (uploadStream) { + delete uploadStream; + uploadStream = nullptr; + } + + if (currentUploadFile) { + currentUploadFile.close(); + } + + if (removePartialFile && partialFile.length() > 0) { + SD_ADAPTER.remove(partialFile.c_str()); + DEBUG_PRINTF("[FPP] Removed partial upload: %s\n", partialFile.c_str()); + } + + currentUploadFile = File(); + currentUploadFileName = ""; + uploadStartTime = 0; + lastUploadActivity = 0; + uploadSessionActive = false; + xlzPendingScan = false; + } + + void cleanupStaleFppUploadIfNeeded() { + if (!uploadStream && !currentUploadFile) return; + if (lastUploadActivity == 0) return; + if (millis() - lastUploadActivity < uploadInactivityTimeout) return; + + DEBUG_PRINTLN(F("[FPP] Upload timed out; cleaning partial state")); + cleanupFppUploadState(true); + } + + void finishSuccessfulFppUpload(const String &uploadedFile) { + String lowerName = uploadedFile; + lowerName.toLowerCase(); + + lastUploadFinished = millis(); + lastUploadActivity = lastUploadFinished; + currentUploadFileName = ""; + currentUploadFile = File(); + uploadStartTime = 0; + + if (lowerName.endsWith(".xlz")) { + xlzPendingScan = true; + uploadSessionActive = true; + } else { + FSEQ_invalidateFileIndexCache(); + uploadSessionActive = false; + } + } + + void processUdpPacket(AsyncUDPPacket packet) { + if (packet.length() < 7) return; + if (WiFi.status() == WL_CONNECTED && packet.remoteIP() == WiFi.localIP()) return; + if (packet.data()[0] != 'F' || packet.data()[1] != 'P' || + packet.data()[2] != 'P' || packet.data()[3] != 'D') return; + + uint8_t packetType = packet.data()[4]; + switch (packetType) { + case CTRL_PKT_SYNC: { + const uint16_t extraDataLen = + (uint16_t)packet.data()[5] | ((uint16_t)packet.data()[6] << 8); + const size_t payloadLen = packet.length() - 7; + if (payloadLen < 10) { + DEBUG_PRINTLN(F("[FPP] Sync packet too short, ignoring")); + break; + } + if (extraDataLen != 0 && payloadLen < extraDataLen) { + DEBUG_PRINTLN(F("[FPP] Sync packet truncated, ignoring")); + break; + } + + const uint8_t syncAction = packet.data()[7]; + const uint8_t syncType = packet.data()[8]; + if (syncType != 0x00) { + break; + } + + float secondsElapsed = 0.0f; + memcpy(&secondsElapsed, packet.data() + 13, sizeof(secondsElapsed)); + + const size_t filenameOffset = 17; + const size_t filenameBytes = packet.length() > filenameOffset + ? (packet.length() - filenameOffset) + : 0; + const size_t copyLen = min((size_t)64, filenameBytes); + char safeFilename[65]; + memset(safeFilename, 0, sizeof(safeFilename)); + if (copyLen > 0) { + memcpy(safeFilename, packet.data() + filenameOffset, copyLen); + safeFilename[copyLen] = '\0'; + const size_t realLen = strnlen(safeFilename, copyLen); + safeFilename[realLen] = '\0'; + } + + switch (syncAction) { + case 0: + queuePendingCommand(PENDING_START, safeFilename, secondsElapsed, packet.remoteIP()); + break; + case 1: + queuePendingCommand(PENDING_STOP, nullptr, 0.0f, packet.remoteIP()); + break; + case 2: + queuePendingCommand(PENDING_SYNC, safeFilename, secondsElapsed, packet.remoteIP()); + break; + default: + break; + } + break; + } + case CTRL_PKT_PING: + if (packet.isBroadcast() || packet.isMulticast()) { + queuePendingPingReply(packet.remoteIP()); + } + break; + case CTRL_PKT_BLANK: + queuePendingCommand(PENDING_BLANK, nullptr, 0.0f, packet.remoteIP()); + break; + default: + break; + } + } + + void startRealtimeFppPlayback(const String &fileName, + float secondsElapsed, + const IPAddress &senderIP) { + String normalized = fileName; + if (normalized.length() == 0) { + if (FSEQPlayer::isPlaying()) { + normalized = "/" + FSEQPlayer::getFileName(); + } else if (lastSequenceName[0] != '\0') { + normalized = "/" + String(lastSequenceName); + } else { + DEBUG_PRINTLN(F("[FPP] Cannot start realtime playback: no filename available")); + return; + } + } else if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + + DEBUG_PRINTF("[FPP] Start realtime playback: %s @ %.3fs\n", + normalized.c_str(), secondsElapsed); + + rememberSyncProgressLoopOnly(normalized.c_str(), secondsElapsed); + FSEQ_markFppControlActivity(); + realtimeIP = senderIP; + useMainSegmentOnly = false; + realtimeLock(3000, REALTIME_MODE_FSEQ); + FSEQPlayer::loadRecording(normalized.c_str(), secondsElapsed, true); + + if (!FSEQPlayer::isPlaying()) { + DEBUG_PRINTF("[FPP] Failed to activate realtime playback for %s\n", + normalized.c_str()); + return; + } + + FSEQPlayer::renderRealtimeFrame(); + } + + void stopRealtimeFppPlayback() { + FSEQ_clearFppOverride(); + FSEQPlayer::clearLastPlayback(); + clearPlaybackStatusCacheLoopOnly(false); + exitRealtime(); + } + + void processPendingFppCommand() { + PendingCommandType cmd; + char fileName[65]; + float seconds; + IPAddress senderIP; + + portENTER_CRITICAL(&fppMux); + cmd = pendingCommand; + if (cmd == PENDING_NONE) { + portEXIT_CRITICAL(&fppMux); + return; + } + pendingCommand = PENDING_NONE; + memcpy(fileName, pendingFileName, sizeof(fileName)); + seconds = pendingSecondsElapsed; + senderIP = lastFppSenderIP; + portEXIT_CRITICAL(&fppMux); + + String fn = String(fileName); + switch (cmd) { + case PENDING_START: + startRealtimeFppPlayback(fn, seconds, senderIP); + break; + case PENDING_STOP: + case PENDING_BLANK: + stopRealtimeFppPlayback(); + break; + case PENDING_SYNC: { + String normalized = fn; + if (normalized.length() == 0) { + if (FSEQPlayer::isPlaying()) { + normalized = "/" + FSEQPlayer::getFileName(); + } else if (lastSequenceName[0] != '\0') { + normalized = "/" + String(lastSequenceName); + } + } else if (!normalized.startsWith("/")) { + normalized = "/" + normalized; + } + + if (normalized.length() == 0) { + DEBUG_PRINTLN(F("[FPP] Ignoring SYNC without filename and without active playback")); + break; + } + + const String wantedFileName = normalized.substring(1); + const String activeFileName = FSEQPlayer::getFileName(); + if (!FSEQPlayer::isPlaying() || + !activeFileName.equalsIgnoreCase(wantedFileName)) { + startRealtimeFppPlayback(normalized, seconds, senderIP); + break; + } + + rememberSyncProgressLoopOnly(normalized.c_str(), seconds); + FSEQ_markFppControlActivity(); + realtimeIP = senderIP; + useMainSegmentOnly = false; + realtimeLock(3000, REALTIME_MODE_FSEQ); + FSEQPlayer::syncPlayback(seconds); + break; + } + default: + break; + } + } + +public: + static const char _name[]; + + void setup() { + DEBUG_PRINTF("[%s] FPP Usermod loaded\n", _name); + FPP_usermodInstance() = this; + server.on("/api/system/info", HTTP_GET, [this](AsyncWebServerRequest *request) { + request->send(200, "application/json", buildSystemInfoJSON()); + }); + server.on("/api/system/status", HTTP_GET, [this](AsyncWebServerRequest *request) { + request->send(200, "application/json", buildSystemStatusJSON()); + }); + server.on("/api/fppd/multiSyncSystems", HTTP_GET, [this](AsyncWebServerRequest *request) { + request->send(200, "application/json", buildFppdMultiSyncSystemsJSON()); + }); + + server.on("/fpp", HTTP_POST, [](AsyncWebServerRequest *request) {}, NULL, + [this](AsyncWebServerRequest *request, uint8_t *data, size_t len, size_t index, size_t total) { + const unsigned long now = millis(); + lastUploadActivity = now; + + if (index == 0) { + cleanupStaleFppUploadIfNeeded(); + + if (uploadStream || currentUploadFile) { + request->send(409, "text/plain", "Upload already in progress"); + return; + } + + String fileParam = ""; + if (request->hasParam("filename")) fileParam = request->arg("filename"); + currentUploadFileName = normalizeUploadPath(fileParam.length() > 0 ? fileParam : "default.fseq"); + + if (isUnsafeUploadPath(currentUploadFileName) || currentUploadFileName == "/") { + currentUploadFileName = ""; + request->send(400, "text/plain", "Invalid filename"); + return; + } + + if (SD_ADAPTER.exists(currentUploadFileName.c_str())) { + SD_ADAPTER.remove(currentUploadFileName.c_str()); + } + + currentUploadFile = SD_ADAPTER.open(currentUploadFileName.c_str(), FILE_WRITE); + if (!currentUploadFile) { + cleanupFppUploadState(false); + request->send(500, "text/plain", "File open failed"); + return; + } + + uploadStream = new WriteBufferingStream(currentUploadFile, FILE_UPLOAD_BUFFER_SIZE); + if (!uploadStream) { + cleanupFppUploadState(true); + request->send(500, "text/plain", "Upload buffer allocation failed"); + return; + } + + uploadStartTime = now; + uploadSessionActive = true; + } + + if (!uploadStream) { + request->send(500, "text/plain", "Upload stream missing"); + return; + } + + const size_t written = uploadStream->write(data, len); + if (written != len || uploadStream->hasWriteError()) { + cleanupFppUploadState(true); + request->send(500, "text/plain", "Upload write failed"); + return; + } + + if (index + len == total) { + uploadStream->flush(); + const bool writeOk = !uploadStream->hasWriteError(); + delete uploadStream; + uploadStream = nullptr; + + String uploadedFile = currentUploadFileName; + if (currentUploadFile) { + currentUploadFile.close(); + } + + if (!writeOk) { + if (uploadedFile.length() > 0) { + SD_ADAPTER.remove(uploadedFile.c_str()); + } + cleanupFppUploadState(false); + request->send(500, "text/plain", "Upload flush failed"); + return; + } + + finishSuccessfulFppUpload(uploadedFile); + request->send(200, "text/plain", "Upload complete"); + } + }); + + server.on("/fseqfilelist", HTTP_GET, [](AsyncWebServerRequest *request) { + DynamicJsonDocument doc(1024); + JsonArray files = doc.createNestedArray("files"); + File root = SD_ADAPTER.open("/"); + if (root && root.isDirectory()) { + File file = root.openNextFile(); + while (file) { + String name = file.name(); + if (name.endsWith(".fseq") || name.endsWith(".FSEQ")) { + JsonObject fileObj = files.createNestedObject(); + fileObj["name"] = name; + fileObj["size"] = file.size(); + } + file.close(); + file = root.openNextFile(); + } + } else { + doc["error"] = "Cannot open SD root directory"; + } + String json; + serializeJson(doc, json); + request->send(200, "application/json", json); + }); + + lastWiFiStatus = WiFi.status(); + startUdpIfNeeded(); + } + + void loop() { + wl_status_t wifiNow = WiFi.status(); + + if (wifiNow != lastWiFiStatus) { + DEBUG_PRINTF("[FPP] WiFi status changed: %d -> %d\n", lastWiFiStatus, wifiNow); + + if (wifiNow == WL_CONNECTED) { + stopUdpListeners(); + announceBurstActive = true; + announceBurstRemaining = 5; + lastAnnounceBurstTime = 0; + lastPingTime = 0; + startUdpIfNeeded(); + } else { + stopUdpListeners(); + } + + lastWiFiStatus = wifiNow; + } + + startUdpIfNeeded(); + cleanupStaleFppUploadIfNeeded(); + processPendingFppCommand(); + + bool doPingReply = false; + IPAddress pingReplyIP; + + portENTER_CRITICAL(&fppMux); + if (pendingPingReply) { + doPingReply = true; + pingReplyIP = pendingPingReplyIP; + pendingPingReply = false; + } + portEXIT_CRITICAL(&fppMux); + + if (doPingReply && udpStarted && wifiNow == WL_CONNECTED) { + sendPingPacket(pingReplyIP); + } + + if (udpStarted && wifiNow == WL_CONNECTED) { + if (announceBurstActive && + (lastAnnounceBurstTime == 0 || + millis() - lastAnnounceBurstTime >= announceBurstInterval)) { + sendDiscoveryBurst(); + lastAnnounceBurstTime = millis(); + + if (announceBurstRemaining > 0) { + announceBurstRemaining--; + } + if (announceBurstRemaining == 0) { + announceBurstActive = false; + } + } + + if (millis() - lastPingTime >= pingInterval) { + sendDiscoveryBurst(); + lastPingTime = millis(); + } + } + + if (FSEQ_isFppOverrideActive()) { + realtimeIP = getLastFppSenderIPSnapshot(); + realtimeLock(3000, REALTIME_MODE_FSEQ); + FSEQPlayer::renderRealtimeFrame(); + } else if (realtimeMode == REALTIME_MODE_FSEQ && FSEQPlayer::isPlaying()) { + stopRealtimeFppPlayback(); + } else if (!FSEQPlayer::isPlaying() && !FSEQ_isFppOverrideActive()) { + clearPlaybackStatusCacheLoopOnly(false); + } + + updatePlaybackStatusSnapshotLoopOnly(); + + if (xlzStartTime == 0) xlzStartTime = millis(); + if (!xlzChecked && (millis() - xlzStartTime >= 2000)) { + File root = SD_ADAPTER.open("/"); + if (root && root.isDirectory()) { + root.close(); + if (!FSEQPlayer::isAnyPlaybackActive()) { + XLZUnzip::processAllPendingXLZ(); + xlzChecked = true; + } + } else if (root) { + root.close(); + xlzChecked = true; + } + } + + if (uploadSessionActive && xlzPendingScan && !xlzProcessing) { + if (millis() - lastUploadActivity >= 10000) { + if (FSEQPlayer::isAnyPlaybackActive()) return; + xlzProcessing = true; + XLZUnzip::processAllPendingXLZ(); + xlzProcessing = false; + xlzPendingScan = false; + uploadSessionActive = false; + } + } + } + + uint16_t getId() override { return USERMOD_ID_FPP; } + void addToConfig(JsonObject &root) override {} + bool readFromConfig(JsonObject &root) override { return true; } +}; + +inline bool FPP_sendEffectSyncMessage(uint8_t action, const String &fileName, + uint32_t currentFrame, float secondsElapsed, + bool sendMulticast, bool sendBroadcast) { + UsermodFPP *instance = FPP_usermodInstance(); + return instance ? instance->sendSyncMessage(action, fileName, currentFrame, + secondsElapsed, sendMulticast, + sendBroadcast) + : false; +} + +inline const char UsermodFPP::_name[] PROGMEM = "FPP Connect"; diff --git a/usermods/FSEQ/usermod_fseq.h b/usermods/FSEQ/usermod_fseq.h new file mode 100644 index 0000000000..2e04ee4155 --- /dev/null +++ b/usermods/FSEQ/usermod_fseq.h @@ -0,0 +1,57 @@ +#pragma once + +#include "wled.h" +#include "sd_adapter_compat.h" + +#include "fseq_player.h" +#include "fseq_effect.h" +#include "web_ui_manager.h" + +// Usermod for FSEQ playback with UDP and web UI support. +// SD card initialisation is handled by the standard sd_card usermod. +class UsermodFseq : public Usermod { +private: + WebUIManager webUI; // Web UI Manager module (handles endpoints) + static const char _name[]; // for storing usermod name in config + +public: + static uint8_t fseqEffectId; // effect ID assigned by strip.addEffect() + + // Setup function called once at startup + void setup() { + DEBUG_PRINTF("[%s] Usermod loaded\n", FPSTR(_name)); + + // Register the FSEQ Player as a WLED effect and store its ID + fseqEffectId = strip.addEffect(255, &mode_fseq_player, _data_FX_MODE_FSEQ_PLAYER); + + // Register web endpoints defined in WebUIManager + webUI.registerEndpoints(); + } + + // Loop function called continuously + void loop() { + // FSEQ playback is now driven by the WLED effect engine via mode_fseq_player. + // No work needed here. + } + + // Unique ID for the usermod + uint16_t getId() override { return USERMOD_ID_FSEQ; } + + // Add a link in the Info tab to your SD + void addToJsonInfo(JsonObject &root) override { + JsonObject user = root["u"]; + if (user.isNull()) { + user = root.createNestedObject("u"); + } + JsonArray arr = user.createNestedArray("FSEQ UI"); + + String button = R"rawliteral( + + )rawliteral"; + + arr.add(button); + } + + void addToConfig(JsonObject &root) override {} + bool readFromConfig(JsonObject &root) override { return true; } +}; \ No newline at end of file diff --git a/usermods/FSEQ/web_ui_manager.cpp b/usermods/FSEQ/web_ui_manager.cpp new file mode 100644 index 0000000000..f24462efc1 --- /dev/null +++ b/usermods/FSEQ/web_ui_manager.cpp @@ -0,0 +1,501 @@ +#include "web_ui_manager.h" +#include "usermod_fseq.h" + +uint16_t FSEQ_refreshFileIndexCache(); +bool FSEQ_getFileNameByIndex(uint16_t index, String &outName); +void FSEQ_invalidateFileIndexCache(); + +struct UploadContext { + File* file; + String path; + bool error; + int statusCode; + const char* message; + + UploadContext() + : file(nullptr), + path(), + error(false), + statusCode(500), + message("Failed to open file for writing") {} +}; + +static bool isUnsafeSdPath(const String& path) { + if (path.length() == 0) return true; + if (path.indexOf("..") >= 0) return true; + if (path.indexOf('\\') >= 0) return true; + return false; +} + +static String normalizeSdPath(String path) { + path.trim(); + if (!path.startsWith("/")) path = "/" + path; + return path; +} + +static const char PAGE_HTML[] PROGMEM = R"rawliteral( + + + + +WLED FSEQ SD Manager + + + + + + + + + + +
+ +

FSEQ SD Manager

+
+
+ +
+

SD Storage

+
+
+
+
+
+ +
+

FSEQ Files

+

The list below is the indexed order used by the FSEQ Player effect slider.

+
    +
    + +
    +

    Other SD Files

    +
      +
      + +
      +

      Upload File

      +

      + +
      +
      +
      +
      +
      + +
      +

      How it works

      +

      + 1. Upload one or more .fseq files to the SD card.
      + 2. Open the WLED effects UI and select FSEQ Player.
      + 3. Use the Index slider to select one of the numbered FSEQ files shown above.
      + 4. Enable Loop if the sequence should repeat continuously.
      + 5. Save the state as a preset if you want the same sequence to be restored on boot.
      + 6. When FPP takes control, FPP temporarily overrides the local effect selection until its control timeout expires. +

      +
      + + + +)rawliteral"; + +void WebUIManager::registerEndpoints() { + + server.on("/fsequi", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send_P(200, "text/html", PAGE_HTML); + }); + + server.on("/api/sd/list", HTTP_GET, [](AsyncWebServerRequest *request) { + File root = SD_ADAPTER.open("/"); + + uint64_t totalBytes = SD_ADAPTER.totalBytes(); + uint64_t usedBytes = SD_ADAPTER.usedBytes(); + + DynamicJsonDocument doc(12288); + JsonObject rootObj = doc.to(); + JsonArray fseqFiles = rootObj.createNestedArray("fseqFiles"); + JsonArray otherFiles = rootObj.createNestedArray("otherFiles"); + + const uint16_t fseqCount = FSEQ_refreshFileIndexCache(); + for (uint16_t i = 0; i < fseqCount; i++) { + String fileName; + if (!FSEQ_getFileNameByIndex(i, fileName)) continue; + JsonObject obj = fseqFiles.createNestedObject(); + obj["index"] = i; + obj["name"] = fileName; + } + + if (root && root.isDirectory()) { + File file = root.openNextFile(); + while (file) { + if (!file.isDirectory()) { + String name = file.name(); + if (!name.startsWith("/")) name = "/" + name; + if (!(name.endsWith(".fseq") || name.endsWith(".FSEQ"))) { + JsonObject obj = otherFiles.createNestedObject(); + obj["name"] = name; + obj["size"] = (float)file.size() / 1024.0f; + } + } + + file.close(); + file = root.openNextFile(); + } + } + + root.close(); + + rootObj["usedKB"] = (float)usedBytes / 1024.0f; + rootObj["totalKB"] = (float)totalBytes / 1024.0f; + + String output; + serializeJson(doc, output); + + if (doc.overflowed()) { + request->send(507, "text/plain", "JSON buffer too small; file list may be truncated"); + return; + } + + request->send(200, "application/json", output); + }); + + server.on("/api/fseq/list", HTTP_GET, [](AsyncWebServerRequest *request) { + DynamicJsonDocument doc(4096); + JsonArray files = doc.to(); + + const uint16_t fseqCount = FSEQ_refreshFileIndexCache(); + for (uint16_t i = 0; i < fseqCount; i++) { + String fileName; + if (!FSEQ_getFileNameByIndex(i, fileName)) continue; + JsonObject obj = files.createNestedObject(); + obj["index"] = i; + obj["name"] = fileName; + } + + String output; + serializeJson(doc, output); + + if (doc.overflowed()) { + request->send(507, "text/plain", "JSON buffer too small; file list may be truncated"); + return; + } + + request->send(200, "application/json", output); + }); + + server.on( + "/api/sd/upload", HTTP_POST, + [](AsyncWebServerRequest *request) { + UploadContext* ctx = static_cast(request->_tempObject); + + if (!ctx || ctx->error || !ctx->file || !*(ctx->file)) { + const int statusCode = ctx ? ctx->statusCode : 500; + const char* message = ctx ? ctx->message : "Failed to open file for writing"; + request->send(statusCode, "text/plain", message); + } else { + request->send(200, "text/plain", "Upload complete"); + } + + if (ctx) { + if (!ctx->error) FSEQ_invalidateFileIndexCache(); + if (ctx->file) { + if (*(ctx->file)) ctx->file->close(); + delete ctx->file; + ctx->file = nullptr; + } + + if (ctx->error && !ctx->path.isEmpty()) { + SD_ADAPTER.remove(ctx->path.c_str()); + } + + delete ctx; + request->_tempObject = nullptr; + } + }, + [](AsyncWebServerRequest *request, String filename, size_t index, + uint8_t *data, size_t len, bool final) { + UploadContext* ctx = static_cast(request->_tempObject); + + if (index == 0) { + ctx = new UploadContext(); + + if (isUnsafeSdPath(filename)) { + ctx->error = true; + ctx->statusCode = 400; + ctx->message = "Invalid path"; + request->_tempObject = ctx; + return; + } + + filename = normalizeSdPath(filename); + + if (filename == "/") { + ctx->error = true; + ctx->statusCode = 400; + ctx->message = "Invalid filename"; + request->_tempObject = ctx; + return; + } + + ctx->path = filename; + + if (SD_ADAPTER.exists(filename.c_str())) SD_ADAPTER.remove(filename.c_str()); + ctx->file = new File(SD_ADAPTER.open(filename.c_str(), FILE_WRITE)); + + if (!ctx->file || !*(ctx->file)) { + ctx->error = true; + ctx->statusCode = 500; + ctx->message = "Failed to open file for writing"; + } + + request->_tempObject = ctx; + } + + ctx = static_cast(request->_tempObject); + + if (!ctx || ctx->error || !ctx->file || !*(ctx->file)) + return; + + const size_t written = ctx->file->write(data, len); + if (written != len) { + ctx->error = true; + ctx->statusCode = 500; + ctx->message = "Failed to write upload data"; + } + } + ); + + server.on("/api/sd/delete", HTTP_POST, [](AsyncWebServerRequest *request) { + if (!request->hasArg("path")) { + request->send(400, "text/plain", "Missing path"); + return; + } + + String path = request->arg("path"); + + if (isUnsafeSdPath(path)) { + request->send(400, "text/plain", "Invalid path"); + return; + } + + path = normalizeSdPath(path); + + if (path == "/") { + request->send(400, "text/plain", "Invalid path"); + return; + } + + if (!SD_ADAPTER.exists(path.c_str())) { + request->send(404, "text/plain", "File not found"); + return; + } + + bool res = SD_ADAPTER.remove(path.c_str()); + if (res) FSEQ_invalidateFileIndexCache(); + request->send(res ? 200 : 500, "text/plain", res ? "File deleted" : "Delete failed"); + }); +} diff --git a/usermods/FSEQ/web_ui_manager.h b/usermods/FSEQ/web_ui_manager.h new file mode 100644 index 0000000000..7621341f6c --- /dev/null +++ b/usermods/FSEQ/web_ui_manager.h @@ -0,0 +1,13 @@ +#ifndef WEB_UI_MANAGER_H +#define WEB_UI_MANAGER_H + +#include "wled.h" + + +class WebUIManager { + public: + WebUIManager() {} + void registerEndpoints(); +}; + +#endif // WEB_UI_MANAGER_H \ No newline at end of file diff --git a/usermods/FSEQ/xlz_unzip.cpp b/usermods/FSEQ/xlz_unzip.cpp new file mode 100644 index 0000000000..c77a218bdf --- /dev/null +++ b/usermods/FSEQ/xlz_unzip.cpp @@ -0,0 +1,332 @@ +#include "xlz_unzip.h" +#include "usermod_fseq.h" // Contains FSEQ playback logic and getter methods for pins +#include "fseq_player.h" + +namespace { +constexpr size_t XLZ_BUFFER_SIZE = 8192; + +// IMPORTANT: unzipLIB uses a fixed internal structure of roughly 41 KB. +// Do NOT put UNZIP on the task stack (e.g. as a local variable in loop()). +// Keeping one static instance avoids stack-canary panics in loopTask. +static UNZIP g_xlzZip; + +static bool endsWithIgnoreCase(const String& value, const char* suffix) { + const size_t n = strlen(suffix); + if (value.length() < n) return false; + return value.substring(value.length() - n).equalsIgnoreCase(suffix); +} + +static String normalizePath(const String& path) { + if (path.isEmpty()) return String("/"); + if (path[0] == '/') return path; + return String("/") + path; +} +} // namespace + +bool XLZUnzip::hasXLZExtension(const String& path) { + return endsWithIgnoreCase(path, ".xlz"); +} + +void* XLZUnzip::openZip(const char* filename, int32_t* size) { + if (size) *size = 0; + + String path = filename ? String(filename) : String(); + path = normalizePath(path); + DEBUG_PRINTF("[XLZ] openZip('%s')\n", path.c_str()); + + FsHandle* h = new FsHandle(); + h->file = SD_ADAPTER.open(path.c_str(), FILE_READ); + + if (!h->file) { + delete h; + DEBUG_PRINTF("[XLZ] Failed to open archive: %s\n", path.c_str()); + return nullptr; + } + + if (size) *size = static_cast(h->file.size()); + h->pos = 0; + return h; +} + +void XLZUnzip::closeZip(void* p) { + if (!p) return; + ZIPFILE* zf = static_cast(p); + FsHandle* h = static_cast(zf->fHandle); + if (h) { + if (h->file) h->file.close(); + delete h; + zf->fHandle = nullptr; + } +} + +int32_t XLZUnzip::readZip(void* p, uint8_t* buffer, int32_t length) { + if (!p || !buffer || length <= 0) return 0; + + ZIPFILE* zf = static_cast(p); + FsHandle* h = static_cast(zf->fHandle); + if (!h || !h->file) return 0; + + if (h->file.position() != h->pos && !h->file.seek(h->pos)) return 0; + + const int32_t bytesRead = static_cast(h->file.read(buffer, length)); + if (bytesRead > 0) h->pos += bytesRead; + return bytesRead; +} + +int32_t XLZUnzip::seekZip(void* p, int32_t position, int iType) { + if (!p) return -1; + + ZIPFILE* zf = static_cast(p); + FsHandle* h = static_cast(zf->fHandle); + if (!h || !h->file) return -1; + + int32_t newPos = position; + switch (iType) { + case SEEK_SET: + newPos = position; + break; + case SEEK_CUR: + newPos = h->pos + position; + break; + case SEEK_END: + newPos = static_cast(h->file.size()) + position; + break; + default: + return -1; + } + + if (newPos < 0) newPos = 0; + if (!h->file.seek(newPos)) return -1; + h->pos = newPos; + return h->pos; +} + +String XLZUnzip::sanitizeEntryName(const char* rawName) { + String name = rawName ? String(rawName) : String(); + name.replace('\\', '/'); + name.trim(); + + while (name.startsWith("/")) { + name.remove(0, 1); + } + while (name.startsWith("./")) { + name.remove(0, 2); + } + + // prevent path traversal on extraction + if (name.indexOf("../") >= 0 || name == "..") { + return String(); + } + + return name; +} + +bool XLZUnzip::unpackCurrentFile(UNZIP& zip, const String& outputPath, uint32_t expectedSize) { + if (zip.openCurrentFile() != UNZ_OK) { + DEBUG_PRINTLN(F("[XLZ] openCurrentFile() failed")); + return false; + } + + if (SD_ADAPTER.exists(outputPath.c_str())) { + SD_ADAPTER.remove(outputPath.c_str()); + } + + File out = SD_ADAPTER.open(outputPath.c_str(), FILE_WRITE); + if (!out) { + DEBUG_PRINTF("[XLZ] Failed to create output file: %s\n", outputPath.c_str()); + zip.closeCurrentFile(); + return false; + } + + uint8_t* buffer = static_cast(d_malloc(XLZ_BUFFER_SIZE)); + if (!buffer) { + DEBUG_PRINTLN(F("[XLZ] Failed to allocate unzip buffer")); + out.close(); + SD_ADAPTER.remove(outputPath.c_str()); + zip.closeCurrentFile(); + return false; + } + + bool ok = true; + uint32_t written = 0; + + while (true) { + const int rc = zip.readCurrentFile(buffer, XLZ_BUFFER_SIZE); + if (rc < 0) { + DEBUG_PRINTF("[XLZ] readCurrentFile() failed: %d\n", rc); + ok = false; + break; + } + if (rc == 0) break; + + if (out.write(buffer, static_cast(rc)) != static_cast(rc)) { + DEBUG_PRINTLN(F("[XLZ] Failed while writing decompressed data")); + ok = false; + break; + } + + written += static_cast(rc); + yield(); + } + + free(buffer); + out.flush(); + out.close(); + + const int closeRc = zip.closeCurrentFile(); + if (closeRc != UNZ_OK) { + DEBUG_PRINTF("[XLZ] closeCurrentFile() failed: %d\n", closeRc); + ok = false; + } + + if (ok && expectedSize > 0 && written != expectedSize) { + DEBUG_PRINTF("[XLZ] Size mismatch. expected=%lu actual=%lu\n", + static_cast(expectedSize), + static_cast(written)); + ok = false; + } + + if (!ok) { + SD_ADAPTER.remove(outputPath.c_str()); + } + + return ok; +} + +bool XLZUnzip::unpackArchive(const String& archivePath, String& finalOutputPath) { + const String zipPath = normalizePath(archivePath); + DEBUG_PRINTF("[XLZ] unpackArchive('%s')\n", zipPath.c_str()); + + const int openRc = g_xlzZip.openZIP(zipPath.c_str(), openZip, closeZip, readZip, seekZip); + if (openRc != UNZ_OK) { + DEBUG_PRINTF("[XLZ] openZIP() failed for %s: %d\n", zipPath.c_str(), openRc); + return false; + } + + bool ok = false; + unz_file_info fileInfo{}; + char entryName[256] = {0}; + char comment[64] = {0}; + + if (g_xlzZip.gotoFirstFile() != UNZ_OK) { + DEBUG_PRINTLN(F("[XLZ] Archive contains no files")); + g_xlzZip.closeZIP(); + return false; + } + + const int infoRc = g_xlzZip.getFileInfo(&fileInfo, entryName, sizeof(entryName), + nullptr, 0, comment, sizeof(comment)); + if (infoRc != UNZ_OK) { + DEBUG_PRINTF("[XLZ] getFileInfo() failed: %d\n", infoRc); + g_xlzZip.closeZIP(); + return false; + } + + String safeName = sanitizeEntryName(entryName); + if (safeName.isEmpty()) { + DEBUG_PRINTLN(F("[XLZ] Invalid filename inside archive")); + g_xlzZip.closeZIP(); + return false; + } + + finalOutputPath = zipPath; + if (hasXLZExtension(finalOutputPath)) { + finalOutputPath.remove(finalOutputPath.length() - 4); + finalOutputPath += ".fseq"; + } else { + finalOutputPath = normalizePath(safeName); + if (!endsWithIgnoreCase(finalOutputPath, ".fseq")) { + finalOutputPath += ".fseq"; + } + } + + const uint64_t totalBytes = SD_ADAPTER.totalBytes(); + const uint64_t usedBytes = SD_ADAPTER.usedBytes(); + const uint64_t freeBytes = (totalBytes >= usedBytes) ? (totalBytes - usedBytes) : 0; + if (fileInfo.uncompressed_size > freeBytes) { + DEBUG_PRINTF("[XLZ] Not enough free space. need=%lu free=%lu\n", + static_cast(fileInfo.uncompressed_size), + static_cast(freeBytes)); + g_xlzZip.closeZIP(); + return false; + } + + DEBUG_PRINTF("[XLZ] Extracting %s -> %s\n", zipPath.c_str(), finalOutputPath.c_str()); + ok = unpackCurrentFile(g_xlzZip, finalOutputPath, static_cast(fileInfo.uncompressed_size)); + + const int nextRc = g_xlzZip.gotoNextFile(); + if (ok && nextRc == UNZ_OK) { + DEBUG_PRINTLN(F("[XLZ] Warning: archive contains more than one file; only the first file was extracted")); + } + + g_xlzZip.closeZIP(); + return ok; +} + +bool XLZUnzip::unpackAndDelete(const String& archivePath, String* outFile) { + DEBUG_PRINTF("[XLZ] raw archivePath='%s'\n", archivePath.c_str()); + + const String zipPath = normalizePath(archivePath); + DEBUG_PRINTF("[XLZ] normalized archivePath='%s'\n", zipPath.c_str()); + + if (!hasXLZExtension(zipPath)) { + DEBUG_PRINTF("[XLZ] Not an .xlz file: %s\n", zipPath.c_str()); + return false; + } + + if (!SD_ADAPTER.exists(zipPath.c_str())) { + DEBUG_PRINTF("[XLZ] Archive not found: %s\n", zipPath.c_str()); + return false; + } + + String finalOutputPath; + const bool ok = unpackArchive(zipPath, finalOutputPath); + if (!ok) return false; + + if (!SD_ADAPTER.remove(zipPath.c_str())) { + DEBUG_PRINTF("[XLZ] Extracted, but could not delete archive: %s\n", zipPath.c_str()); + } + + if (outFile) { + *outFile = finalOutputPath; + } + + FSEQ_invalidateFileIndexCache(); + DEBUG_PRINTF("[XLZ] Done: %s\n", finalOutputPath.c_str()); + return true; +} + +uint8_t XLZUnzip::processAllPendingXLZ() { + DEBUG_PRINTLN("[XLZ] processAllPendingXLZ() entered"); + + File root = SD_ADAPTER.open("/"); + if (!root || !root.isDirectory()) { + DEBUG_PRINTLN("[XLZ] failed to open root directory"); + return 0; + } + + uint8_t count = 0; + File file = root.openNextFile(); + while (file) { + String path = String(file.name()); + path = normalizePath(path); + DEBUG_PRINTF("[XLZ] found entry: %s\n", path.c_str()); + + const bool isDir = file.isDirectory(); + file.close(); + + if (!isDir && hasXLZExtension(path)) { + DEBUG_PRINTF("[XLZ] unpacking: %s\n", path.c_str()); + if (unpackAndDelete(path, nullptr)) { + ++count; + } + } + + file = root.openNextFile(); + yield(); + } + + root.close(); + DEBUG_PRINTF("[XLZ] processAllPendingXLZ() done, count=%u\n", count); + return count; +} diff --git a/usermods/FSEQ/xlz_unzip.h b/usermods/FSEQ/xlz_unzip.h new file mode 100644 index 0000000000..f32bfd8be2 --- /dev/null +++ b/usermods/FSEQ/xlz_unzip.h @@ -0,0 +1,31 @@ +#pragma once + +#include "wled.h" +#include "sd_adapter_compat.h" +#include + +class XLZUnzip { +public: + // Unpacks one .xlz archive to the SD card. + // On success, the .xlz file is deleted and the final .fseq path is returned in outFile. + static bool unpackAndDelete(const String& archivePath, String* outFile = nullptr); + + // Optional helper: scan the SD root and unpack all .xlz files. + static uint8_t processAllPendingXLZ(); + +private: + struct FsHandle { + File file; + int32_t pos = 0; + }; + + static void* openZip(const char* filename, int32_t* size); + static void closeZip(void* p); + static int32_t readZip(void* p, uint8_t* buffer, int32_t length); + static int32_t seekZip(void* p, int32_t position, int iType); + + static bool unpackArchive(const String& archivePath, String& finalOutputPath); + static bool unpackCurrentFile(UNZIP& zip, const String& outputPath, uint32_t expectedSize); + static String sanitizeEntryName(const char* rawName); + static bool hasXLZExtension(const String& path); +}; diff --git a/usermods/sd_card/sd_card.cpp b/usermods/sd_card/sd_card.cpp index 4e68b97a34..372e013c3c 100644 --- a/usermods/sd_card/sd_card.cpp +++ b/usermods/sd_card/sd_card.cpp @@ -15,7 +15,11 @@ #ifdef WLED_USE_SD_MMC #elif defined(WLED_USE_SD_SPI) + #if CONFIG_IDF_TARGET_ESP32 SPIClass spiPort = SPIClass(VSPI); + #else + SPIClass spiPort = SPI; + #endif #endif void listDir( const char * dirname, uint8_t levels); @@ -25,10 +29,10 @@ class UsermodSdCard : public Usermod { bool sdInitDone = false; #ifdef WLED_USE_SD_SPI - int8_t configPinSourceSelect = 16; - int8_t configPinSourceClock = 14; - int8_t configPinPoci = 36; // confusing names? Then have a look :) - int8_t configPinPico = 15; // https://www.oshwa.org/a-resolution-to-redefine-spi-signal-names/ + int8_t configPinSourceSelect = -1; + int8_t configPinSourceClock = -1; + int8_t configPinPoci = -1; // confusing names? Then have a look :) + int8_t configPinPico = -1; // https://www.oshwa.org/a-resolution-to-redefine-spi-signal-names/ //acquired and initialize the SPI port void init_SD_SPI() @@ -153,10 +157,10 @@ class UsermodSdCard : public Usermod { return false; } - uint8_t oldPinSourceSelect = configPinSourceSelect; - uint8_t oldPinSourceClock = configPinSourceClock; - uint8_t oldPinPoci = configPinPoci; - uint8_t oldPinPico = configPinPico; + int8_t oldPinSourceSelect = configPinSourceSelect; + int8_t oldPinSourceClock = configPinSourceClock; + int8_t oldPinPoci = configPinPoci; + int8_t oldPinPico = configPinPico; bool oldSdEnabled = configSdEnabled; getJsonValue(top["pinSourceSelect"], configPinSourceSelect); diff --git a/wled00/const.h b/wled00/const.h index 70373316fd..61f2fc8115 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -230,6 +230,8 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define USERMOD_ID_RF433 56 //Usermod "usermod_v2_RF433.h" #define USERMOD_ID_BRIGHTNESS_FOLLOW_SUN 57 //Usermod "usermod_v2_brightness_follow_sun.h" #define USERMOD_ID_USER_FX 58 //Usermod "user_fx" +#define USERMOD_ID_FSEQ 59 //Usermod "FSEQ Player" +#define USERMOD_ID_FPP 60 //Usermod "FPP" //Wifi encryption type #ifdef WLED_ENABLE_WPA_ENTERPRISE @@ -282,6 +284,7 @@ static_assert(WLED_MAX_BUSSES <= 32, "WLED_MAX_BUSSES exceeds hard limit"); #define REALTIME_MODE_TPM2NET 7 #define REALTIME_MODE_DDP 8 #define REALTIME_MODE_DMX 9 +#define REALTIME_MODE_FSEQ 10 //realtime override modes #define REALTIME_OVERRIDE_NONE 0 diff --git a/wled00/json.cpp b/wled00/json.cpp index b4388d27ef..aeb4e0d5af 100644 --- a/wled00/json.cpp +++ b/wled00/json.cpp @@ -764,6 +764,7 @@ void serializeInfo(JsonObject root) case REALTIME_MODE_TPM2NET: root["lm"] = F("tpm2.net"); break; case REALTIME_MODE_DDP: root["lm"] = F("DDP"); break; case REALTIME_MODE_DMX: root["lm"] = F("DMX"); break; + case REALTIME_MODE_FSEQ: root["lm"] = F("FSEQ"); break; } root[F("lip")] = realtimeIP[0] == 0 ? "" : realtimeIP.toString();