diff --git a/usermods/FSEQ/README.md b/usermods/FSEQ/README.md
new file mode 100644
index 0000000000..16b4bae385
--- /dev/null
+++ b/usermods/FSEQ/README.md
@@ -0,0 +1,149 @@
+# ✨ Usermod FSEQ ✨
+
+> **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.
+
+---
+
+# 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 /fsequi
+Returns the main HTML interface for the SD & FSEQ Manager.
+
+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
+Handles file uploads using multipart/form-data.
+
+POST /api/sd/delete
+Deletes the specified file from the SD card.
+Example: /api/sd/delete
+body: file=example.fseq
+
+---
+
+### FSEQ Control
+
+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
+body: file=animation.fseq
+Starts playback of the selected FSEQ file.
+
+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.
+
+---
+
+### 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
+
+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.
+
+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..70314b4610
--- /dev/null
+++ b/usermods/FSEQ/fseq_player.cpp
@@ -0,0 +1,340 @@
+#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() {
+ uint32_t packetLength = file_header.channel_count;
+ uint16_t lastLed =
+ 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);
+ uint32_t bytes_remaining = packetLength;
+ uint16_t index = playbackLedStart;
+ while (index < lastLed && bytes_remaining > 0) {
+ 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++) {
+ 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.channel_data_offset);
+ return false;
+ }
+
+ if (recordingRepeats > 0) {
+ recordingRepeats--;
+ frame = 0;
+ 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);
+ 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,
+ bool loop)
+{
+ if (recordingFile.available()) {
+ clearLastPlayback();
+ }
+ 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);
+ 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);
+ 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
+ recordingRepeats = loop
+ ? RECORDING_REPEAT_LOOP
+ : 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;
+ }
+
+ uint32_t expectedFrame =
+ (uint32_t)((secondsElapsed * 1000.0f) / file_header.step_time);
+
+ int32_t diff = (int32_t)expectedFrame - (int32_t)frame;
+
+ // -------------------------------
+ // Hard Resync
+ // -------------------------------
+ if (abs(diff) > 30) {
+
+ frame = expectedFrame;
+
+ uint32_t offset =
+ file_header.channel_data_offset +
+ (uint32_t)file_header.channel_count * frame;
+
+ if (recordingFile.seek(offset)) {
+ DEBUG_PRINTF("[FSEQ] HARD Sync -> frame=%lu (diff=%ld)\n",
+ expectedFrame, diff);
+ } else {
+ 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 OK (current=%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..eb05b98ce1
--- /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
+
+#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,
+ bool loop = false);
+ 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..27c560323d
--- /dev/null
+++ b/usermods/FSEQ/sd_manager.cpp
@@ -0,0 +1,21 @@
+#include "sd_manager.h"
+#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;
+#elif defined(WLED_USE_SD_MMC)
+ if (!SD_ADAPTER.begin())
+ return false;
+#endif
+ return true;
+}
+
+void SDManager::end() { SD_ADAPTER.end(); }
+
+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..e40a2e2040
--- /dev/null
+++ b/usermods/FSEQ/sd_manager.h
@@ -0,0 +1,21 @@
+#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();
+ 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..288280134e
--- /dev/null
+++ b/usermods/FSEQ/usermod_fpp.h
@@ -0,0 +1,742 @@
+#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 {
+ if (!_buffer) return 0;
+ size_t total = 0;
+ while (size > 0) {
+ size_t space = _capacity - _offset;
+ size_t toCopy = (size < space) ? size : space;
+ memcpy(_buffer + _offset, buffer, toCopy);
+ _offset += toCopy;
+ buffer += toCopy;
+ size -= toCopy;
+ total += toCopy;
+ if (_offset == _capacity)
+ 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
+inline constexpr uint16_t UDP_SYNC_PORT = 32320;
+
+inline unsigned long lastPingTime = 0;
+inline constexpr unsigned long pingInterval = 5000;
+
+// 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();
+
+ String id = "WLED-" + WiFi.macAddress();
+ id.replace(":", "");
+
+ doc["HostName"] = id;
+ doc["HostDescription"] = devName;
+ doc["Platform"] = "ESP32";
+ doc["Variant"] = "WLED";
+ doc["Mode"] = "remote";
+ doc["Version"] = versionString;
+
+ uint16_t major = 0, minor = 0;
+ String ver = versionString;
+ int dashPos = ver.indexOf('-');
+ if (dashPos > 0) ver = ver.substring(0, dashPos);
+ int dotPos = ver.indexOf('.');
+ if (dotPos > 0) { major = ver.substring(0, dotPos).toInt(); minor = ver.substring(dotPos + 1).toInt(); }
+ else { major = ver.toInt(); }
+ doc["majorVersion"] = major;
+ doc["minorVersion"] = minor;
+ doc["typeId"] = 195;
+ doc["UUID"] = WiFi.macAddress();
+
+ 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();
+ float elapsedF = FSEQPlayer::getElapsedSeconds();
+ uint32_t elapsed = (uint32_t)elapsedF;
+
+ doc["current_sequence"] = fileName;
+ doc["playlist"] = "";
+ doc["seconds_elapsed"] = String(elapsed);
+ doc["seconds_played"] = String(elapsed);
+ doc["seconds_remaining"] = "0";
+ doc["sequence_filename"] = fileName;
+
+ uint32_t mins = elapsed / 60;
+ uint32_t secs = elapsed % 60;
+ char timeStr[16];
+ snprintf(timeStr, sizeof(timeStr), "%02u:%02u", mins, secs);
+
+ doc["time_elapsed"] = timeStr;
+ doc["time_remaining"] = "00:00";
+
+ doc["status"] = 1;
+ doc["status_name"] = "playing";
+ doc["mode"] = 8;
+ doc["mode_name"] = "remote";
+
+ } else {
+
+ doc["current_sequence"] = "";
+ doc["playlist"] = "";
+ doc["seconds_elapsed"] = "0";
+ doc["seconds_played"] = "0";
+ doc["seconds_remaining"] = "0";
+ doc["sequence_filename"] = "";
+ doc["time_elapsed"] = "00:00";
+ doc["time_remaining"] = "00:00";
+ doc["status"] = 0;
+ doc["status_name"] = "idle";
+ doc["mode"] = 8;
+ doc["mode_name"] = "remote";
+ }
+
+ // --------------------------------------------------
+ // Advanced View
+ // --------------------------------------------------
+ JsonObject adv = doc.createNestedObject("advancedView");
+
+ String devName = getDeviceName();
+
+ String id = "WLED-" + WiFi.macAddress();
+ id.replace(":", "");
+
+ adv["HostName"] = id;
+ adv["HostDescription"] = devName;
+ adv["Platform"] = "WLED";
+ adv["Variant"] = "ESP32";
+ adv["Mode"] = "remote";
+ adv["Version"] = versionString;
+
+ uint16_t major = 0;
+ uint16_t minor = 0;
+
+ String ver = versionString;
+ int dashPos = ver.indexOf('-');
+ if (dashPos > 0) {
+ ver = ver.substring(0, dashPos);
+ }
+
+ int dotPos = ver.indexOf('.');
+ if (dotPos > 0) {
+ major = ver.substring(0, dotPos).toInt();
+ minor = ver.substring(dotPos + 1).toInt();
+ } else {
+ major = ver.toInt();
+ minor = 0;
+ }
+
+ adv["majorVersion"] = major;
+ adv["minorVersion"] = minor;
+ adv["typeId"] = 195;
+ adv["UUID"] = WiFi.macAddress();
+
+ JsonObject util = adv.createNestedObject("Utilization");
+ util["MemoryFree"] = ESP.getFreeHeap();
+ util["Uptime"] = millis();
+
+ adv["rssi"] = WiFi.RSSI();
+
+ JsonArray ips = adv.createNestedArray("IPS");
+ ips.add(WiFi.localIP().toString());
+
+ // --------------------------------------------------
+ // 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();
+
+ String id = "WLED-" + WiFi.macAddress();
+ id.replace(":", "");
+
+ sys["hostname"] = devName;
+ sys["id"] = id;
+ sys["ip"] = WiFi.localIP().toString();
+ sys["version"] = versionString;
+ sys["hardwareType"] = "WLED";
+ sys["type"] = 195;
+ sys["num_chan"] = strip.getLength() * 3;
+ sys["NumPixelPort"] = 1;
+ sys["NumSerialPort"] = 0;
+ sys["mode"] = "remote";
+
+ String json;
+ serializeJson(doc, json);
+ return json;
+ }
+
+ // 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
+
+ // --------------------------------------------------
+ // 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 id = "WLED-" + WiFi.macAddress();
+ id.replace(":", "");
+
+ if (id.length() > 64)
+ id = id.substring(0, 64);
+
+ for (int i = 0; i < 64; i++) {
+ buf[19 + i] = (i < id.length()) ? id[i] : 0;
+ }
+
+ // --------------------------------------------------
+ // 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 - 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() < 5)
+ return;
+ if (packet.data()[0] != 'F' || packet.data()[1] != 'P' ||
+ packet.data()[2] != 'P' || packet.data()[3] != 'D')
+ return;
+ uint8_t packetType = packet.data()[4];
+ switch (packetType) {
+ case CTRL_PKT_SYNC: {
+
+ const size_t baseSize = 17; // up to seconds_elapsed
+
+ if (packet.length() <= baseSize) {
+ DEBUG_PRINTLN(F("[FPP] Sync packet too short, ignoring"));
+ break;
+ }
+
+ 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", syncAction);
+ DEBUG_PRINTF("[FPP] Frame Number: %lu\n", frameNumber);
+ DEBUG_PRINTF("[FPP] Seconds Elapsed: %.2f\n", secondsElapsed);
+
+ // ---- 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(syncAction, String(safeFilename), secondsElapsed);
+
+ 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);
+
+ // 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) {
+ if (uploadStream || currentUploadFile) {
+ request->send(409, "text/plain", "Upload already in progress");
+ return;
+ }
+
+ 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_ADAPTER.exists(currentUploadFileName.c_str())) {
+ SD_ADAPTER.remove(currentUploadFileName.c_str());
+ }
+
+ currentUploadFile =
+ SD_ADAPTER.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();
+ }
+ }
+ }
+
+ uint16_t getId() override { return USERMOD_ID_FPP; }
+ void addToConfig(JsonObject &root) override {}
+ bool readFromConfig(JsonObject &root) override { return true; }
+};
+
+inline const char UsermodFPP::_name[] PROGMEM = "FPP Connect";
diff --git a/usermods/FSEQ/usermod_fseq.h b/usermods/FSEQ/usermod_fseq.h
new file mode 100644
index 0000000000..0f09a65e2e
--- /dev/null
+++ b/usermods/FSEQ/usermod_fseq.h
@@ -0,0 +1,193 @@
+#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
+#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:
+ 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;
+
+ int8_t oldCs = configPinSourceSelect;
+ int8_t oldSck = configPinSourceClock;
+ int8_t oldMiso = configPinPoci;
+ int8_t oldMosi = configPinPico;
+
+ 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(oldCs, oldSck, oldMiso, oldMosi); // 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(int8_t oldCs, int8_t oldSck, int8_t oldMiso, int8_t oldMosi) {
+ // Deinit SD if needed
+ SD_ADAPTER.end();
+ // Reallocate pins
+ 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},
+ {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.cpp b/usermods/FSEQ/web_ui_manager.cpp
new file mode 100644
index 0000000000..b0604a3f66
--- /dev/null
+++ b/usermods/FSEQ/web_ui_manager.cpp
@@ -0,0 +1,612 @@
+#include "web_ui_manager.h"
+#include "fseq_player.h"
+#include "sd_manager.h"
+#include "usermod_fseq.h"
+
+struct UploadContext {
+ File* file;
+ bool error;
+};
+
+static const char PAGE_HTML[] PROGMEM = R"rawliteral(
+
+
+
+
+WLED FSEQ UI
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Upload File
+
+
+
+
+
+
+
+
+
+
+
+
+)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();
+
+ // 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()) {
+
+ File file = root.openNextFile();
+ while (file) {
+
+ String name = file.name();
+
+ JsonObject obj = files.createNestedObject();
+ obj["name"] = name;
+ obj["size"] = (float)file.size() / 1024.0;
+
+ file.close();
+ file = root.openNextFile();
+ }
+ }
+
+ root.close();
+
+ rootObj["usedKB"] = (float)usedBytes / 1024.0;
+ rootObj["totalKB"] = (float)totalBytes / 1024.0;
+
+ 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 - List FSEQ files
+ server.on("/api/fseq/list", HTTP_GET, [](AsyncWebServerRequest *request) {
+
+ 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);
+
+ 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,
+
+ // 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) {
+ 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_POST, [](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, false);
+ request->send(200, "text/plain", "FSEQ started");
+ });
+
+ // API - Start FSEQ in loop mode
+ server.on(
+ "/api/fseq/startloop", HTTP_POST, [](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, true);
+ request->send(200, "text/plain", "FSEQ loop started");
+ });
+
+ // API - Stop FSEQ
+ server.on("/api/fseq/stop", HTTP_POST, [](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) {
+
+ DynamicJsonDocument doc(512);
+
+ doc["playing"] = FSEQPlayer::isPlaying();
+ doc["file"] = FSEQPlayer::getFileName();
+
+ String output;
+ serializeJson(doc, output);
+
+ request->send(200, "application/json", output);
+ });
+}
\ 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..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
@@ -267,6 +269,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();