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 + +image + +--- + +# 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 + + + + + + + + + + +
+ +

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(); + + // 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();