From e25ee044767d60ad1f9dd30acdbfc9355d274121 Mon Sep 17 00:00:00 2001 From: Danit2 <71522810+Danit2@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:32:19 +0100 Subject: [PATCH 1/9] Add FSEQ usermod (SD playback + FPP) Introduce a new FSEQ usermod that enables playing .fseq animations from an SD card with a web UI and FPP/UDP synchronization. Adds core components: FSEQ player (fseq_player.cpp/.h), SD manager (sd_manager.*), web/UI & upload/FPP handling (usermod_fpp.h, web_ui_manager.*), registration (register_usermod.cpp), library metadata and auto-build script (library.json, auto_fseq_sd.py) and a README. Also includes helpers for buffered uploads and FPP ping/sync packets. Small updates in existing wled00 files (const.h, json.cpp) are included to integrate the usermod. --- usermods/FSEQ/README.md | 119 +++++ usermods/FSEQ/auto_fseq_sd.py | 43 ++ usermods/FSEQ/fseq_player.cpp | 299 ++++++++++++ usermods/FSEQ/fseq_player.h | 76 +++ usermods/FSEQ/library.json | 7 + usermods/FSEQ/register_usermod.cpp | 9 + usermods/FSEQ/sd_manager.cpp | 44 ++ usermods/FSEQ/sd_manager.h | 22 + usermods/FSEQ/usermod_fpp.h | 752 +++++++++++++++++++++++++++++ usermods/FSEQ/usermod_fseq.h | 188 ++++++++ usermods/FSEQ/web_ui_manager.bak | 512 ++++++++++++++++++++ usermods/FSEQ/web_ui_manager.cpp | 557 +++++++++++++++++++++ usermods/FSEQ/web_ui_manager.h | 13 + wled00/const.h | 1 + wled00/json.cpp | 1 + 15 files changed, 2643 insertions(+) create mode 100644 usermods/FSEQ/README.md create mode 100644 usermods/FSEQ/auto_fseq_sd.py create mode 100644 usermods/FSEQ/fseq_player.cpp create mode 100644 usermods/FSEQ/fseq_player.h create mode 100644 usermods/FSEQ/library.json create mode 100644 usermods/FSEQ/register_usermod.cpp create mode 100644 usermods/FSEQ/sd_manager.cpp create mode 100644 usermods/FSEQ/sd_manager.h create mode 100644 usermods/FSEQ/usermod_fpp.h create mode 100644 usermods/FSEQ/usermod_fseq.h create mode 100644 usermods/FSEQ/web_ui_manager.bak create mode 100644 usermods/FSEQ/web_ui_manager.cpp create mode 100644 usermods/FSEQ/web_ui_manager.h diff --git a/usermods/FSEQ/README.md b/usermods/FSEQ/README.md new file mode 100644 index 0000000000..e211c5a324 --- /dev/null +++ b/usermods/FSEQ/README.md @@ -0,0 +1,119 @@ +# ✨ Usermod FSEQ ✨ + +> **Created original by: Andrej Chrcek** +> **Updatet by: Danit2** + +Welcome to the **Usermod FSEQ** project! +This module extends your WLED setup by enabling FSEQ file playback from an SD card, including a web UI and UDP remote control. It combines creativity with functionality to enhance your lighting experience. + +--- + +# FSEQ Web UI + +Access the interface via: + +http://yourIP/fsequi + +or over the WLED Infotab + +--- + +# SD & FSEQ Usermod for WLED + +This usermod adds support for playing FSEQ files from an SD card and provides a web interface for managing SD files and controlling FSEQ playback via HTTP and UDP. + +It supports configurable SPI pin settings when using SD over SPI. + +The usermod exposes several HTTP endpoints for file management and playback control. + +--- + +## Features + +- **FSEQ Playback** – Play FSEQ files from an SD card. +- **Web UI** – Manage SD files (list, upload, delete) and control playback. +- **UDP Synchronization** – Remote control via UDP packets. +- **Configurable SPI Pins** – SPI pin assignments can be configured via WLED’s Usermods settings (JSON). + +--- + +## Installation + +### Configure PlatformIO + +Add the following to your `platformio_override.ini` (or `platformio.ini`): + +[env:esp32dev_V4] +custom_usermods = FSEQ + +--- + +### Storage Configuration + +- If you use **SD over SPI**, the build flag + `-D WLED_USE_SD_SPI` + will be enabled automatically (default behavior). + +- If you use **SD via MMC**, you must manually set the build flag: + `-D WLED_USE_SD_MMC` + +--- + +## Available Endpoints + +### SD Management + +GET /sd/ui +Returns the main HTML interface for the SD & FSEQ Manager. + +GET /sd/list +Displays an HTML page listing all files on the SD card, including options to delete files and upload new ones. + +POST /sd/upload +Handles file uploads using multipart/form-data. + +GET /sd/delete?path=/filename +Deletes the specified file from the SD card. +Example: /sd/delete?path=/example.fseq + +--- + +### FSEQ Control + +GET /fseq/list +Returns an HTML page listing all .fseq and .FSEQ files found on the SD card. Each file includes a play button. + +GET /fseq/start?file=/animation.fseq&t=10 +Starts playback of the selected FSEQ file. +Optional parameter: t = time offset in seconds. + +GET /fseq/stop +Stops the current FSEQ playback and clears the active session. + +GET /fseqfilelist +Returns a JSON list of all FSEQ files on the SD card. + +--- + +## Configurable SPI Pin Settings + +Default SPI pin assignments for SD over SPI: + +#ifdef WLED_USE_SD_SPI +int8_t UsermodFseq::configPinSourceSelect = 5; +int8_t UsermodFseq::configPinSourceClock = 18; +int8_t UsermodFseq::configPinPoci = 19; +int8_t UsermodFseq::configPinPico = 23; +#endif + +These values can be modified via the WLED Usermods settings tab without recompiling the firmware. + +After making changes, you must reboot the device. + +--- + +## Summary + +The SD & FSEQ Usermod for WLED enables FSEQ playback from an SD card with a full-featured web interface and UDP synchronization. With configurable SPI pin settings, it integrates seamlessly into WLED without modifying the core code. + +For further customization or support, please refer to the project documentation or open an issue on GitHub. \ No newline at end of file diff --git a/usermods/FSEQ/auto_fseq_sd.py b/usermods/FSEQ/auto_fseq_sd.py new file mode 100644 index 0000000000..7c8e12ec82 --- /dev/null +++ b/usermods/FSEQ/auto_fseq_sd.py @@ -0,0 +1,43 @@ +Import("env") + +# Reference to the current build environment +projenv = env + +# Read the custom_usermods option from platformio.ini (WLED 0.16 structure) +custom_usermods = projenv.GetProjectOption("custom_usermods", default="") + +# Convert the string into a clean uppercase list +# Supports comma or space separated entries +usermod_list = [ + u.strip().upper() + for u in custom_usermods.replace(",", " ").split() +] + +# Check if FSEQ or wildcard "*" is selected +fseq_enabled = ( + "FSEQ" in usermod_list or + "*" in usermod_list +) + +# Get current CPPDEFINES (build flags) +cpp_defines = projenv.get("CPPDEFINES", []) + +# Extract define names into a simple list +define_names = [] +for d in cpp_defines: + if isinstance(d, tuple): + define_names.append(d[0]) + else: + define_names.append(d) + +# Check if MMC or SPI is already enabled +mmc_enabled = "WLED_USE_SD_MMC" in define_names +spi_enabled = "WLED_USE_SD_SPI" in define_names + +# Logic: +# If FSEQ usermod is selected +# AND neither MMC nor SPI is already defined +# then automatically enable SPI +if fseq_enabled and not mmc_enabled and not spi_enabled: + print("FSEQ usermod detected -> enabling WLED_USE_SD_SPI") + projenv.Append(CPPDEFINES=["WLED_USE_SD_SPI"]) \ No newline at end of file diff --git a/usermods/FSEQ/fseq_player.cpp b/usermods/FSEQ/fseq_player.cpp new file mode 100644 index 0000000000..d12a492072 --- /dev/null +++ b/usermods/FSEQ/fseq_player.cpp @@ -0,0 +1,299 @@ +#include "fseq_player.h" +#include "usermod_fseq.h" +#include "wled.h" +#include + +#ifdef WLED_USE_SD_SPI +#include +#include +#elif defined(WLED_USE_SD_MMC) +#include "SD_MMC.h" +#endif + +// Static member definitions moved from header to avoid multiple definition +// errors +const char UsermodFseq::_name[] PROGMEM = "usermod FSEQ sd card"; + +#ifdef WLED_USE_SD_SPI +int8_t UsermodFseq::configPinSourceSelect = 5; +int8_t UsermodFseq::configPinSourceClock = 18; +int8_t UsermodFseq::configPinPoci = 19; +int8_t UsermodFseq::configPinPico = 23; +#endif + +File FSEQPlayer::recordingFile; +String FSEQPlayer::currentFileName = ""; +float FSEQPlayer::secondsElapsed = 0; + +uint8_t FSEQPlayer::colorChannels = 3; +int32_t FSEQPlayer::recordingRepeats = RECORDING_REPEAT_DEFAULT; +uint32_t FSEQPlayer::now = 0; +uint32_t FSEQPlayer::next_time = 0; +uint16_t FSEQPlayer::playbackLedStart = 0; +uint16_t FSEQPlayer::playbackLedStop = uint16_t(-1); +uint32_t FSEQPlayer::frame = 0; +uint16_t FSEQPlayer::buffer_size = 48; +FSEQPlayer::FileHeader FSEQPlayer::file_header; + +inline uint32_t FSEQPlayer::readUInt32() { + uint8_t buffer[4]; + if (recordingFile.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() { + uint8_t buffer[3]; + if (recordingFile.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() { + uint8_t buffer[2]; + if (recordingFile.read(buffer, 2) != 2) + return 0; + return (uint16_t)buffer[0] | ((uint16_t)buffer[1] << 8); +} + +inline uint8_t FSEQPlayer::readUInt8() { + int c = recordingFile.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() { + DEBUG_PRINTLN("FSEQ file header:"); + DEBUG_PRINTF(" channel_data_offset = %d\n", file_header.channel_data_offset); + DEBUG_PRINTF(" minor_version = %d\n", file_header.minor_version); + DEBUG_PRINTF(" major_version = %d\n", file_header.major_version); + DEBUG_PRINTF(" header_length = %d\n", file_header.header_length); + DEBUG_PRINTF(" channel_count = %d\n", file_header.channel_count); + DEBUG_PRINTF(" frame_count = %d\n", file_header.frame_count); + DEBUG_PRINTF(" step_time = %d\n", file_header.step_time); + DEBUG_PRINTF(" flags = %d\n", file_header.flags); +} + +void FSEQPlayer::processFrameData() { + uint16_t packetLength = file_header.channel_count; + uint16_t lastLed = + min(playbackLedStop, uint16_t(playbackLedStart + (packetLength / 3))); + char frame_data[buffer_size]; + CRGB *crgb = reinterpret_cast(frame_data); + uint16_t bytes_remaining = packetLength; + uint16_t index = playbackLedStart; + while (index < lastLed && bytes_remaining > 0) { + uint16_t length = min(bytes_remaining, buffer_size); + recordingFile.readBytes(frame_data, length); + bytes_remaining -= length; + for (uint16_t offset = 0; offset < length / 3; offset++) { + setRealtimePixel(index, crgb[offset].r, crgb[offset].g, crgb[offset].b, + 0); + if (++index > lastLed) + break; + } + } + strip.show(); + realtimeLock(3000, REALTIME_MODE_FSEQ); + next_time = now + file_header.step_time; +} + +bool FSEQPlayer::stopBecauseAtTheEnd() { + + // If we reached the last frame + if (frame >= file_header.frame_count) { + + if (recordingRepeats == RECORDING_REPEAT_LOOP) { + frame = 0; + recordingFile.seek(file_header.header_length); + return false; + } + + if (recordingRepeats > 0) { + recordingRepeats--; + frame = 0; + recordingFile.seek(file_header.header_length); + DEBUG_PRINTF("Repeat recording again for: %d\n", recordingRepeats); + return false; + } + + DEBUG_PRINTLN("Finished playing recording, disabling realtime mode"); + realtimeLock(10, REALTIME_MODE_INACTIVE); + recordingFile.close(); + clearLastPlayback(); + return true; + } + + return false; +} + +void FSEQPlayer::playNextRecordingFrame() { + if (stopBecauseAtTheEnd()) + return; + uint32_t offset = file_header.channel_count * frame++; + offset += file_header.channel_data_offset; + if (!recordingFile.seek(offset)) { + if (recordingFile.position() != offset) { + DEBUG_PRINTLN("Failed to seek to proper offset for channel data!"); + return; + } + } + processFrameData(); +} + +void FSEQPlayer::handlePlayRecording() { + now = millis(); + if (realtimeMode != REALTIME_MODE_FSEQ) + return; + if (now < next_time) + return; + playNextRecordingFrame(); +} + +void FSEQPlayer::loadRecording(const char *filepath, uint16_t startLed, + uint16_t stopLed, float secondsElapsed) { + if (recordingFile.available()) { + clearLastPlayback(); + recordingFile.close(); + } + playbackLedStart = startLed; + playbackLedStop = stopLed; + if (playbackLedStart == uint16_t(-1) || playbackLedStop == uint16_t(-1)) { + Segment sg = strip.getSegment(-1); + playbackLedStart = sg.start; + playbackLedStop = sg.stop; + } + DEBUG_PRINTF("FSEQ load animation on LED %d to %d\n", playbackLedStart, + playbackLedStop); + if (fileOnSD(filepath)) { + DEBUG_PRINTF("Read file from SD: %s\n", filepath); + recordingFile = SD_ADAPTER.open(filepath, "rb"); + currentFileName = String(filepath); + if (currentFileName.startsWith("/")) + currentFileName = currentFileName.substring(1); + } else if (fileOnFS(filepath)) { + DEBUG_PRINTF("Read file from FS: %s\n", filepath); + } else { + DEBUG_PRINTF("File %s not found (%s)\n", filepath, + USED_STORAGE_FILESYSTEMS); + return; + } + if ((uint64_t)recordingFile.available() < sizeof(file_header)) { + DEBUG_PRINTF("Invalid file size: %d\n", recordingFile.available()); + recordingFile.close(); + return; + } + for (int i = 0; i < 4; i++) { + file_header.identifier[i] = readUInt8(); + } + file_header.channel_data_offset = readUInt16(); + file_header.minor_version = readUInt8(); + file_header.major_version = readUInt8(); + file_header.header_length = readUInt16(); + file_header.channel_count = readUInt32(); + file_header.frame_count = readUInt32(); + file_header.step_time = readUInt8(); + file_header.flags = readUInt8(); + printHeaderInfo(); + if (file_header.identifier[0] != 'P' || file_header.identifier[1] != 'S' || + file_header.identifier[2] != 'E' || file_header.identifier[3] != 'Q') { + DEBUG_PRINTF("Error reading FSEQ file %s header, invalid identifier\n", + filepath); + recordingFile.close(); + return; + } + if (((uint64_t)file_header.channel_count * + (uint64_t)file_header.frame_count) + + file_header.header_length > + UINT32_MAX) { + DEBUG_PRINTF("Error reading FSEQ file %s header, file too long (max 4gb)\n", + filepath); + recordingFile.close(); + return; + } + if (file_header.step_time < 1) { + DEBUG_PRINTF("Invalid step time %d, using default %d instead\n", + file_header.step_time, FSEQ_DEFAULT_STEP_TIME); + file_header.step_time = FSEQ_DEFAULT_STEP_TIME; + } + if (realtimeOverride == REALTIME_OVERRIDE_ONCE) { + realtimeOverride = REALTIME_OVERRIDE_NONE; + } + frame = (uint32_t)((secondsElapsed * 1000.0f) / file_header.step_time); + if (frame >= file_header.frame_count) { + frame = file_header.frame_count - 1; + } + // Set loop mode if secondsElapsed is exactly 1.0f + if (fabs(secondsElapsed - 1.0f) < 0.001f) { + recordingRepeats = RECORDING_REPEAT_LOOP; + } else { + recordingRepeats = RECORDING_REPEAT_DEFAULT; + } + playNextRecordingFrame(); + playNextRecordingFrame(); +} + +void FSEQPlayer::clearLastPlayback() { + for (uint16_t i = playbackLedStart; i < playbackLedStop; i++) { + setRealtimePixel(i, 0, 0, 0, 0); + } + frame = 0; + recordingFile.close(); + currentFileName = ""; +} + +bool FSEQPlayer::isPlaying() { + return recordingFile && frame < file_header.frame_count; +} + +String FSEQPlayer::getFileName() { return currentFileName; } + +float FSEQPlayer::getElapsedSeconds() { + if (!isPlaying()) + return 0; + // Calculate approximate elapsed seconds based on frame and step time + // Or if secondsElapsed is updated elsewhere, return it. + // Ideally secondsElapsed should be updated during playback. + // But for now, let's just calculate it from frame count + return (float)frame * (float)file_header.step_time / 1000.0f; +} + +void FSEQPlayer::syncPlayback(float secondsElapsed) { + if (!isPlaying()) { + DEBUG_PRINTLN("[FSEQ] Sync: Playback not active, cannot sync."); + return; + } + + // Update internal secondsElapsed if we were tracking it + // FSEQPlayer::secondsElapsed = secondsElapsed; // If we were tracking it + + uint32_t expectedFrame = + (uint32_t)((secondsElapsed * 1000.0f) / file_header.step_time); + int32_t diff = (int32_t)expectedFrame - (int32_t)frame; + + if (abs(diff) > 2) { + frame = expectedFrame; + uint32_t offset = + file_header.channel_data_offset + file_header.channel_count * frame; + if (recordingFile.seek(offset)) { + DEBUG_PRINTF("[FSEQ] Sync: Adjusted frame to %lu (diff=%ld)\n", + expectedFrame, diff); + } else { + DEBUG_PRINTLN("[FSEQ] Sync: Failed to seek to new frame"); + } + } else { + DEBUG_PRINTF("[FSEQ] Sync: No adjustment needed (current frame: %lu, " + "expected: %lu)\n", + frame, expectedFrame); + } +} diff --git a/usermods/FSEQ/fseq_player.h b/usermods/FSEQ/fseq_player.h new file mode 100644 index 0000000000..3c854ea641 --- /dev/null +++ b/usermods/FSEQ/fseq_player.h @@ -0,0 +1,76 @@ +#ifndef FSEQ_PLAYER_H +#define FSEQ_PLAYER_H + +#ifndef RECORDING_REPEAT_LOOP +#define RECORDING_REPEAT_LOOP -1 +#endif +#ifndef RECORDING_REPEAT_DEFAULT +#define RECORDING_REPEAT_DEFAULT 0 +#endif +#ifndef REALTIME_MODE_FSEQ +#define REALTIME_MODE_FSEQ 10 +#endif + +#include "wled.h" +#ifdef WLED_USE_SD_SPI +#include +#include +#elif defined(WLED_USE_SD_MMC) +#include "SD_MMC.h" +#endif + +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 loadRecording(const char *filepath, uint16_t startLed, + uint16_t stopLed, float secondsElapsed = 0.0f); + static void handlePlayRecording(); + static void clearLastPlayback(); + static void syncPlayback(float secondsElapsed); + static bool isPlaying(); + static String getFileName(); + static float getElapsedSeconds(); + +private: + FSEQPlayer() {} + + static const int FSEQ_DEFAULT_STEP_TIME = 50; + + static File recordingFile; + static String currentFileName; + static float secondsElapsed; + static uint8_t colorChannels; + static int32_t recordingRepeats; + static uint32_t now; + static uint32_t next_time; + static uint16_t playbackLedStart; + static uint16_t playbackLedStop; + static uint32_t frame; + static uint16_t buffer_size; + static FileHeader file_header; + + static inline uint32_t readUInt32(); + static inline uint32_t readUInt24(); + static inline uint16_t readUInt16(); + static inline uint8_t readUInt8(); + + static bool fileOnSD(const char *filepath); + static bool fileOnFS(const char *filepath); + static void printHeaderInfo(); + static void processFrameData(); + static bool stopBecauseAtTheEnd(); + static void playNextRecordingFrame(); +}; + +#endif // FSEQ_PLAYER_H \ No newline at end of file diff --git a/usermods/FSEQ/library.json b/usermods/FSEQ/library.json new file mode 100644 index 0000000000..25722cc429 --- /dev/null +++ b/usermods/FSEQ/library.json @@ -0,0 +1,7 @@ +{ + "name": "FSEQ", + "build": { + "libArchive": false, + "extraScript": "auto_fseq_sd.py" + } +} \ 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_manager.cpp b/usermods/FSEQ/sd_manager.cpp new file mode 100644 index 0000000000..d4a1e3bae0 --- /dev/null +++ b/usermods/FSEQ/sd_manager.cpp @@ -0,0 +1,44 @@ +#include "sd_manager.h" +#include "usermod_fseq.h" + +bool SDManager::begin() { +#ifdef WLED_USE_SD_SPI + if (!SD_ADAPTER.begin(WLED_PIN_SS, spiPort)) + return false; +#elif defined(WLED_USE_SD_MMC) + if (!SD_ADAPTER.begin()) + return false; +#endif + return true; +} + +void SDManager::end() { SD_ADAPTER.end(); } + +String SDManager::listFiles(const char *dirname) { + String result = ""; + File root = SD_ADAPTER.open(dirname); + if (!root) { + result += "
  • Failed to open directory: "; + result += dirname; + result += "
  • "; + return result; + } + if (!root.isDirectory()) { + result += "
  • Not a directory: "; + result += dirname; + result += "
  • "; + return result; + } + File file = root.openNextFile(); + while (file) { + result += "
  • "; + result += file.name(); + result += " (" + String(file.size()) + " bytes)
  • "; + file.close(); + file = root.openNextFile(); + } + root.close(); + return result; +} + +bool SDManager::deleteFile(const char *path) { return SD_ADAPTER.remove(path); } \ No newline at end of file diff --git a/usermods/FSEQ/sd_manager.h b/usermods/FSEQ/sd_manager.h new file mode 100644 index 0000000000..c5ac79c67d --- /dev/null +++ b/usermods/FSEQ/sd_manager.h @@ -0,0 +1,22 @@ +#ifndef SD_MANAGER_H +#define SD_MANAGER_H + +#include "wled.h" + +#ifdef WLED_USE_SD_SPI + #include + #include +#elif defined(WLED_USE_SD_MMC) + #include "SD_MMC.h" +#endif + +class SDManager { + public: + SDManager() {} + bool begin(); + void end(); + String listFiles(const char* dirname); + bool deleteFile(const char* path); +}; + +#endif // SD_MANAGER_H \ No newline at end of file diff --git a/usermods/FSEQ/usermod_fpp.h b/usermods/FSEQ/usermod_fpp.h new file mode 100644 index 0000000000..52f5e7e818 --- /dev/null +++ b/usermods/FSEQ/usermod_fpp.h @@ -0,0 +1,752 @@ +#pragma once + +#include "usermod_fseq.h" // Contains FSEQ playback logic and getter methods for pins +#include "wled.h" + +#ifdef WLED_USE_SD_SPI +#include +#include +#elif defined(WLED_USE_SD_MMC) +#include "SD_MMC.h" +#endif + +#include +#include + +// ----- Minimal WriteBufferingStream Implementation ----- +// This class buffers data before writing it to an underlying Stream. +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); + } + // Write a block of data to the buffer + size_t write(const uint8_t *buffer, size_t size) override { + 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) + flush(); + } + return total; + } + // Write a single byte + size_t write(uint8_t b) override { return write(&b, 1); } + // Flush the buffer to the upstream stream + void flush() override { + if (_offset > 0) { + _upstream.write(_buffer, _offset); + _offset = 0; + } + _upstream.flush(); + } + int available() override { return _upstream.available(); } + int read() override { return _upstream.read(); } + int peek() override { return _upstream.peek(); } + +private: + Stream &_upstream; + uint8_t *_buffer = nullptr; + size_t _capacity = 0; + size_t _offset = 0; +}; +// ----- End WriteBufferingStream ----- + +#define FILE_UPLOAD_BUFFER_SIZE 8192 + +// Definitions for UDP (FPP) synchronization +#define CTRL_PKT_SYNC 1 +#define CTRL_PKT_PING 4 +#define CTRL_PKT_BLANK 3 + +// UDP port for FPP discovery/synchronization +const uint16_t UDP_SYNC_PORT = 32320; + +unsigned long lastPingTime = 0; +const unsigned long pingInterval = 5000; + +// Inline functions to write 16-bit and 32-bit values +static inline void write16(uint8_t *dest, uint16_t value) { + dest[0] = (value >> 8) & 0xff; + dest[1] = value & 0xff; +} + +static inline void write32(uint8_t *dest, uint32_t value) { + dest[0] = (value >> 24) & 0xff; + dest[1] = (value >> 16) & 0xff; + dest[2] = (value >> 8) & 0xff; + dest[3] = value & 0xff; +} + +// Structure for the synchronization packet +// Using pragma pack to avoid any padding issues +#pragma pack(push, 1) +struct FPPMultiSyncPacket { + uint8_t header[4]; // e.g. "FPPD" + uint8_t packet_type; // e.g. CTRL_PKT_SYNC + uint16_t data_len; // data length + uint8_t sync_action; // action: start, stop, sync, open, etc. + uint8_t sync_type; // sync type, e.g. 0 for FSEQ + uint32_t frame_number; // current frame number + float seconds_elapsed; // elapsed seconds + char filename[64]; // name of the file to play + uint8_t raw[128]; // raw packet data +}; +#pragma pack(pop) + +// UsermodFPP class: Implements FPP (FSEQ/UDP) functionality +class UsermodFPP : public Usermod { +private: + AsyncUDP udp; // UDP object for FPP discovery/sync + bool udpStarted = false; // Flag to indicate UDP listener status + const IPAddress multicastAddr = + IPAddress(239, 70, 80, 80); // Multicast address + const uint16_t udpPort = UDP_SYNC_PORT; // UDP port + + // Variables for FSEQ file upload + File currentUploadFile; + String currentUploadFileName = ""; + unsigned long uploadStartTime = 0; + WriteBufferingStream *uploadStream = nullptr; + + // Returns device name from server description + String getDeviceName() { return String(serverDescription); } + + // Build JSON with system information + String buildSystemInfoJSON() { + DynamicJsonDocument doc(1024); + + String devName = getDeviceName(); + + doc["HostName"] = devName; + doc["HostDescription"] = "WLED"; + doc["Platform"] = "ESP32"; + doc["Variant"] = "WLED"; + doc["Mode"] = "remote"; + doc["Version"] = versionString; + + doc["majorVersion"] = 16; + doc["minorVersion"] = 0; + doc["typeId"] = 195; + doc["UUID"] = WiFi.macAddress(); + + 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; + } + + // Build JSON with system status + String buildSystemStatusJSON() { + + DynamicJsonDocument doc(2048); + + // -------------------------------------------------- + // MQTT + // -------------------------------------------------- + JsonObject mqtt = doc.createNestedObject("MQTT"); + mqtt["configured"] = false; + mqtt["connected"] = false; + + // -------------------------------------------------- + // Playlist Info + // -------------------------------------------------- + JsonObject currentPlaylist = doc.createNestedObject("current_playlist"); + currentPlaylist["count"] = "0"; + currentPlaylist["description"] = ""; + currentPlaylist["index"] = "0"; + currentPlaylist["playlist"] = ""; + currentPlaylist["type"] = ""; + + // -------------------------------------------------- + // Basic Status + // -------------------------------------------------- + doc["volume"] = 70; + doc["media_filename"] = ""; + doc["fppd"] = "running"; + doc["current_song"] = ""; + + if (FSEQPlayer::isPlaying()) { + + String fileName = FSEQPlayer::getFileName(); + uint32_t elapsed = FSEQPlayer::getElapsedSeconds(); + + 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[6]; + sprintf(timeStr, "%02d:%02d", 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"; + } + + // -------------------------------------------------- + // Advanced View (entscheidend für ESPixelStick!) + // -------------------------------------------------- + JsonObject adv = doc.createNestedObject("advancedView"); + + adv["HostName"] = getDeviceName(); + adv["HostDescription"] = "WLED"; + adv["Platform"] = "WLED"; + adv["Variant"] = "ESP32"; + adv["Mode"] = "remote"; + adv["Version"] = versionString; + + // Version aus versionString extrahieren (z.B. 16.0-alpha) + 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(); + minor = 0; + } + + adv["majorVersion"] = major; + adv["minorVersion"] = minor; + adv["typeId"] = 165; + 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()); + + // -------------------------------------------------- + // Serialize + // -------------------------------------------------- + String json; + serializeJson(doc, json); + return json; + } + + // Build JSON for FPP multi-sync systems + String buildFppdMultiSyncSystemsJSON() { + DynamicJsonDocument doc(1024); + + JsonArray systems = doc.createNestedArray("systems"); + JsonObject sys = systems.createNestedObject(); + + String devName = getDeviceName(); + + sys["hostname"] = devName; + sys["id"] = WiFi.macAddress(); + sys["ip"] = WiFi.localIP().toString(); + sys["version"] = versionString; + sys["hardwareType"] = "WLED"; + sys["type"] = 165; + sys["num_chan"] = strip.getLength() * 3; + sys["NumPixelPort"] = 1; + sys["NumSerialPort"] = 0; + sys["mode"] = "remote"; + + String json; + serializeJson(doc, json); + return json; + } + + // UDP - send a ping packet +void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { + uint8_t buf[301]; + memset(buf, 0, sizeof(buf)); + + // -------------------------------------------------- + // Header + // -------------------------------------------------- + buf[0] = 'F'; + buf[1] = 'P'; + buf[2] = 'P'; + buf[3] = 'D'; + + buf[4] = 0x04; // PacketType = Ping + + // ExtraDataLen = 294 (Ping v3) -> LITTLE ENDIAN + uint16_t dataLen = 294; + buf[5] = dataLen & 0xFF; + buf[6] = (dataLen >> 8) & 0xFF; + + buf[7] = 0x03; // Ping packet version = 3 + buf[8] = 0x00; // SubType = Ping + + buf[9] = 0xC3; // Hardware Type = ESPixelStick ESP32 + + // -------------------------------------------------- + // Version (MSB first!) + // -------------------------------------------------- + uint16_t versionMajor = 0; + uint16_t 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; + + // -------------------------------------------------- + // Operating Mode Flags + // 0x08 = Remote + // -------------------------------------------------- + buf[14] = 0x08; + + // -------------------------------------------------- + // IP Address + // -------------------------------------------------- + IPAddress ip = WiFi.localIP(); + buf[15] = ip[0]; + buf[16] = ip[1]; + buf[17] = ip[2]; + buf[18] = ip[3]; + + // -------------------------------------------------- + // Hostname (19-83) 64 bytes + NULL + // -------------------------------------------------- + String hostName = getDeviceName(); + if (hostName.length() > 64) + hostName = hostName.substring(0, 64); + + for (int i = 0; i < 64; i++) { + buf[19 + i] = (i < hostName.length()) ? hostName[i] : 0; + } + + // -------------------------------------------------- + // Version String (84-124) 40 bytes + NULL + // -------------------------------------------------- + String verStr = versionString; + for (int i = 0; i < 40; i++) { + buf[84 + i] = (i < verStr.length()) ? verStr[i] : 0; + } + + // -------------------------------------------------- + // Hardware Type String (125-165) 40 bytes + NULL + // -------------------------------------------------- + String hwType = "WLED"; + for (int i = 0; i < 40; i++) { + buf[125 + i] = (i < hwType.length()) ? hwType[i] : 0; + } + + // -------------------------------------------------- + // Channel Ranges (166-286) 120 bytes + NULL + // -------------------------------------------------- + String channelRanges = ""; + for (int i = 0; i < 120; i++) { + buf[166 + i] = (i < channelRanges.length()) ? channelRanges[i] : 0; + } + + // -------------------------------------------------- + // Send packet + // -------------------------------------------------- + udp.writeTo(buf, sizeof(buf), destination, udpPort); +} + +/* // UDP - send a sync message + void sendSyncMessage(uint8_t action, const String &fileName, + uint32_t currentFrame, float secondsElapsed) { + FPPMultiSyncPacket syncPacket; + // Fill in header "FPPD" + syncPacket.header[0] = 'F'; + syncPacket.header[1] = 'P'; + syncPacket.header[2] = 'P'; + syncPacket.header[3] = 'D'; + syncPacket.packet_type = CTRL_PKT_SYNC; + write16((uint8_t *)&syncPacket.data_len, sizeof(syncPacket)); + syncPacket.sync_action = action; + syncPacket.sync_type = 0; // FSEQ synchronization + write32((uint8_t *)&syncPacket.frame_number, currentFrame); + syncPacket.seconds_elapsed = secondsElapsed; + strncpy(syncPacket.filename, fileName.c_str(), + sizeof(syncPacket.filename) - 1); + syncPacket.filename[sizeof(syncPacket.filename) - 1] = 0x00; + // Send to both broadcast and multicast addresses + udp.writeTo((uint8_t *)&syncPacket, sizeof(syncPacket), + IPAddress(255, 255, 255, 255), udpPort); + udp.writeTo((uint8_t *)&syncPacket, sizeof(syncPacket), multicastAddr, + udpPort); + } */ + + // UDP - process received packet + void processUdpPacket(AsyncUDPPacket packet) { + // Print the raw UDP packet in hex format for debugging + DEBUG_PRINTLN(F("[FPP] Raw UDP Packet:")); + for (size_t i = 0; i < packet.length(); i++) { + DEBUG_PRINTF("%02X ", packet.data()[i]); + } + DEBUG_PRINTLN(); + + if (packet.length() < 4) + 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: { + FPPMultiSyncPacket *syncPacket = + reinterpret_cast(packet.data()); + DEBUG_PRINTLN(F("[FPP] Received UDP sync packet")); + // Print detailed sync packet information: + DEBUG_PRINTF("[FPP] Sync Packet - Action: %d\n", syncPacket->sync_action); + DEBUG_PRINT(F("[FPP] Filename: ")); + DEBUG_PRINTLN(syncPacket->filename); + DEBUG_PRINTF("[FPP] Frame Number: %lu\n", syncPacket->frame_number); + DEBUG_PRINTF("[FPP] Seconds Elapsed: %.2f\n", + syncPacket->seconds_elapsed); + ProcessSyncPacket(syncPacket->sync_action, String(syncPacket->filename), + syncPacket->seconds_elapsed); + break; + } + case CTRL_PKT_PING: + DEBUG_PRINTLN(F("[FPP] Received UDP ping packet")); + sendPingPacket(packet.remoteIP()); + break; + case CTRL_PKT_BLANK: + DEBUG_PRINTLN(F("[FPP] Received UDP blank packet")); + FSEQPlayer::clearLastPlayback(); + realtimeLock(10, REALTIME_MODE_INACTIVE); + break; + default: + DEBUG_PRINTLN(F("[FPP] Unknown UDP packet type")); + break; + } + } + + // Process sync command with detailed debug output + void ProcessSyncPacket(uint8_t action, String fileName, + float secondsElapsed) { + // Ensure the filename is absolute + if (!fileName.startsWith("/")) { + fileName = "/" + fileName; + } + + DEBUG_PRINTLN(F("[FPP] ProcessSyncPacket: Sync command received")); + DEBUG_PRINTF("[FPP] Action: %d\n", action); + DEBUG_PRINT(F("[FPP] FileName: ")); + DEBUG_PRINTLN(fileName); + DEBUG_PRINTF("[FPP] Seconds Elapsed: %.2f\n", secondsElapsed); + + switch (action) { + case 0: // SYNC_PKT_START + FSEQPlayer::loadRecording(fileName.c_str(), 0, strip.getLength(), + secondsElapsed); + break; + case 1: // SYNC_PKT_STOP + FSEQPlayer::clearLastPlayback(); + realtimeLock(10, REALTIME_MODE_INACTIVE); + break; + case 2: // SYNC_PKT_SYNC + DEBUG_PRINTLN(F("[FPP] ProcessSyncPacket: Sync command received")); + DEBUG_PRINTF("[FPP] Sync Packet - FileName: %s, Seconds Elapsed: %.2f\n", + fileName.c_str(), secondsElapsed); + if (!FSEQPlayer::isPlaying()) { + DEBUG_PRINTLN(F("[FPP] Sync: Playback not active, starting playback.")); + FSEQPlayer::loadRecording(fileName.c_str(), 0, strip.getLength(), + secondsElapsed); + } else { + FSEQPlayer::syncPlayback(secondsElapsed); + } + break; + case 3: // SYNC_PKT_OPEN + DEBUG_PRINTLN(F( + "[FPP] Open command received – metadata request (not implemented)")); + break; + default: + DEBUG_PRINTLN(F("[FPP] ProcessSyncPacket: Unknown sync action")); + break; + } + } + +public: + static const char _name[]; + + // Setup function called once at startup + void setup() { + DEBUG_PRINTF("[%s] FPP Usermod loaded\n", _name); +#ifdef WLED_USE_SD_SPI + int8_t csPin = UsermodFseq::getCsPin(); + int8_t sckPin = UsermodFseq::getSckPin(); + int8_t misoPin = UsermodFseq::getMisoPin(); + int8_t mosiPin = UsermodFseq::getMosiPin(); + if (!SD.begin(csPin)) { + DEBUG_PRINTF("[%s] ERROR: SD.begin() failed with CS pin %d!\n", _name, + csPin); + } else { + DEBUG_PRINTF("[%s] SD card initialized (SPI) with CS pin %d\n", _name, + csPin); + } +#elif defined(WLED_USE_SD_MMC) + if (!SD_MMC.begin()) { + DEBUG_PRINTF("[%s] ERROR: SD_MMC.begin() failed!\n", _name); + } else { + DEBUG_PRINTF("[%s] SD card initialized (MMC)\n", _name); + } +#endif + + // Register API endpoints + server.on("/api/system/info", HTTP_GET, + [this](AsyncWebServerRequest *request) { + String json = buildSystemInfoJSON(); + request->send(200, "application/json", json); + }); + server.on("/api/system/status", HTTP_GET, + [this](AsyncWebServerRequest *request) { + String json = buildSystemStatusJSON(); + request->send(200, "application/json", json); + }); + server.on("/api/fppd/multiSyncSystems", HTTP_GET, + [this](AsyncWebServerRequest *request) { + String json = buildFppdMultiSyncSystemsJSON(); + request->send(200, "application/json", json); + }); + // Other API endpoints as needed... + + // Endpoint for file upload (raw, application/octet-stream) + server.on( + "/fpp", HTTP_POST, + [](AsyncWebServerRequest *request) { + }, + NULL, + [this](AsyncWebServerRequest *request, + uint8_t *data, size_t len, + size_t index, size_t total) { + + // Debug optional: + DEBUG_PRINTF("[FPP] Chunk index=%u len=%u total=%u\n", index, len, total); + + if (index == 0) { + + DEBUG_PRINTLN("[FPP] Starting file upload"); + + if (uploadStream) { + uploadStream->flush(); + delete uploadStream; + uploadStream = nullptr; + } + + if (currentUploadFile) { + currentUploadFile.close(); + } + + String fileParam = ""; + if (request->hasParam("filename")) { + fileParam = request->arg("filename"); + } + + currentUploadFileName = + (fileParam != "") + ? (fileParam.startsWith("/") ? fileParam : "/" + fileParam) + : "/default.fseq"; + + DEBUG_PRINTF("[FPP] Using filename: %s\n", + currentUploadFileName.c_str()); + + if (SD.exists(currentUploadFileName.c_str())) { + SD.remove(currentUploadFileName.c_str()); + } + + currentUploadFile = + SD.open(currentUploadFileName.c_str(), FILE_WRITE); + + if (!currentUploadFile) { + DEBUG_PRINTLN(F("[FPP] ERROR: Failed to open file")); + request->send(500, "text/plain", "File open failed"); + return; + } + + uploadStream = new WriteBufferingStream( + currentUploadFile, FILE_UPLOAD_BUFFER_SIZE); + + uploadStartTime = millis(); + } + + if (uploadStream) { + uploadStream->write(data, len); + } + + if (index + len == total) { + + DEBUG_PRINTLN("[FPP] Upload finished"); + + if (uploadStream) { + uploadStream->flush(); + delete uploadStream; + uploadStream = nullptr; + } + + if (currentUploadFile) { + currentUploadFile.close(); + } + + unsigned long duration = millis() - uploadStartTime; + DEBUG_PRINTF("[FPP] Upload complete in %lu ms\n", duration); + + currentUploadFileName = ""; + + request->send(200, "text/plain", "Upload complete"); + } + }); + + + // Endpoint to list FSEQ files on SD card + 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); + }); + + // Endpoint to start FSEQ playback + server.on("/fpp/connect", HTTP_GET, [this](AsyncWebServerRequest *request) { + if (!request->hasArg("file")) { + request->send(400, "text/plain", "Missing 'file' parameter"); + return; + } + String filepath = request->arg("file"); + if (!filepath.startsWith("/")) { + filepath = "/" + filepath; + } + // Use FSEQPlayer to start playback + FSEQPlayer::loadRecording(filepath.c_str(), 0, strip.getLength()); + request->send(200, "text/plain", "FPP connect started: " + filepath); + }); + // Endpoint to stop FSEQ playback + server.on("/fpp/stop", HTTP_GET, [this](AsyncWebServerRequest *request) { + FSEQPlayer::clearLastPlayback(); + realtimeLock(10, REALTIME_MODE_INACTIVE); + request->send(200, "text/plain", "FPP connect stopped"); + }); + + // Initialize UDP listener for synchronization and ping + if (!udpStarted && (WiFi.status() == WL_CONNECTED)) { + if (udp.listenMulticast(multicastAddr, udpPort)) { + udpStarted = true; + udp.onPacket( + [this](AsyncUDPPacket packet) { processUdpPacket(packet); }); + DEBUG_PRINTLN(F("[FPP] UDP listener started on multicast")); + } + } + } + + // Main loop function + void loop() { + if (!udpStarted && (WiFi.status() == WL_CONNECTED)) { + if (udp.listenMulticast(multicastAddr, udpPort)) { + udpStarted = true; + udp.onPacket( + [this](AsyncUDPPacket packet) { processUdpPacket(packet); }); + DEBUG_PRINTLN(F("[FPP] UDP listener started on multicast")); + } + } + + if (udpStarted && WiFi.status() == WL_CONNECTED) { + + if (millis() - lastPingTime > pingInterval) { + sendPingPacket(); + lastPingTime = millis(); + } + } + + // Process FSEQ playback + FSEQPlayer::handlePlayRecording(); + } + + uint16_t getId() { return USERMOD_ID_SD_CARD; } + void addToConfig(JsonObject &root) {} + bool readFromConfig(JsonObject &root) { return true; } +}; + +const char UsermodFPP::_name[] PROGMEM = "FPP Connect"; \ No newline at end of file diff --git a/usermods/FSEQ/usermod_fseq.h b/usermods/FSEQ/usermod_fseq.h new file mode 100644 index 0000000000..7c43e85fbe --- /dev/null +++ b/usermods/FSEQ/usermod_fseq.h @@ -0,0 +1,188 @@ +#pragma once + +#ifndef USED_STORAGE_FILESYSTEMS +#ifdef WLED_USE_SD_SPI +#define USED_STORAGE_FILESYSTEMS "SD SPI, LittleFS" +#else +#define USED_STORAGE_FILESYSTEMS "SD MMC, LittleFS" +#endif +#endif + +#include "wled.h" +#ifdef WLED_USE_SD_SPI +#include +#include +#endif + +#ifndef SD_ADAPTER +#if defined(WLED_USE_SD) || defined(WLED_USE_SD_SPI) +#ifdef WLED_USE_SD_SPI +#ifndef WLED_USE_SD +#define WLED_USE_SD +#endif +#ifndef WLED_PIN_SCK +#define WLED_PIN_SCK SCK +#endif +#ifndef WLED_PIN_MISO +#define WLED_PIN_MISO MISO +#endif +#ifndef WLED_PIN_MOSI +#define WLED_PIN_MOSI MOSI +#endif +#ifndef WLED_PIN_SS +#define WLED_PIN_SS SS +#endif +#define SD_ADAPTER SD +#else +#define SD_ADAPTER SD_MMC +#endif +#endif +#endif + +#ifdef WLED_USE_SD_SPI +#ifndef SPI_PORT_DEFINED +#if CONFIG_IDF_TARGET_ESP32 +inline SPIClass spiPort = SPIClass(VSPI); +#elif CONFIG_IDF_TARGET_ESP32S3 +inline SPIClass spiPort = SPI; +#else +inline SPIClass spiPort = SPI; +#endif +#define SPI_PORT_DEFINED +#include "../usermods/FSEQ/fseq_player.h" +#include "../usermods/FSEQ/sd_manager.h" +#include "../usermods/FSEQ/web_ui_manager.h" +#include "wled.h" +#endif +#endif + +// Usermod for FSEQ playback with UDP and web UI support +class UsermodFseq : public Usermod { +private: + WebUIManager webUI; // Web UI Manager module (handles endpoints) + static const char _name[]; // for storing usermod name in config + +public: + // Setup function called once at startup + void setup() { + DEBUG_PRINTF("[%s] Usermod loaded\n", FPSTR(_name)); + + // Initialize SD card using SDManager + SDManager sd; + if (!sd.begin()) { + DEBUG_PRINTF("[%s] SD initialization FAILED.\n", FPSTR(_name)); + } else { + DEBUG_PRINTF("[%s] SD initialization successful.\n", FPSTR(_name)); + } + + // Register web endpoints defined in WebUIManager + webUI.registerEndpoints(); + } + + // Loop function called continuously + void loop() { + // Process FSEQ playback (includes UDP sync commands) + FSEQPlayer::handlePlayRecording(); + } + + // Unique ID for the usermod + uint16_t getId() override { return USERMOD_ID_SD_CARD; } + + // 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); + } + + // Save your SPI pins to WLED config + void addToConfig(JsonObject &root) override { + + #ifdef WLED_USE_SD_SPI + + JsonObject top = root.createNestedObject(FPSTR(_name)); + + top["csPin"] = configPinSourceSelect; + top["sckPin"] = configPinSourceClock; + top["misoPin"] = configPinPoci; + top["mosiPin"] = configPinPico; + + #endif + } + + // Read your SPI pins from WLED config JSON + bool readFromConfig(JsonObject &root) override { +#ifdef WLED_USE_SD_SPI + JsonObject top = root[FPSTR(_name)]; + if (top.isNull()) + return false; + + if (top["csPin"].is()) + configPinSourceSelect = top["csPin"].as(); + if (top["sckPin"].is()) + configPinSourceClock = top["sckPin"].as(); + if (top["misoPin"].is()) + configPinPoci = top["misoPin"].as(); + if (top["mosiPin"].is()) + configPinPico = top["mosiPin"].as(); + + reinit_SD_SPI(); // reinitialize SD with new pins + return true; +#else + return false; +#endif + } + +#ifdef WLED_USE_SD_SPI + // Reinitialize SD SPI with updated pins + void reinit_SD_SPI() { + // Deinit SD if needed + SD_ADAPTER.end(); + // Reallocate pins + PinManager::deallocatePin(configPinSourceSelect, PinOwner::UM_SdCard); + PinManager::deallocatePin(configPinSourceClock, PinOwner::UM_SdCard); + PinManager::deallocatePin(configPinPoci, PinOwner::UM_SdCard); + PinManager::deallocatePin(configPinPico, PinOwner::UM_SdCard); + + PinManagerPinType pins[4] = {{configPinSourceSelect, true}, + {configPinSourceClock, true}, + {configPinPoci, false}, + { configPinPico, + true }}; + if (!PinManager::allocateMultiplePins(pins, 4, PinOwner::UM_SdCard)) { + DEBUG_PRINTF("[%s] SPI pin allocation failed!\n", FPSTR(_name)); + return; + } + + // Reinit SPI with new pins + spiPort.begin(configPinSourceClock, configPinPoci, configPinPico, + configPinSourceSelect); + + // Try to begin SD again + if (!SD_ADAPTER.begin(configPinSourceSelect, spiPort)) { + DEBUG_PRINTF("[%s] SPI begin failed!\n", FPSTR(_name)); + } else { + DEBUG_PRINTF("[%s] SD SPI reinitialized with new pins\n", FPSTR(_name)); + } + } + + // Getter methods and static variables for SD pins + static int8_t getCsPin() { return configPinSourceSelect; } + static int8_t getSckPin() { return configPinSourceClock; } + static int8_t getMisoPin() { return configPinPoci; } + static int8_t getMosiPin() { return configPinPico; } + + static int8_t configPinSourceSelect; + static int8_t configPinSourceClock; + static int8_t configPinPoci; + static int8_t configPinPico; +#endif +}; \ No newline at end of file diff --git a/usermods/FSEQ/web_ui_manager.bak b/usermods/FSEQ/web_ui_manager.bak new file mode 100644 index 0000000000..b2fc2ac6db --- /dev/null +++ b/usermods/FSEQ/web_ui_manager.bak @@ -0,0 +1,512 @@ +#include "web_ui_manager.h" +#include "fseq_player.h" +#include "sd_manager.h" +#include "usermod_fseq.h" + +static const char PAGE_HTML[] PROGMEM = R"rawliteral( + + + + +WLED Unified UI + + + + + + + + + + +
    + +

    Unified UI

    +
    +
    + + + +
    + +
    +

    SD Storage

    +
    +
    +
    +
    +
    + +
    +

    SD Files

    +
      +
      + +
      +

      Upload File

      +

      + + +
      +
      +
      +
      +
      + +
      + +
      +
      +

      FSEQ Files

      +
        +
        +
        + + + +)rawliteral"; + + +void WebUIManager::registerEndpoints() { + + // Main UI page (navigation, SD and FSEQ tabs) + server.on("/fsequi", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send_P(200, "text/html", PAGE_HTML); + }); + + // API - List SD files (size in KB + storage info) + 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(); + + String json = "{"; + json += "\"files\":["; + + if (root && root.isDirectory()) { + bool first = true; + File file = root.openNextFile(); + + while (file) { + if (!first) json += ","; + first = false; + + float sizeKB = file.size() / 1024.0; + + json += "{"; + json += "\"name\":\"" + String(file.name()) + "\","; + json += "\"size\":" + String(sizeKB, 2); + json += "}"; + + file.close(); + file = root.openNextFile(); + } + } + + root.close(); + + json += "],"; + json += "\"usedKB\":" + String(usedBytes / 1024.0, 2) + ","; + json += "\"totalKB\":" + String(totalBytes / 1024.0, 2); + json += "}"; + + request->send(200, "application/json", json); + }); + + + // API - List FSEQ files + server.on("/api/fseq/list", HTTP_GET, [](AsyncWebServerRequest *request) { + File root = SD_ADAPTER.open("/"); + String json = "["; + if (root && root.isDirectory()) { + bool first = true; + File file = root.openNextFile(); + while (file) { + String name = file.name(); + if (name.endsWith(".fseq") || name.endsWith(".FSEQ")) { + if (!first) + json += ","; + first = false; + json += "{"; + json += "\"name\":\"" + name + "\""; + json += "}"; + } + file.close(); + file = root.openNextFile(); + } + } + root.close(); + json += "]"; + request->send(200, "application/json", json); + }); + + // API - File Upload + server.on( + "/api/sd/upload", HTTP_POST, + [](AsyncWebServerRequest *request) { + request->send(200, "text/plain", "Upload complete"); + }, + [](AsyncWebServerRequest *request, String filename, size_t index, + uint8_t *data, size_t len, bool final) { + static File uploadFile; + if (index == 0) { + if (!filename.startsWith("/")) + filename = "/" + filename; + uploadFile = SD_ADAPTER.open(filename.c_str(), FILE_WRITE); + } + if (uploadFile) { + uploadFile.write(data, len); + if (final) + uploadFile.close(); + } + }); + + // API - File Delete + server.on("/api/sd/delete", HTTP_GET, [](AsyncWebServerRequest *request) { + if (!request->hasArg("path")) { + request->send(400, "text/plain", "Missing path"); + return; + } + String path = request->arg("path"); + if (!path.startsWith("/")) + path = "/" + path; + bool res = SD_ADAPTER.remove(path.c_str()); + request->send(200, "text/plain", res ? "File deleted" : "Delete failed"); + }); + + // API - Start FSEQ (normal playback) + server.on("/api/fseq/start", HTTP_GET, [](AsyncWebServerRequest *request) { + if (!request->hasArg("file")) { + request->send(400, "text/plain", "Missing file param"); + return; + } + String filepath = request->arg("file"); + if (!filepath.startsWith("/")) + filepath = "/" + filepath; + FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 0.0f); + request->send(200, "text/plain", "FSEQ started"); + }); + + // API - Start FSEQ in loop mode + server.on( + "/api/fseq/startloop", HTTP_GET, [](AsyncWebServerRequest *request) { + if (!request->hasArg("file")) { + request->send(400, "text/plain", "Missing file param"); + return; + } + String filepath = request->arg("file"); + if (!filepath.startsWith("/")) + filepath = "/" + filepath; + // Passing 1.0f enables loop mode in loadRecording() + FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 1.0f); + request->send(200, "text/plain", "FSEQ loop started"); + }); + + // API - Stop FSEQ + server.on("/api/fseq/stop", HTTP_GET, [](AsyncWebServerRequest *request) { + FSEQPlayer::clearLastPlayback(); + if (realtimeOverride == REALTIME_OVERRIDE_ONCE) + realtimeOverride = REALTIME_OVERRIDE_NONE; + if (realtimeMode) + exitRealtime(); + else { + realtimeMode = REALTIME_MODE_INACTIVE; + strip.trigger(); + } + request->send(200, "text/plain", "FSEQ stopped"); + }); + + // API - FSEQ Status + server.on("/api/fseq/status", HTTP_GET, [](AsyncWebServerRequest *request) { + bool playing = FSEQPlayer::isPlaying(); + String json = "{\"playing\":"; + json += (playing ? "true" : "false"); + json += "}"; + request->send(200, "application/json", json); + }); +} \ 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..19076cd41e --- /dev/null +++ b/usermods/FSEQ/web_ui_manager.cpp @@ -0,0 +1,557 @@ +#include "web_ui_manager.h" +#include "fseq_player.h" +#include "sd_manager.h" +#include "usermod_fseq.h" + +static const char PAGE_HTML[] PROGMEM = R"rawliteral( + + + + +WLED FSEQ UI + + + + + + + + + + +
        + +

        FSEQ UI

        +
        +
        + + + +
        + +
        +

        SD Storage

        +
        +
        +
        +
        +
        + +
        +

        SD Files

        +
          +
          + +
          +

          Upload File

          +

          + +
          +
          +
          +
          +
          + +
          + +
          +
          +

          FSEQ Files

          +
            +
            +
            + + + +)rawliteral"; + + +void WebUIManager::registerEndpoints() { + + // Main UI page (navigation, SD and FSEQ tabs) + server.on("/fsequi", HTTP_GET, [](AsyncWebServerRequest *request) { + request->send_P(200, "text/html", PAGE_HTML); + }); + + // API - List SD files (size in KB + storage info) + 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(); + + String json = "{"; + json += "\"files\":["; + + if (root && root.isDirectory()) { + bool first = true; + File file = root.openNextFile(); + + while (file) { + if (!first) json += ","; + first = false; + + float sizeKB = file.size() / 1024.0; + + json += "{"; + json += "\"name\":\"" + String(file.name()) + "\","; + json += "\"size\":" + String(sizeKB, 2); + json += "}"; + + file.close(); + file = root.openNextFile(); + } + } + + root.close(); + + json += "],"; + json += "\"usedKB\":" + String(usedBytes / 1024.0, 2) + ","; + json += "\"totalKB\":" + String(totalBytes / 1024.0, 2); + json += "}"; + + request->send(200, "application/json", json); + }); + + + // API - List FSEQ files + server.on("/api/fseq/list", HTTP_GET, [](AsyncWebServerRequest *request) { + File root = SD_ADAPTER.open("/"); + String json = "["; + if (root && root.isDirectory()) { + bool first = true; + File file = root.openNextFile(); + while (file) { + String name = file.name(); + if (name.endsWith(".fseq") || name.endsWith(".FSEQ")) { + if (!first) + json += ","; + first = false; + json += "{"; + json += "\"name\":\"" + name + "\""; + json += "}"; + } + file.close(); + file = root.openNextFile(); + } + } + root.close(); + json += "]"; + request->send(200, "application/json", json); + }); + + // API - File Upload + server.on( + "/api/sd/upload", HTTP_POST, + [](AsyncWebServerRequest *request) { + request->send(200, "text/plain", "Upload complete"); + }, + [](AsyncWebServerRequest *request, String filename, size_t index, + uint8_t *data, size_t len, bool final) { + static File uploadFile; + if (index == 0) { + if (!filename.startsWith("/")) + filename = "/" + filename; + uploadFile = SD_ADAPTER.open(filename.c_str(), FILE_WRITE); + } + if (uploadFile) { + uploadFile.write(data, len); + if (final) + uploadFile.close(); + } + }); + + // API - File Delete + server.on("/api/sd/delete", HTTP_GET, [](AsyncWebServerRequest *request) { + if (!request->hasArg("path")) { + request->send(400, "text/plain", "Missing path"); + return; + } + String path = request->arg("path"); + if (!path.startsWith("/")) + path = "/" + path; + bool res = SD_ADAPTER.remove(path.c_str()); + request->send(200, "text/plain", res ? "File deleted" : "Delete failed"); + }); + + // API - Start FSEQ (normal playback) + server.on("/api/fseq/start", HTTP_GET, [](AsyncWebServerRequest *request) { + if (!request->hasArg("file")) { + request->send(400, "text/plain", "Missing file param"); + return; + } + String filepath = request->arg("file"); + if (!filepath.startsWith("/")) + filepath = "/" + filepath; + FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 0.0f); + request->send(200, "text/plain", "FSEQ started"); + }); + + // API - Start FSEQ in loop mode + server.on( + "/api/fseq/startloop", HTTP_GET, [](AsyncWebServerRequest *request) { + if (!request->hasArg("file")) { + request->send(400, "text/plain", "Missing file param"); + return; + } + String filepath = request->arg("file"); + if (!filepath.startsWith("/")) + filepath = "/" + filepath; + // Passing 1.0f enables loop mode in loadRecording() + FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 1.0f); + request->send(200, "text/plain", "FSEQ loop started"); + }); + + // API - Stop FSEQ + server.on("/api/fseq/stop", HTTP_GET, [](AsyncWebServerRequest *request) { + FSEQPlayer::clearLastPlayback(); + if (realtimeOverride == REALTIME_OVERRIDE_ONCE) + realtimeOverride = REALTIME_OVERRIDE_NONE; + if (realtimeMode) + exitRealtime(); + else { + realtimeMode = REALTIME_MODE_INACTIVE; + strip.trigger(); + } + request->send(200, "text/plain", "FSEQ stopped"); + }); + + // API - FSEQ Status + server.on("/api/fseq/status", HTTP_GET, [](AsyncWebServerRequest *request) { + + bool playing = FSEQPlayer::isPlaying(); + String file = FSEQPlayer::getFileName(); + + String json = "{"; + json += "\"playing\":"; + json += (playing ? "true" : "false"); + json += ","; + + json += "\"file\":\""; + json += file; + json += "\""; + + json += "}"; + + request->send(200, "application/json", json); + }); +} \ No newline at end of file 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/wled00/const.h b/wled00/const.h index 95e69d855b..ba84bb7b88 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -267,6 +267,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 d7521de42c..f8a986bc61 100644 --- a/wled00/json.cpp +++ b/wled00/json.cpp @@ -764,6 +764,7 @@ void serializeInfo(JsonObject root) case REALTIME_MODE_ARTNET: root["lm"] = F("Art-Net"); break; case REALTIME_MODE_TPM2NET: root["lm"] = F("tpm2.net"); break; case REALTIME_MODE_DDP: root["lm"] = F("DDP"); break; + case REALTIME_MODE_FSEQ: root["lm"] = F("FSEQ"); break; } root[F("lip")] = realtimeIP[0] == 0 ? "" : realtimeIP.toString(); From 1b034b453d36104f5e0fb32a207cfba753aa7f28 Mon Sep 17 00:00:00 2001 From: Danit2 <71522810+Danit2@users.noreply.github.com> Date: Mon, 23 Feb 2026 19:36:15 +0100 Subject: [PATCH 2/9] Update README.md with author information and details Added additional author credit and improved project description. --- usermods/FSEQ/README.md | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/usermods/FSEQ/README.md b/usermods/FSEQ/README.md index e211c5a324..9d8d89611b 100644 --- a/usermods/FSEQ/README.md +++ b/usermods/FSEQ/README.md @@ -1,6 +1,7 @@ # ✨ Usermod FSEQ ✨ > **Created original by: Andrej Chrcek** + > **Updatet by: Danit2** Welcome to the **Usermod FSEQ** project! @@ -16,6 +17,8 @@ http://yourIP/fsequi or over the WLED Infotab +image + --- # SD & FSEQ Usermod for WLED @@ -116,4 +119,4 @@ After making changes, you must reboot the device. The SD & FSEQ Usermod for WLED enables FSEQ playback from an SD card with a full-featured web interface and UDP synchronization. With configurable SPI pin settings, it integrates seamlessly into WLED without modifying the core code. -For further customization or support, please refer to the project documentation or open an issue on GitHub. \ No newline at end of file +For further customization or support, please refer to the project documentation or open an issue on GitHub. From 69480433b35651f20c2ee0df11323cefd21c49aa Mon Sep 17 00:00:00 2001 From: Danit2 <71522810+Danit2@users.noreply.github.com> Date: Mon, 23 Feb 2026 23:30:31 +0100 Subject: [PATCH 3/9] Fixes to make the Rabit happy: Refactor FSEQ SD, playback and web API MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Multiple cleanups and feature changes across the FSEQ usermod: - fseq_player: Add a bool loop parameter to loadRecording(), use file_header.channel_data_offset when seeking, avoid prematurely closing recordingFile, and open files via WLED_FS.open for FS paths. Set recordingRepeats from the new loop flag instead of using secondsElapsed heuristics. - fseq_player.h: Update loadRecording signature and remove unused REALTIME_MODE_FSEQ define. - sd_manager: Add compile-time check that an SD backend is enabled (SPI or MMC) and remove the listFiles implementation/API; keep deleteFile. Corresponding header prototype removed. - web_ui_manager: Switch FSEQ/SD endpoints to POST with application/x-www-form-urlencoded bodies (start, startloop, stop, delete). Replace manual string-based SD file listing with a JSON response using DynamicJsonDocument. Improve file upload handling by storing a File pointer on the request and cleaning up on completion. Return status/info endpoints as JSON. - usermod_fpp/usermod_fseq: Various robustness and modernizations — null-buffer check in write(), inline constexpr and inline variables for UDP and timing, use snprintf for time formatting, suppress noisy debug hex dump, add override annotations, and rework SD reinit to accept and deallocate old pins before reinitializing. Overall these changes improve robustness of file handling, enforce SD backend presence at compile time, reduce string concatenation overhead by using JSON, and standardize the web API to POST for mutating actions. --- usermods/FSEQ/fseq_player.cpp | 29 ++++---- usermods/FSEQ/fseq_player.h | 10 +-- usermods/FSEQ/sd_manager.cpp | 31 ++------- usermods/FSEQ/sd_manager.h | 1 - usermods/FSEQ/usermod_fpp.h | 52 +++++--------- usermods/FSEQ/usermod_fseq.h | 25 ++++--- usermods/FSEQ/web_ui_manager.cpp | 115 ++++++++++++++++--------------- 7 files changed, 117 insertions(+), 146 deletions(-) diff --git a/usermods/FSEQ/fseq_player.cpp b/usermods/FSEQ/fseq_player.cpp index d12a492072..959d7f41cb 100644 --- a/usermods/FSEQ/fseq_player.cpp +++ b/usermods/FSEQ/fseq_player.cpp @@ -115,21 +115,20 @@ bool FSEQPlayer::stopBecauseAtTheEnd() { if (recordingRepeats == RECORDING_REPEAT_LOOP) { frame = 0; - recordingFile.seek(file_header.header_length); + recordingFile.seek(file_header.channel_data_offset); return false; } if (recordingRepeats > 0) { recordingRepeats--; frame = 0; - recordingFile.seek(file_header.header_length); + recordingFile.seek(file_header.channel_data_offset); DEBUG_PRINTF("Repeat recording again for: %d\n", recordingRepeats); return false; } DEBUG_PRINTLN("Finished playing recording, disabling realtime mode"); realtimeLock(10, REALTIME_MODE_INACTIVE); - recordingFile.close(); clearLastPlayback(); return true; } @@ -160,11 +159,14 @@ void FSEQPlayer::handlePlayRecording() { playNextRecordingFrame(); } -void FSEQPlayer::loadRecording(const char *filepath, uint16_t startLed, - uint16_t stopLed, float secondsElapsed) { +void FSEQPlayer::loadRecording(const char *filepath, + uint16_t startLed, + uint16_t stopLed, + float secondsElapsed, + bool loop) +{ if (recordingFile.available()) { clearLastPlayback(); - recordingFile.close(); } playbackLedStart = startLed; playbackLedStop = stopLed; @@ -183,6 +185,10 @@ void FSEQPlayer::loadRecording(const char *filepath, uint16_t startLed, currentFileName = currentFileName.substring(1); } else if (fileOnFS(filepath)) { DEBUG_PRINTF("Read file from FS: %s\n", filepath); + recordingFile = WLED_FS.open(filepath, "rb"); + currentFileName = String(filepath); + if (currentFileName.startsWith("/")) + currentFileName = currentFileName.substring(1); } else { DEBUG_PRINTF("File %s not found (%s)\n", filepath, USED_STORAGE_FILESYSTEMS); @@ -234,13 +240,12 @@ void FSEQPlayer::loadRecording(const char *filepath, uint16_t startLed, frame = file_header.frame_count - 1; } // Set loop mode if secondsElapsed is exactly 1.0f - if (fabs(secondsElapsed - 1.0f) < 0.001f) { - recordingRepeats = RECORDING_REPEAT_LOOP; - } else { - recordingRepeats = RECORDING_REPEAT_DEFAULT; - } - playNextRecordingFrame(); + recordingRepeats = loop + ? RECORDING_REPEAT_LOOP + : RECORDING_REPEAT_DEFAULT; + playNextRecordingFrame(); + //playNextRecordingFrame(); } void FSEQPlayer::clearLastPlayback() { diff --git a/usermods/FSEQ/fseq_player.h b/usermods/FSEQ/fseq_player.h index 3c854ea641..eb05b98ce1 100644 --- a/usermods/FSEQ/fseq_player.h +++ b/usermods/FSEQ/fseq_player.h @@ -7,9 +7,6 @@ #ifndef RECORDING_REPEAT_DEFAULT #define RECORDING_REPEAT_DEFAULT 0 #endif -#ifndef REALTIME_MODE_FSEQ -#define REALTIME_MODE_FSEQ 10 -#endif #include "wled.h" #ifdef WLED_USE_SD_SPI @@ -33,8 +30,11 @@ class FSEQPlayer { uint8_t flags; }; - static void loadRecording(const char *filepath, uint16_t startLed, - uint16_t stopLed, float secondsElapsed = 0.0f); + static void loadRecording(const char *filepath, + uint16_t startLed, + uint16_t stopLed, + float secondsElapsed = 0.0f, + bool loop = false); static void handlePlayRecording(); static void clearLastPlayback(); static void syncPlayback(float secondsElapsed); diff --git a/usermods/FSEQ/sd_manager.cpp b/usermods/FSEQ/sd_manager.cpp index d4a1e3bae0..27c560323d 100644 --- a/usermods/FSEQ/sd_manager.cpp +++ b/usermods/FSEQ/sd_manager.cpp @@ -2,6 +2,10 @@ #include "usermod_fseq.h" bool SDManager::begin() { +#if !defined(WLED_USE_SD_SPI) && !defined(WLED_USE_SD_MMC) +#error "FSEQ requires SD backend (WLED_USE_SD_SPI or WLED_USE_SD_MMC)" +#endif + #ifdef WLED_USE_SD_SPI if (!SD_ADAPTER.begin(WLED_PIN_SS, spiPort)) return false; @@ -14,31 +18,4 @@ bool SDManager::begin() { void SDManager::end() { SD_ADAPTER.end(); } -String SDManager::listFiles(const char *dirname) { - String result = ""; - File root = SD_ADAPTER.open(dirname); - if (!root) { - result += "
          • Failed to open directory: "; - result += dirname; - result += "
          • "; - return result; - } - if (!root.isDirectory()) { - result += "
          • Not a directory: "; - result += dirname; - result += "
          • "; - return result; - } - File file = root.openNextFile(); - while (file) { - result += "
          • "; - result += file.name(); - result += " (" + String(file.size()) + " bytes)
          • "; - file.close(); - file = root.openNextFile(); - } - root.close(); - return result; -} - bool SDManager::deleteFile(const char *path) { return SD_ADAPTER.remove(path); } \ No newline at end of file diff --git a/usermods/FSEQ/sd_manager.h b/usermods/FSEQ/sd_manager.h index c5ac79c67d..e40a2e2040 100644 --- a/usermods/FSEQ/sd_manager.h +++ b/usermods/FSEQ/sd_manager.h @@ -15,7 +15,6 @@ class SDManager { SDManager() {} bool begin(); void end(); - String listFiles(const char* dirname); bool deleteFile(const char* path); }; diff --git a/usermods/FSEQ/usermod_fpp.h b/usermods/FSEQ/usermod_fpp.h index 52f5e7e818..f93d54d0d9 100644 --- a/usermods/FSEQ/usermod_fpp.h +++ b/usermods/FSEQ/usermod_fpp.h @@ -33,6 +33,7 @@ class WriteBufferingStream : public Stream { } // Write a block of data to the buffer size_t write(const uint8_t *buffer, size_t size) override { + if (!_buffer) return 0; size_t total = 0; while (size > 0) { size_t space = _capacity - _offset; @@ -77,10 +78,10 @@ class WriteBufferingStream : public Stream { #define CTRL_PKT_BLANK 3 // UDP port for FPP discovery/synchronization -const uint16_t UDP_SYNC_PORT = 32320; +inline constexpr uint16_t UDP_SYNC_PORT = 32320; -unsigned long lastPingTime = 0; -const unsigned long pingInterval = 5000; +inline unsigned long lastPingTime = 0; +inline constexpr unsigned long pingInterval = 5000; // Inline functions to write 16-bit and 32-bit values static inline void write16(uint8_t *dest, uint16_t value) { @@ -194,7 +195,8 @@ class UsermodFPP : public Usermod { if (FSEQPlayer::isPlaying()) { String fileName = FSEQPlayer::getFileName(); - uint32_t elapsed = FSEQPlayer::getElapsedSeconds(); + float elapsedF = FSEQPlayer::getElapsedSeconds(); + uint32_t elapsed = (uint32_t)elapsedF; doc["current_sequence"] = fileName; doc["playlist"] = ""; @@ -205,8 +207,8 @@ class UsermodFPP : public Usermod { uint32_t mins = elapsed / 60; uint32_t secs = elapsed % 60; - char timeStr[6]; - sprintf(timeStr, "%02d:%02d", mins, secs); + char timeStr[16]; + snprintf(timeStr, sizeof(timeStr), "%02u:%02u", mins, secs); doc["time_elapsed"] = timeStr; doc["time_remaining"] = "00:00"; @@ -443,11 +445,11 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { // UDP - process received packet void processUdpPacket(AsyncUDPPacket packet) { // Print the raw UDP packet in hex format for debugging - DEBUG_PRINTLN(F("[FPP] Raw UDP Packet:")); - for (size_t i = 0; i < packet.length(); i++) { - DEBUG_PRINTF("%02X ", packet.data()[i]); - } - DEBUG_PRINTLN(); + // DEBUG_PRINTLN(F("[FPP] Raw UDP Packet:")); + //for (size_t i = 0; i < packet.length(); i++) { + // DEBUG_PRINTF("%02X ", packet.data()[i]); + // } + // DEBUG_PRINTLN(); if (packet.length() < 4) return; @@ -537,25 +539,6 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { // Setup function called once at startup void setup() { DEBUG_PRINTF("[%s] FPP Usermod loaded\n", _name); -#ifdef WLED_USE_SD_SPI - int8_t csPin = UsermodFseq::getCsPin(); - int8_t sckPin = UsermodFseq::getSckPin(); - int8_t misoPin = UsermodFseq::getMisoPin(); - int8_t mosiPin = UsermodFseq::getMosiPin(); - if (!SD.begin(csPin)) { - DEBUG_PRINTF("[%s] ERROR: SD.begin() failed with CS pin %d!\n", _name, - csPin); - } else { - DEBUG_PRINTF("[%s] SD card initialized (SPI) with CS pin %d\n", _name, - csPin); - } -#elif defined(WLED_USE_SD_MMC) - if (!SD_MMC.begin()) { - DEBUG_PRINTF("[%s] ERROR: SD_MMC.begin() failed!\n", _name); - } else { - DEBUG_PRINTF("[%s] SD card initialized (MMC)\n", _name); - } -#endif // Register API endpoints server.on("/api/system/info", HTTP_GET, @@ -739,14 +722,11 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { lastPingTime = millis(); } } - - // Process FSEQ playback - FSEQPlayer::handlePlayRecording(); } - uint16_t getId() { return USERMOD_ID_SD_CARD; } - void addToConfig(JsonObject &root) {} - bool readFromConfig(JsonObject &root) { return true; } + uint16_t getId() override { return USERMOD_ID_SD_CARD; } + void addToConfig(JsonObject &root) override {} + bool readFromConfig(JsonObject &root) override { return true; } }; const char UsermodFPP::_name[] PROGMEM = "FPP Connect"; \ No newline at end of file diff --git a/usermods/FSEQ/usermod_fseq.h b/usermods/FSEQ/usermod_fseq.h index 7c43e85fbe..0f09a65e2e 100644 --- a/usermods/FSEQ/usermod_fseq.h +++ b/usermods/FSEQ/usermod_fseq.h @@ -49,13 +49,13 @@ inline SPIClass spiPort = SPI; inline SPIClass spiPort = SPI; #endif #define SPI_PORT_DEFINED -#include "../usermods/FSEQ/fseq_player.h" -#include "../usermods/FSEQ/sd_manager.h" -#include "../usermods/FSEQ/web_ui_manager.h" -#include "wled.h" #endif #endif +#include "fseq_player.h" +#include "sd_manager.h" +#include "web_ui_manager.h" + // Usermod for FSEQ playback with UDP and web UI support class UsermodFseq : public Usermod { private: @@ -124,6 +124,11 @@ class UsermodFseq : public Usermod { JsonObject top = root[FPSTR(_name)]; if (top.isNull()) return false; + + int8_t oldCs = configPinSourceSelect; + int8_t oldSck = configPinSourceClock; + int8_t oldMiso = configPinPoci; + int8_t oldMosi = configPinPico; if (top["csPin"].is()) configPinSourceSelect = top["csPin"].as(); @@ -134,7 +139,7 @@ class UsermodFseq : public Usermod { if (top["mosiPin"].is()) configPinPico = top["mosiPin"].as(); - reinit_SD_SPI(); // reinitialize SD with new pins + reinit_SD_SPI(oldCs, oldSck, oldMiso, oldMosi); // reinitialize SD with new pins return true; #else return false; @@ -143,14 +148,14 @@ class UsermodFseq : public Usermod { #ifdef WLED_USE_SD_SPI // Reinitialize SD SPI with updated pins - void reinit_SD_SPI() { + void reinit_SD_SPI(int8_t oldCs, int8_t oldSck, int8_t oldMiso, int8_t oldMosi) { // Deinit SD if needed SD_ADAPTER.end(); // Reallocate pins - PinManager::deallocatePin(configPinSourceSelect, PinOwner::UM_SdCard); - PinManager::deallocatePin(configPinSourceClock, PinOwner::UM_SdCard); - PinManager::deallocatePin(configPinPoci, PinOwner::UM_SdCard); - PinManager::deallocatePin(configPinPico, PinOwner::UM_SdCard); + PinManager::deallocatePin(oldCs, PinOwner::UM_SdCard); + PinManager::deallocatePin(oldSck, PinOwner::UM_SdCard); + PinManager::deallocatePin(oldMiso, PinOwner::UM_SdCard); + PinManager::deallocatePin(oldMosi, PinOwner::UM_SdCard); PinManagerPinType pins[4] = {{configPinSourceSelect, true}, {configPinSourceClock, true}, diff --git a/usermods/FSEQ/web_ui_manager.cpp b/usermods/FSEQ/web_ui_manager.cpp index 19076cd41e..7b8a2e42de 100644 --- a/usermods/FSEQ/web_ui_manager.cpp +++ b/usermods/FSEQ/web_ui_manager.cpp @@ -219,7 +219,7 @@ function toggleNormal(name, playBtn, loopBtn) { if (!isPlaying) { - fetch('/api/fseq/start?file=' + encodeURIComponent(name)); + fetch('/api/fseq/start', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:'file='+encodeURIComponent(name)}) resetAllFseqButtons(); @@ -229,7 +229,7 @@ function toggleNormal(name, playBtn, loopBtn) { } else { - fetch('/api/fseq/stop'); + fetch('/api/fseq/stop', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}}) resetAllFseqButtons(); } } @@ -240,7 +240,7 @@ function toggleLoop(name, playBtn, loopBtn) { if (!isLooping) { - fetch('/api/fseq/startloop?file=' + encodeURIComponent(name)); + fetch('/api/fseq/startloop', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:'file='+encodeURIComponent(name)}) resetAllFseqButtons(); @@ -250,7 +250,7 @@ function toggleLoop(name, playBtn, loopBtn) { } else { - fetch('/api/fseq/stop'); + fetch('/api/fseq/stop', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}}) resetAllFseqButtons(); } } @@ -287,7 +287,7 @@ function checkFseqStatus(){ function deleteFile(name){ if(!confirm("Delete "+name+"?"))return; - fetch('/api/sd/delete?path='+encodeURIComponent(name)) + fetch('/api/sd/delete', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:'path='+encodeURIComponent(name)}) .then(()=>{ loadSDList(); loadFseqList(); }); } @@ -435,29 +435,36 @@ void WebUIManager::registerEndpoints() { // API - List FSEQ files server.on("/api/fseq/list", HTTP_GET, [](AsyncWebServerRequest *request) { - File root = SD_ADAPTER.open("/"); - String json = "["; - if (root && root.isDirectory()) { - bool first = true; - File file = root.openNextFile(); - while (file) { - String name = file.name(); - if (name.endsWith(".fseq") || name.endsWith(".FSEQ")) { - if (!first) - json += ","; - first = false; - json += "{"; - json += "\"name\":\"" + name + "\""; - json += "}"; - } - file.close(); - file = root.openNextFile(); - } - } - root.close(); - json += "]"; - request->send(200, "application/json", json); - }); + + File root = SD_ADAPTER.open("/"); + + DynamicJsonDocument doc(4096); + JsonArray files = doc.to(); + + if (root && root.isDirectory()) { + + File file = root.openNextFile(); + while (file) { + + String name = file.name(); + + if (name.endsWith(".fseq") || name.endsWith(".FSEQ")) { + JsonObject obj = files.createNestedObject(); + obj["name"] = name; + } + + file.close(); + file = root.openNextFile(); + } + } + + root.close(); + + String output; + serializeJson(doc, output); + + request->send(200, "application/json", output); + }); // API - File Upload server.on( @@ -467,21 +474,25 @@ void WebUIManager::registerEndpoints() { }, [](AsyncWebServerRequest *request, String filename, size_t index, uint8_t *data, size_t len, bool final) { - static File uploadFile; if (index == 0) { if (!filename.startsWith("/")) filename = "/" + filename; - uploadFile = SD_ADAPTER.open(filename.c_str(), FILE_WRITE); + File *f = new File(SD_ADAPTER.open(filename.c_str(), FILE_WRITE)); + request->_tempObject = f; } - if (uploadFile) { - uploadFile.write(data, len); - if (final) - uploadFile.close(); + File *f = static_cast(request->_tempObject); + if (f && *f) { + f->write(data, len); + if (final) { + f->close(); + delete f; + request->_tempObject = nullptr; + } } }); // API - File Delete - server.on("/api/sd/delete", HTTP_GET, [](AsyncWebServerRequest *request) { + server.on("/api/sd/delete", HTTP_POST, [](AsyncWebServerRequest *request) { if (!request->hasArg("path")) { request->send(400, "text/plain", "Missing path"); return; @@ -494,7 +505,7 @@ void WebUIManager::registerEndpoints() { }); // API - Start FSEQ (normal playback) - server.on("/api/fseq/start", HTTP_GET, [](AsyncWebServerRequest *request) { + server.on("/api/fseq/start", HTTP_POST, [](AsyncWebServerRequest *request) { if (!request->hasArg("file")) { request->send(400, "text/plain", "Missing file param"); return; @@ -502,13 +513,13 @@ void WebUIManager::registerEndpoints() { String filepath = request->arg("file"); if (!filepath.startsWith("/")) filepath = "/" + filepath; - FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 0.0f); + FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 0.0f, false); request->send(200, "text/plain", "FSEQ started"); }); // API - Start FSEQ in loop mode server.on( - "/api/fseq/startloop", HTTP_GET, [](AsyncWebServerRequest *request) { + "/api/fseq/startloop", HTTP_POST, [](AsyncWebServerRequest *request) { if (!request->hasArg("file")) { request->send(400, "text/plain", "Missing file param"); return; @@ -517,12 +528,12 @@ void WebUIManager::registerEndpoints() { if (!filepath.startsWith("/")) filepath = "/" + filepath; // Passing 1.0f enables loop mode in loadRecording() - FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 1.0f); + FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 1.0f, true); request->send(200, "text/plain", "FSEQ loop started"); }); // API - Stop FSEQ - server.on("/api/fseq/stop", HTTP_GET, [](AsyncWebServerRequest *request) { + server.on("/api/fseq/stop", HTTP_POST, [](AsyncWebServerRequest *request) { FSEQPlayer::clearLastPlayback(); if (realtimeOverride == REALTIME_OVERRIDE_ONCE) realtimeOverride = REALTIME_OVERRIDE_NONE; @@ -535,23 +546,17 @@ void WebUIManager::registerEndpoints() { request->send(200, "text/plain", "FSEQ stopped"); }); - // API - FSEQ Status - server.on("/api/fseq/status", HTTP_GET, [](AsyncWebServerRequest *request) { + // API - FSEQ Status + server.on("/api/fseq/status", HTTP_GET, [](AsyncWebServerRequest *request) { - bool playing = FSEQPlayer::isPlaying(); - String file = FSEQPlayer::getFileName(); + DynamicJsonDocument doc(512); - String json = "{"; - json += "\"playing\":"; - json += (playing ? "true" : "false"); - json += ","; + doc["playing"] = FSEQPlayer::isPlaying(); + doc["file"] = FSEQPlayer::getFileName(); - json += "\"file\":\""; - json += file; - json += "\""; + String output; + serializeJson(doc, output); - json += "}"; - - request->send(200, "application/json", json); - }); + request->send(200, "application/json", output); + }); } \ No newline at end of file From 8599e9d9019cfb382ea377015f7a218c85a5e08a Mon Sep 17 00:00:00 2001 From: Danit2 <71522810+Danit2@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:51:26 +0100 Subject: [PATCH 4/9] Fixes to make the Rabbit happy: FSEQ: fix types, SD_ADAPTER, UDP checks & UI Widen FSEQ frame size counters to 32-bit and make frame buffer reads safe (fixed 48-byte buffer and proper length casts). Add stricter UDP packet length checks for sync packets. Replace raw SD file ops with SD_ADAPTER (exists/remove/open) and add upload open-failure handling/cleanup. Refactor web UI SD list to emit proper JSON via ArduinoJson, fix JS loop-state bug, and adjust loadRecording loop parameter. Remove stale backup file. --- usermods/FSEQ/README.md | 2 +- usermods/FSEQ/fseq_player.cpp | 10 +- usermods/FSEQ/usermod_fpp.h | 12 +- usermods/FSEQ/web_ui_manager.bak | 512 ------------------------------- usermods/FSEQ/web_ui_manager.cpp | 46 +-- 5 files changed, 41 insertions(+), 541 deletions(-) delete mode 100644 usermods/FSEQ/web_ui_manager.bak diff --git a/usermods/FSEQ/README.md b/usermods/FSEQ/README.md index 9d8d89611b..6caae725df 100644 --- a/usermods/FSEQ/README.md +++ b/usermods/FSEQ/README.md @@ -119,4 +119,4 @@ After making changes, you must reboot the device. The SD & FSEQ Usermod for WLED enables FSEQ playback from an SD card with a full-featured web interface and UDP synchronization. With configurable SPI pin settings, it integrates seamlessly into WLED without modifying the core code. -For further customization or support, please refer to the project documentation or open an issue on GitHub. +For further customization or support, please refer to the project documentation or open an issue on GitHub. \ No newline at end of file diff --git a/usermods/FSEQ/fseq_player.cpp b/usermods/FSEQ/fseq_player.cpp index 959d7f41cb..12278b405a 100644 --- a/usermods/FSEQ/fseq_player.cpp +++ b/usermods/FSEQ/fseq_player.cpp @@ -85,15 +85,15 @@ void FSEQPlayer::printHeaderInfo() { } void FSEQPlayer::processFrameData() { - uint16_t packetLength = file_header.channel_count; + uint32_t packetLength = file_header.channel_count; uint16_t lastLed = - min(playbackLedStop, uint16_t(playbackLedStart + (packetLength / 3))); - char frame_data[buffer_size]; + min((uint32_t)playbackLedStop, (uint32_t)playbackLedStart + (packetLength / 3)); + char frame_data[48]; // fixed size; buffer_size is always 48 CRGB *crgb = reinterpret_cast(frame_data); - uint16_t bytes_remaining = packetLength; + uint32_t bytes_remaining = packetLength; uint16_t index = playbackLedStart; while (index < lastLed && bytes_remaining > 0) { - uint16_t length = min(bytes_remaining, buffer_size); + uint16_t length = (uint16_t)min(bytes_remaining, (uint32_t)sizeof(frame_data)); recordingFile.readBytes(frame_data, length); bytes_remaining -= length; for (uint16_t offset = 0; offset < length / 3; offset++) { diff --git a/usermods/FSEQ/usermod_fpp.h b/usermods/FSEQ/usermod_fpp.h index f93d54d0d9..450221d5d0 100644 --- a/usermods/FSEQ/usermod_fpp.h +++ b/usermods/FSEQ/usermod_fpp.h @@ -451,7 +451,7 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { // } // DEBUG_PRINTLN(); - if (packet.length() < 4) + if (packet.length() < 5) return; if (packet.data()[0] != 'F' || packet.data()[1] != 'P' || packet.data()[2] != 'P' || packet.data()[3] != 'D') @@ -459,6 +459,10 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { uint8_t packetType = packet.data()[4]; switch (packetType) { case CTRL_PKT_SYNC: { + if (packet.length() < 17) { + DEBUG_PRINTLN(F("[FPP] Sync packet too short, ignoring")); + break; + } FPPMultiSyncPacket *syncPacket = reinterpret_cast(packet.data()); DEBUG_PRINTLN(F("[FPP] Received UDP sync packet")); @@ -598,12 +602,12 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { DEBUG_PRINTF("[FPP] Using filename: %s\n", currentUploadFileName.c_str()); - if (SD.exists(currentUploadFileName.c_str())) { - SD.remove(currentUploadFileName.c_str()); + if (SD_ADAPTER.exists(currentUploadFileName.c_str())) { + SD_ADAPTER.remove(currentUploadFileName.c_str()); } currentUploadFile = - SD.open(currentUploadFileName.c_str(), FILE_WRITE); + SD_ADAPTER.open(currentUploadFileName.c_str(), FILE_WRITE); if (!currentUploadFile) { DEBUG_PRINTLN(F("[FPP] ERROR: Failed to open file")); diff --git a/usermods/FSEQ/web_ui_manager.bak b/usermods/FSEQ/web_ui_manager.bak deleted file mode 100644 index b2fc2ac6db..0000000000 --- a/usermods/FSEQ/web_ui_manager.bak +++ /dev/null @@ -1,512 +0,0 @@ -#include "web_ui_manager.h" -#include "fseq_player.h" -#include "sd_manager.h" -#include "usermod_fseq.h" - -static const char PAGE_HTML[] PROGMEM = R"rawliteral( - - - - -WLED Unified UI - - - - - - - - - - -
            - -

            Unified UI

            -
            -
            - - - -
            - -
            -

            SD Storage

            -
            -
            -
            -
            -
            - -
            -

            SD Files

            -
              -
              - -
              -

              Upload File

              -

              - - -
              -
              -
              -
              -
              - -
              - -
              -
              -

              FSEQ Files

              -
                -
                -
                - - - -)rawliteral"; - - -void WebUIManager::registerEndpoints() { - - // Main UI page (navigation, SD and FSEQ tabs) - server.on("/fsequi", HTTP_GET, [](AsyncWebServerRequest *request) { - request->send_P(200, "text/html", PAGE_HTML); - }); - - // API - List SD files (size in KB + storage info) - 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(); - - String json = "{"; - json += "\"files\":["; - - if (root && root.isDirectory()) { - bool first = true; - File file = root.openNextFile(); - - while (file) { - if (!first) json += ","; - first = false; - - float sizeKB = file.size() / 1024.0; - - json += "{"; - json += "\"name\":\"" + String(file.name()) + "\","; - json += "\"size\":" + String(sizeKB, 2); - json += "}"; - - file.close(); - file = root.openNextFile(); - } - } - - root.close(); - - json += "],"; - json += "\"usedKB\":" + String(usedBytes / 1024.0, 2) + ","; - json += "\"totalKB\":" + String(totalBytes / 1024.0, 2); - json += "}"; - - request->send(200, "application/json", json); - }); - - - // API - List FSEQ files - server.on("/api/fseq/list", HTTP_GET, [](AsyncWebServerRequest *request) { - File root = SD_ADAPTER.open("/"); - String json = "["; - if (root && root.isDirectory()) { - bool first = true; - File file = root.openNextFile(); - while (file) { - String name = file.name(); - if (name.endsWith(".fseq") || name.endsWith(".FSEQ")) { - if (!first) - json += ","; - first = false; - json += "{"; - json += "\"name\":\"" + name + "\""; - json += "}"; - } - file.close(); - file = root.openNextFile(); - } - } - root.close(); - json += "]"; - request->send(200, "application/json", json); - }); - - // API - File Upload - server.on( - "/api/sd/upload", HTTP_POST, - [](AsyncWebServerRequest *request) { - request->send(200, "text/plain", "Upload complete"); - }, - [](AsyncWebServerRequest *request, String filename, size_t index, - uint8_t *data, size_t len, bool final) { - static File uploadFile; - if (index == 0) { - if (!filename.startsWith("/")) - filename = "/" + filename; - uploadFile = SD_ADAPTER.open(filename.c_str(), FILE_WRITE); - } - if (uploadFile) { - uploadFile.write(data, len); - if (final) - uploadFile.close(); - } - }); - - // API - File Delete - server.on("/api/sd/delete", HTTP_GET, [](AsyncWebServerRequest *request) { - if (!request->hasArg("path")) { - request->send(400, "text/plain", "Missing path"); - return; - } - String path = request->arg("path"); - if (!path.startsWith("/")) - path = "/" + path; - bool res = SD_ADAPTER.remove(path.c_str()); - request->send(200, "text/plain", res ? "File deleted" : "Delete failed"); - }); - - // API - Start FSEQ (normal playback) - server.on("/api/fseq/start", HTTP_GET, [](AsyncWebServerRequest *request) { - if (!request->hasArg("file")) { - request->send(400, "text/plain", "Missing file param"); - return; - } - String filepath = request->arg("file"); - if (!filepath.startsWith("/")) - filepath = "/" + filepath; - FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 0.0f); - request->send(200, "text/plain", "FSEQ started"); - }); - - // API - Start FSEQ in loop mode - server.on( - "/api/fseq/startloop", HTTP_GET, [](AsyncWebServerRequest *request) { - if (!request->hasArg("file")) { - request->send(400, "text/plain", "Missing file param"); - return; - } - String filepath = request->arg("file"); - if (!filepath.startsWith("/")) - filepath = "/" + filepath; - // Passing 1.0f enables loop mode in loadRecording() - FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 1.0f); - request->send(200, "text/plain", "FSEQ loop started"); - }); - - // API - Stop FSEQ - server.on("/api/fseq/stop", HTTP_GET, [](AsyncWebServerRequest *request) { - FSEQPlayer::clearLastPlayback(); - if (realtimeOverride == REALTIME_OVERRIDE_ONCE) - realtimeOverride = REALTIME_OVERRIDE_NONE; - if (realtimeMode) - exitRealtime(); - else { - realtimeMode = REALTIME_MODE_INACTIVE; - strip.trigger(); - } - request->send(200, "text/plain", "FSEQ stopped"); - }); - - // API - FSEQ Status - server.on("/api/fseq/status", HTTP_GET, [](AsyncWebServerRequest *request) { - bool playing = FSEQPlayer::isPlaying(); - String json = "{\"playing\":"; - json += (playing ? "true" : "false"); - json += "}"; - request->send(200, "application/json", json); - }); -} \ No newline at end of file diff --git a/usermods/FSEQ/web_ui_manager.cpp b/usermods/FSEQ/web_ui_manager.cpp index 7b8a2e42de..c5b6a4303f 100644 --- a/usermods/FSEQ/web_ui_manager.cpp +++ b/usermods/FSEQ/web_ui_manager.cpp @@ -236,7 +236,7 @@ function toggleNormal(name, playBtn, loopBtn) { function toggleLoop(name, playBtn, loopBtn) { - const isLooping = loopBtn.dataset.state === "looping"; + const isLooping = loopBtn.dataset.state === "playing"; if (!isLooping) { @@ -399,23 +399,20 @@ void WebUIManager::registerEndpoints() { uint64_t totalBytes = SD_ADAPTER.totalBytes(); uint64_t usedBytes = SD_ADAPTER.usedBytes(); - String json = "{"; - json += "\"files\":["; + // Adjust size if needed (depends on max file count) + DynamicJsonDocument doc(8192); + + JsonObject rootObj = doc.to(); + JsonArray files = rootObj.createNestedArray("files"); if (root && root.isDirectory()) { - bool first = true; - File file = root.openNextFile(); + File file = root.openNextFile(); while (file) { - if (!first) json += ","; - first = false; - float sizeKB = file.size() / 1024.0; - - json += "{"; - json += "\"name\":\"" + String(file.name()) + "\","; - json += "\"size\":" + String(sizeKB, 2); - json += "}"; + JsonObject obj = files.createNestedObject(); + obj["name"] = file.name(); + obj["size"] = (float)file.size() / 1024.0; file.close(); file = root.openNextFile(); @@ -424,12 +421,13 @@ void WebUIManager::registerEndpoints() { root.close(); - json += "],"; - json += "\"usedKB\":" + String(usedBytes / 1024.0, 2) + ","; - json += "\"totalKB\":" + String(totalBytes / 1024.0, 2); - json += "}"; + rootObj["usedKB"] = (float)usedBytes / 1024.0; + rootObj["totalKB"] = (float)totalBytes / 1024.0; - request->send(200, "application/json", json); + String output; + serializeJson(doc, output); + + request->send(200, "application/json", output); }); @@ -479,6 +477,12 @@ void WebUIManager::registerEndpoints() { filename = "/" + filename; File *f = new File(SD_ADAPTER.open(filename.c_str(), FILE_WRITE)); request->_tempObject = f; + if (!*f) { + delete f; + request->_tempObject = nullptr; + request->send(500, "text/plain", "Failed to open file for writing"); + return; ++ } } File *f = static_cast(request->_tempObject); if (f && *f) { @@ -488,6 +492,10 @@ void WebUIManager::registerEndpoints() { delete f; request->_tempObject = nullptr; } + } else if (final && f) { + // Cleanup leaked handle if open failed and we somehow reach final + delete f; + request->_tempObject = nullptr; } }); @@ -528,7 +536,7 @@ void WebUIManager::registerEndpoints() { if (!filepath.startsWith("/")) filepath = "/" + filepath; // Passing 1.0f enables loop mode in loadRecording() - FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 1.0f, true); + FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 0.0f, true); request->send(200, "text/plain", "FSEQ loop started"); }); From 832e6fafcc950276cdefbb2481fbabee81eafb20 Mon Sep 17 00:00:00 2001 From: Danit2 <71522810+Danit2@users.noreply.github.com> Date: Tue, 24 Feb 2026 18:24:58 +0100 Subject: [PATCH 5/9] FSEQ/FPP: update APIs, IDs and UI handling Update FSEQ usermod and docs to new API routes and add FPP endpoints; adjust usermod IDs and fix minor web UI handling. Changes: - README: revise endpoints (use /api/ prefix, switch some endpoints to POST, rename /sd/ui to /fsequi) and add FPP control endpoints (system info/status and multisync). Merge author line. - const.h: add USERMOD_ID_FSEQ (59) and USERMOD_ID_FPP (60). - usermod_fpp.h: change getId() to return USERMOD_ID_FPP instead of USERMOD_ID_SD_CARD. - web_ui_manager.cpp: store file.name() in a temporary String before use, fix upload handler brace/formatting, and remove an outdated comment about loop mode. Notes: API route and usermod ID changes may require updates elsewhere that reference the old routes or IDs. --- usermods/FSEQ/README.md | 43 ++++++++++++++++++++++---------- usermods/FSEQ/usermod_fpp.h | 2 +- usermods/FSEQ/web_ui_manager.cpp | 7 +++--- wled00/const.h | 2 ++ 4 files changed, 37 insertions(+), 17 deletions(-) diff --git a/usermods/FSEQ/README.md b/usermods/FSEQ/README.md index 6caae725df..787ad8f5e8 100644 --- a/usermods/FSEQ/README.md +++ b/usermods/FSEQ/README.md @@ -1,8 +1,6 @@ # ✨ Usermod FSEQ ✨ -> **Created original by: Andrej Chrcek** - -> **Updatet by: Danit2** +> **Created original by: Andrej Chrcek** **Updatet by: Danit2** Welcome to the **Usermod FSEQ** project! This module extends your WLED setup by enabling FSEQ file playback from an SD card, including a web UI and UDP remote control. It combines creativity with functionality to enhance your lighting experience. @@ -66,38 +64,57 @@ custom_usermods = FSEQ ### SD Management -GET /sd/ui +GET /fsequi Returns the main HTML interface for the SD & FSEQ Manager. -GET /sd/list +GET api/sd/list Displays an HTML page listing all files on the SD card, including options to delete files and upload new ones. -POST /sd/upload +POST api/sd/upload Handles file uploads using multipart/form-data. -GET /sd/delete?path=/filename +POST /sd/delete + Deletes the specified file from the SD card. -Example: /sd/delete?path=/example.fseq +Example: /sd/delete +body: file=example.fseq --- ### FSEQ Control -GET /fseq/list +GET api/fseq/list Returns an HTML page listing all .fseq and .FSEQ files found on the SD card. Each file includes a play button. -GET /fseq/start?file=/animation.fseq&t=10 +POST api/fseq/start +body: file=animation.fseq Starts playback of the selected FSEQ file. -Optional parameter: t = time offset in seconds. -GET /fseq/stop +POST api/fseq/startloop +body: file=animation.fseq +Starts playback of the selected FSEQ file in loop mode. + +POST api/fseq/stop Stops the current FSEQ playback and clears the active session. -GET /fseqfilelist +GET /api/fseq/list Returns a JSON list of all FSEQ files on the SD card. --- +### FPP Control + +GET /api/system/info +Returns a JSON list of the system info + +GET /api/system/status +Returns a JSON list of the system status + +GET /api/fppd/multiSyncSystems +Returns a JSON list of the multisyncinfos + +--- + ## Configurable SPI Pin Settings Default SPI pin assignments for SD over SPI: diff --git a/usermods/FSEQ/usermod_fpp.h b/usermods/FSEQ/usermod_fpp.h index 450221d5d0..1bb957db3d 100644 --- a/usermods/FSEQ/usermod_fpp.h +++ b/usermods/FSEQ/usermod_fpp.h @@ -728,7 +728,7 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { } } - uint16_t getId() override { return USERMOD_ID_SD_CARD; } + uint16_t getId() override { return USERMOD_ID_FPP; } void addToConfig(JsonObject &root) override {} bool readFromConfig(JsonObject &root) override { return true; } }; diff --git a/usermods/FSEQ/web_ui_manager.cpp b/usermods/FSEQ/web_ui_manager.cpp index c5b6a4303f..81472f3102 100644 --- a/usermods/FSEQ/web_ui_manager.cpp +++ b/usermods/FSEQ/web_ui_manager.cpp @@ -409,9 +409,11 @@ void WebUIManager::registerEndpoints() { File file = root.openNextFile(); while (file) { + + String name = file.name(); JsonObject obj = files.createNestedObject(); - obj["name"] = file.name(); + obj["name"] = name; obj["size"] = (float)file.size() / 1024.0; file.close(); @@ -482,7 +484,7 @@ void WebUIManager::registerEndpoints() { request->_tempObject = nullptr; request->send(500, "text/plain", "Failed to open file for writing"); return; -+ } + } } File *f = static_cast(request->_tempObject); if (f && *f) { @@ -535,7 +537,6 @@ void WebUIManager::registerEndpoints() { String filepath = request->arg("file"); if (!filepath.startsWith("/")) filepath = "/" + filepath; - // Passing 1.0f enables loop mode in loadRecording() FSEQPlayer::loadRecording(filepath.c_str(), 0, uint16_t(-1), 0.0f, true); request->send(200, "text/plain", "FSEQ loop started"); }); diff --git a/wled00/const.h b/wled00/const.h index ba84bb7b88..507aee14a3 100644 --- a/wled00/const.h +++ b/wled00/const.h @@ -215,6 +215,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 "usermode_fseq" +#define USERMOD_ID_FPP 60 //Usermod "usermode_fpp" //Wifi encryption type #ifdef WLED_ENABLE_WPA_ENTERPRISE From ef4e4e0995523751a630164290cfbb04be1553a4 Mon Sep 17 00:00:00 2001 From: Danit2 <71522810+Danit2@users.noreply.github.com> Date: Tue, 24 Feb 2026 20:32:36 +0100 Subject: [PATCH 6/9] FSEQ: API paths, UDP sync safety, UI fixes Update README to correct and standardize API paths (/api/...), add FPP endpoints and format SPI pin block. In usermod_fpp.h remove unused helpers, parse versionString into major/minor, change typeId to 195, and harden UDP sync handling with length checks and safe filename extraction to avoid buffer overruns (also remove an old commented sendSyncMessage). In web_ui_manager.cpp only update UI buttons after successful server response and add JSON overflow handling when returning file lists. These changes improve correctness, safety, and UX. --- usermods/FSEQ/README.md | 36 ++++++---- usermods/FSEQ/usermod_fpp.h | 112 ++++++++++++++----------------- usermods/FSEQ/web_ui_manager.cpp | 33 +++++---- 3 files changed, 95 insertions(+), 86 deletions(-) diff --git a/usermods/FSEQ/README.md b/usermods/FSEQ/README.md index 787ad8f5e8..16b4bae385 100644 --- a/usermods/FSEQ/README.md +++ b/usermods/FSEQ/README.md @@ -1,6 +1,6 @@ # ✨ Usermod FSEQ ✨ -> **Created original by: Andrej Chrcek** **Updatet by: Danit2** +> **Original created original by: Andrej Chrcek** Welcome to the **Usermod FSEQ** project! This module extends your WLED setup by enabling FSEQ file playback from an SD card, including a web UI and UDP remote control. It combines creativity with functionality to enhance your lighting experience. @@ -67,39 +67,35 @@ custom_usermods = FSEQ GET /fsequi Returns the main HTML interface for the SD & FSEQ Manager. -GET api/sd/list +GET /api/sd/list Displays an HTML page listing all files on the SD card, including options to delete files and upload new ones. -POST api/sd/upload +POST /api/sd/upload Handles file uploads using multipart/form-data. -POST /sd/delete - +POST /api/sd/delete Deletes the specified file from the SD card. -Example: /sd/delete +Example: /api/sd/delete body: file=example.fseq --- ### FSEQ Control -GET api/fseq/list +GET /api/fseq/list Returns an HTML page listing all .fseq and .FSEQ files found on the SD card. Each file includes a play button. -POST api/fseq/start +POST /api/fseq/start body: file=animation.fseq Starts playback of the selected FSEQ file. -POST api/fseq/startloop +POST /api/fseq/startloop body: file=animation.fseq Starts playback of the selected FSEQ file in loop mode. -POST api/fseq/stop +POST /api/fseq/stop Stops the current FSEQ playback and clears the active session. -GET /api/fseq/list -Returns a JSON list of all FSEQ files on the SD card. - --- ### FPP Control @@ -113,18 +109,32 @@ Returns a JSON list of the system status GET /api/fppd/multiSyncSystems Returns a JSON list of the multisyncinfos +POST /fpp +Endpoint for file upload from xLights (raw, application/octet-stream) + +GET /fseqfilelist +Endpoint to list FSEQ files on SD card for FPP Player + +GET /fpp/connect +Endpoint to start FSEQ playback from FPP Player + +GET /fpp/stop +Endpoint to stop FSEQ playback + --- ## Configurable SPI Pin Settings Default SPI pin assignments for SD over SPI: +```cpp #ifdef WLED_USE_SD_SPI int8_t UsermodFseq::configPinSourceSelect = 5; int8_t UsermodFseq::configPinSourceClock = 18; int8_t UsermodFseq::configPinPoci = 19; int8_t UsermodFseq::configPinPico = 23; #endif +``` These values can be modified via the WLED Usermods settings tab without recompiling the firmware. diff --git a/usermods/FSEQ/usermod_fpp.h b/usermods/FSEQ/usermod_fpp.h index 1bb957db3d..ec90b14f5c 100644 --- a/usermods/FSEQ/usermod_fpp.h +++ b/usermods/FSEQ/usermod_fpp.h @@ -83,19 +83,6 @@ inline constexpr uint16_t UDP_SYNC_PORT = 32320; inline unsigned long lastPingTime = 0; inline constexpr unsigned long pingInterval = 5000; -// Inline functions to write 16-bit and 32-bit values -static inline void write16(uint8_t *dest, uint16_t value) { - dest[0] = (value >> 8) & 0xff; - dest[1] = value & 0xff; -} - -static inline void write32(uint8_t *dest, uint32_t value) { - dest[0] = (value >> 24) & 0xff; - dest[1] = (value >> 16) & 0xff; - dest[2] = (value >> 8) & 0xff; - dest[3] = value & 0xff; -} - // Structure for the synchronization packet // Using pragma pack to avoid any padding issues #pragma pack(push, 1) @@ -142,9 +129,16 @@ class UsermodFPP : public Usermod { doc["Variant"] = "WLED"; doc["Mode"] = "remote"; doc["Version"] = versionString; - - doc["majorVersion"] = 16; - doc["minorVersion"] = 0; + + 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(); @@ -267,7 +261,7 @@ class UsermodFPP : public Usermod { adv["majorVersion"] = major; adv["minorVersion"] = minor; - adv["typeId"] = 165; + adv["typeId"] = 195; adv["UUID"] = WiFi.macAddress(); JsonObject util = adv.createNestedObject("Utilization"); @@ -301,7 +295,7 @@ class UsermodFPP : public Usermod { sys["ip"] = WiFi.localIP().toString(); sys["version"] = versionString; sys["hardwareType"] = "WLED"; - sys["type"] = 165; + sys["type"] = 195; sys["num_chan"] = strip.getLength() * 3; sys["NumPixelPort"] = 1; sys["NumSerialPort"] = 0; @@ -417,31 +411,6 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { udp.writeTo(buf, sizeof(buf), destination, udpPort); } -/* // UDP - send a sync message - void sendSyncMessage(uint8_t action, const String &fileName, - uint32_t currentFrame, float secondsElapsed) { - FPPMultiSyncPacket syncPacket; - // Fill in header "FPPD" - syncPacket.header[0] = 'F'; - syncPacket.header[1] = 'P'; - syncPacket.header[2] = 'P'; - syncPacket.header[3] = 'D'; - syncPacket.packet_type = CTRL_PKT_SYNC; - write16((uint8_t *)&syncPacket.data_len, sizeof(syncPacket)); - syncPacket.sync_action = action; - syncPacket.sync_type = 0; // FSEQ synchronization - write32((uint8_t *)&syncPacket.frame_number, currentFrame); - syncPacket.seconds_elapsed = secondsElapsed; - strncpy(syncPacket.filename, fileName.c_str(), - sizeof(syncPacket.filename) - 1); - syncPacket.filename[sizeof(syncPacket.filename) - 1] = 0x00; - // Send to both broadcast and multicast addresses - udp.writeTo((uint8_t *)&syncPacket, sizeof(syncPacket), - IPAddress(255, 255, 255, 255), udpPort); - udp.writeTo((uint8_t *)&syncPacket, sizeof(syncPacket), multicastAddr, - udpPort); - } */ - // UDP - process received packet void processUdpPacket(AsyncUDPPacket packet) { // Print the raw UDP packet in hex format for debugging @@ -459,24 +428,45 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { uint8_t packetType = packet.data()[4]; switch (packetType) { case CTRL_PKT_SYNC: { - if (packet.length() < 17) { - DEBUG_PRINTLN(F("[FPP] Sync packet too short, ignoring")); - break; - } - FPPMultiSyncPacket *syncPacket = - reinterpret_cast(packet.data()); - DEBUG_PRINTLN(F("[FPP] Received UDP sync packet")); - // Print detailed sync packet information: - DEBUG_PRINTF("[FPP] Sync Packet - Action: %d\n", syncPacket->sync_action); - DEBUG_PRINT(F("[FPP] Filename: ")); - DEBUG_PRINTLN(syncPacket->filename); - DEBUG_PRINTF("[FPP] Frame Number: %lu\n", syncPacket->frame_number); - DEBUG_PRINTF("[FPP] Seconds Elapsed: %.2f\n", - syncPacket->seconds_elapsed); - ProcessSyncPacket(syncPacket->sync_action, String(syncPacket->filename), - syncPacket->seconds_elapsed); - break; - } + + const size_t baseSize = 17; // up to seconds_elapsed + + if (packet.length() <= baseSize) { + DEBUG_PRINTLN(F("[FPP] Sync packet too short, ignoring")); + break; + } + + FPPMultiSyncPacket *syncPacket = + reinterpret_cast(packet.data()); + + DEBUG_PRINTLN(F("[FPP] Received UDP sync packet")); + + DEBUG_PRINTF("[FPP] Sync Packet - Action: %d\n", syncPacket->sync_action); + DEBUG_PRINTF("[FPP] Frame Number: %lu\n", syncPacket->frame_number); + DEBUG_PRINTF("[FPP] Seconds Elapsed: %.2f\n", + syncPacket->seconds_elapsed); + + // ---- SAFE filename extraction ---- + size_t filenameOffset = 17; + size_t maxFilenameLen = + min((size_t)64, packet.length() - filenameOffset); + + char safeFilename[65]; + memcpy(safeFilename, + packet.data() + filenameOffset, + maxFilenameLen); + + safeFilename[maxFilenameLen] = '\0'; + + DEBUG_PRINT(F("[FPP] Filename: ")); + DEBUG_PRINTLN(safeFilename); + + ProcessSyncPacket(syncPacket->sync_action, + String(safeFilename), + syncPacket->seconds_elapsed); + + break; + } case CTRL_PKT_PING: DEBUG_PRINTLN(F("[FPP] Received UDP ping packet")); sendPingPacket(packet.remoteIP()); diff --git a/usermods/FSEQ/web_ui_manager.cpp b/usermods/FSEQ/web_ui_manager.cpp index 81472f3102..712a14a898 100644 --- a/usermods/FSEQ/web_ui_manager.cpp +++ b/usermods/FSEQ/web_ui_manager.cpp @@ -220,12 +220,14 @@ function toggleNormal(name, playBtn, loopBtn) { if (!isPlaying) { fetch('/api/fseq/start', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:'file='+encodeURIComponent(name)}) - - resetAllFseqButtons(); - - playBtn.dataset.state = "playing"; - playBtn.textContent = "Stop"; - playBtn.classList.add("btn-stop"); + .then(r => { + if (r.ok) { + resetAllFseqButtons(); + playBtn.dataset.state = "playing"; + playBtn.textContent = "Stop"; + playBtn.classList.add("btn-stop"); + } + }); } else { @@ -241,12 +243,14 @@ function toggleLoop(name, playBtn, loopBtn) { if (!isLooping) { fetch('/api/fseq/startloop', {method:'POST', headers:{'Content-Type':'application/x-www-form-urlencoded'}, body:'file='+encodeURIComponent(name)}) - - resetAllFseqButtons(); - - loopBtn.dataset.state = "looping"; - loopBtn.textContent = "Stop"; - loopBtn.classList.add("btn-stop"); + .then(r => { + if (r.ok) { + resetAllFseqButtons(); + playBtn.dataset.state = "looping"; + playBtn.textContent = "Stop"; + playBtn.classList.add("btn-stop"); + } + }); } else { @@ -428,6 +432,11 @@ void WebUIManager::registerEndpoints() { 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); }); From b1d49b51355620998c9f79f927f4735e68ff2ab2 Mon Sep 17 00:00:00 2001 From: Danit2 <71522810+Danit2@users.noreply.github.com> Date: Tue, 24 Feb 2026 22:50:40 +0100 Subject: [PATCH 7/9] Improve FPP sync parsing and SD upload handling Replace unsafe reinterpret_cast of UDP sync packet with explicit parsing of sync action, frame number and seconds elapsed, and pass those parsed values to ProcessSyncPacket. Add a concurrent-upload guard (returns 409) and make UsermodFPP::_name inline. In the web UI manager add UploadContext for safer upload state handling, rework /api/sd/upload to use the context (better error handling, proper cleanup and file close), write chunks only when the file is valid, and return appropriate responses. Also check JSON overflow when serializing file lists and return 507 if the buffer is too small. --- usermods/FSEQ/usermod_fpp.h | 25 ++++---- usermods/FSEQ/web_ui_manager.cpp | 98 +++++++++++++++++++++----------- 2 files changed, 79 insertions(+), 44 deletions(-) diff --git a/usermods/FSEQ/usermod_fpp.h b/usermods/FSEQ/usermod_fpp.h index ec90b14f5c..4157f80c27 100644 --- a/usermods/FSEQ/usermod_fpp.h +++ b/usermods/FSEQ/usermod_fpp.h @@ -436,15 +436,16 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { break; } - FPPMultiSyncPacket *syncPacket = - reinterpret_cast(packet.data()); + uint8_t syncAction = packet.data()[7]; + uint32_t frameNumber = 0; + float secondsElapsed = 0.0f; + memcpy(&frameNumber, packet.data() + 9, sizeof(frameNumber)); + memcpy(&secondsElapsed, packet.data() + 13, sizeof(secondsElapsed)); DEBUG_PRINTLN(F("[FPP] Received UDP sync packet")); - - DEBUG_PRINTF("[FPP] Sync Packet - Action: %d\n", syncPacket->sync_action); - DEBUG_PRINTF("[FPP] Frame Number: %lu\n", syncPacket->frame_number); - DEBUG_PRINTF("[FPP] Seconds Elapsed: %.2f\n", - syncPacket->seconds_elapsed); + DEBUG_PRINTF("[FPP] Sync Packet - Action: %d\n", syncAction); + DEBUG_PRINTF("[FPP] Frame Number: %lu\n", frameNumber); + DEBUG_PRINTF("[FPP] Seconds Elapsed: %.2f\n", secondsElapsed); // ---- SAFE filename extraction ---- size_t filenameOffset = 17; @@ -461,9 +462,7 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { DEBUG_PRINT(F("[FPP] Filename: ")); DEBUG_PRINTLN(safeFilename); - ProcessSyncPacket(syncPacket->sync_action, - String(safeFilename), - syncPacket->seconds_elapsed); + ProcessSyncPacket(syncAction, String(safeFilename), secondsElapsed); break; } @@ -566,6 +565,10 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { DEBUG_PRINTF("[FPP] Chunk index=%u len=%u total=%u\n", index, len, total); if (index == 0) { + if (uploadStream || currentUploadFile) { + request->send(409, "text/plain", "Upload already in progress"); + return; + } DEBUG_PRINTLN("[FPP] Starting file upload"); @@ -723,4 +726,4 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { bool readFromConfig(JsonObject &root) override { return true; } }; -const char UsermodFPP::_name[] PROGMEM = "FPP Connect"; \ No newline at end of file +inline const char UsermodFPP::_name[] PROGMEM = "FPP Connect"; \ No newline at end of file diff --git a/usermods/FSEQ/web_ui_manager.cpp b/usermods/FSEQ/web_ui_manager.cpp index 712a14a898..b0604a3f66 100644 --- a/usermods/FSEQ/web_ui_manager.cpp +++ b/usermods/FSEQ/web_ui_manager.cpp @@ -3,6 +3,11 @@ #include "sd_manager.h" #include "usermod_fseq.h" +struct UploadContext { + File* file; + bool error; +}; + static const char PAGE_HTML[] PROGMEM = R"rawliteral( @@ -471,44 +476,71 @@ void WebUIManager::registerEndpoints() { 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); }); // API - File Upload - server.on( - "/api/sd/upload", HTTP_POST, - [](AsyncWebServerRequest *request) { - request->send(200, "text/plain", "Upload complete"); - }, - [](AsyncWebServerRequest *request, String filename, size_t index, - uint8_t *data, size_t len, bool final) { - if (index == 0) { - if (!filename.startsWith("/")) - filename = "/" + filename; - File *f = new File(SD_ADAPTER.open(filename.c_str(), FILE_WRITE)); - request->_tempObject = f; - if (!*f) { - delete f; - request->_tempObject = nullptr; - request->send(500, "text/plain", "Failed to open file for writing"); - return; - } - } - File *f = static_cast(request->_tempObject); - if (f && *f) { - f->write(data, len); - if (final) { - f->close(); - delete f; - request->_tempObject = nullptr; - } - } else if (final && f) { - // Cleanup leaked handle if open failed and we somehow reach final - delete f; - request->_tempObject = nullptr; - } - }); + server.on( + "/api/sd/upload", HTTP_POST, + + // MAIN HANDLER + [](AsyncWebServerRequest *request) { + + UploadContext* ctx = static_cast(request->_tempObject); + + if (!ctx || ctx->error || !ctx->file || !*(ctx->file)) { + request->send(500, "text/plain", "Failed to open file for writing"); + } else { + request->send(200, "text/plain", "Upload complete"); + } + + // Cleanup + if (ctx) { + if (ctx->file) { + if (*(ctx->file)) ctx->file->close(); + delete ctx->file; + } + delete ctx; + request->_tempObject = nullptr; + } + }, + + // UPLOAD CALLBACK + [](AsyncWebServerRequest *request, String filename, size_t index, + uint8_t *data, size_t len, bool final) { + + UploadContext* ctx; + + if (index == 0) { + if (!filename.startsWith("/")) + filename = "/" + filename; + + ctx = new UploadContext(); + ctx->error = false; + ctx->file = new File(SD_ADAPTER.open(filename.c_str(), FILE_WRITE)); + + if (!*(ctx->file)) { + ctx->error = true; + } + + request->_tempObject = ctx; + } + + ctx = static_cast(request->_tempObject); + + if (!ctx || ctx->error || !ctx->file || !*(ctx->file)) + return; + + ctx->file->write(data, len); + + } + ); // API - File Delete server.on("/api/sd/delete", HTTP_POST, [](AsyncWebServerRequest *request) { From 5c1ad6346930ac73936ea3a2380bd6761c6c29ec Mon Sep 17 00:00:00 2001 From: Danit2 <71522810+Danit2@users.noreply.github.com> Date: Wed, 25 Feb 2026 22:30:23 +0100 Subject: [PATCH 8/9] Use MAC-based ID for FPP host and ping Replace the human-readable hostname with a deterministic unique device ID built from "WLED-" + MAC (colons removed) and use this ID consistently across all FPP payloads. This change prevents conflicts in FPP when multiple WLED devices share the same default hostname (e.g. multiple devices left as "WLED"). FPP requires unique host identifiers; duplicate hostnames can cause device collisions, sync issues, or undefined behavior in the player. The new unique ID is now used in: advancedView sys.id FPP ping packet hostname field (truncated to 64 bytes) The human-readable device name is preserved in HostDescription. --- usermods/FSEQ/usermod_fpp.h | 39 ++++++++++++++++++++++++------------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/usermods/FSEQ/usermod_fpp.h b/usermods/FSEQ/usermod_fpp.h index 4157f80c27..288280134e 100644 --- a/usermods/FSEQ/usermod_fpp.h +++ b/usermods/FSEQ/usermod_fpp.h @@ -123,8 +123,11 @@ class UsermodFPP : public Usermod { String devName = getDeviceName(); - doc["HostName"] = devName; - doc["HostDescription"] = "WLED"; + String id = "WLED-" + WiFi.macAddress(); + id.replace(":", ""); + + doc["HostName"] = id; + doc["HostDescription"] = devName; doc["Platform"] = "ESP32"; doc["Variant"] = "WLED"; doc["Mode"] = "remote"; @@ -229,18 +232,22 @@ class UsermodFPP : public Usermod { } // -------------------------------------------------- - // Advanced View (entscheidend für ESPixelStick!) + // Advanced View // -------------------------------------------------- JsonObject adv = doc.createNestedObject("advancedView"); - adv["HostName"] = getDeviceName(); - adv["HostDescription"] = "WLED"; + 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; - // Version aus versionString extrahieren (z.B. 16.0-alpha) uint16_t major = 0; uint16_t minor = 0; @@ -290,8 +297,11 @@ class UsermodFPP : public Usermod { String devName = getDeviceName(); + String id = "WLED-" + WiFi.macAddress(); + id.replace(":", ""); + sys["hostname"] = devName; - sys["id"] = WiFi.macAddress(); + sys["id"] = id; sys["ip"] = WiFi.localIP().toString(); sys["version"] = versionString; sys["hardwareType"] = "WLED"; @@ -329,7 +339,7 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { buf[7] = 0x03; // Ping packet version = 3 buf[8] = 0x00; // SubType = Ping - buf[9] = 0xC3; // Hardware Type = ESPixelStick ESP32 + buf[9] = 0xC3; // Hardware Type = ESPixelStick // -------------------------------------------------- // Version (MSB first!) @@ -373,12 +383,15 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { // -------------------------------------------------- // Hostname (19-83) 64 bytes + NULL // -------------------------------------------------- - String hostName = getDeviceName(); - if (hostName.length() > 64) - hostName = hostName.substring(0, 64); + + 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 < hostName.length()) ? hostName[i] : 0; + buf[19 + i] = (i < id.length()) ? id[i] : 0; } // -------------------------------------------------- @@ -726,4 +739,4 @@ void sendPingPacket(IPAddress destination = IPAddress(255, 255, 255, 255)) { bool readFromConfig(JsonObject &root) override { return true; } }; -inline const char UsermodFPP::_name[] PROGMEM = "FPP Connect"; \ No newline at end of file +inline const char UsermodFPP::_name[] PROGMEM = "FPP Connect"; From e3f667730839d69bf3b9006f4d8a18cd6578c19c Mon Sep 17 00:00:00 2001 From: Danit2 <71522810+Danit2@users.noreply.github.com> Date: Mon, 2 Mar 2026 07:05:30 +0100 Subject: [PATCH 9/9] Improve FSEQ Multisync Stability with Proportional Time-Based Drift Correction This changes replaces the previous hard frame-based synchronization logic in FSEQPlayer::syncPlayback() with a proportional time-based drift correction mechanism. Instead of immediately jumping to the expected frame when a small deviation is detected, the new implementation performs a smooth time adjustment using a proportional correction model. Hard resynchronization is now only triggered when the drift exceeds a defined safety threshold. This significantly improves visual smoothness during Multisync playback, especially on ESP32-based controllers where clock drift is common. --- usermods/FSEQ/fseq_player.cpp | 56 ++++++++++++++++++++++++++++------- 1 file changed, 46 insertions(+), 10 deletions(-) diff --git a/usermods/FSEQ/fseq_player.cpp b/usermods/FSEQ/fseq_player.cpp index 12278b405a..70314b4610 100644 --- a/usermods/FSEQ/fseq_player.cpp +++ b/usermods/FSEQ/fseq_player.cpp @@ -274,31 +274,67 @@ float FSEQPlayer::getElapsedSeconds() { } void FSEQPlayer::syncPlayback(float secondsElapsed) { + if (!isPlaying()) { DEBUG_PRINTLN("[FSEQ] Sync: Playback not active, cannot sync."); return; } - // Update internal secondsElapsed if we were tracking it - // FSEQPlayer::secondsElapsed = secondsElapsed; // If we were tracking it - uint32_t expectedFrame = (uint32_t)((secondsElapsed * 1000.0f) / file_header.step_time); + int32_t diff = (int32_t)expectedFrame - (int32_t)frame; - if (abs(diff) > 2) { + // ------------------------------- + // Hard Resync + // ------------------------------- + if (abs(diff) > 30) { + frame = expectedFrame; + uint32_t offset = - file_header.channel_data_offset + file_header.channel_count * frame; + file_header.channel_data_offset + + (uint32_t)file_header.channel_count * frame; + if (recordingFile.seek(offset)) { - DEBUG_PRINTF("[FSEQ] Sync: Adjusted frame to %lu (diff=%ld)\n", + DEBUG_PRINTF("[FSEQ] HARD Sync -> frame=%lu (diff=%ld)\n", expectedFrame, diff); } else { - DEBUG_PRINTLN("[FSEQ] Sync: Failed to seek to new frame"); + DEBUG_PRINTLN("[FSEQ] HARD Sync failed to seek"); } + + return; + } + + // ----------------------------------------- + // Soft Sync + // ----------------------------------------- + if (abs(diff) > 1) { + + // Proportionaler Faktor wächst mit Drift + float correctionFactor = 0.05f * abs(diff); + + // Begrenzen damit es nicht aggressiv wird + correctionFactor = constrain(correctionFactor, 0.05f, 0.4f); + + int32_t timeAdjustment = + (int32_t)(diff * file_header.step_time * correctionFactor); + + next_time -= timeAdjustment; + + DEBUG_PRINTF( + "[FSEQ] Soft Sync diff=%ld factor=%.3f adjust=%ldus\n", + diff, + correctionFactor, + timeAdjustment + ); + } else { - DEBUG_PRINTF("[FSEQ] Sync: No adjustment needed (current frame: %lu, " - "expected: %lu)\n", - frame, expectedFrame); + + DEBUG_PRINTF( + "[FSEQ] Sync OK (current=%lu expected=%lu)\n", + frame, + expectedFrame + ); } }