diff --git a/usermods/FSEQ/README.md b/usermods/FSEQ/README.md new file mode 100644 index 0000000000..e0d8c4d27b --- /dev/null +++ b/usermods/FSEQ/README.md @@ -0,0 +1,131 @@ +# ✨ 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. + +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 + sd_card + +You must add the sd_card usermode also to use the FSEQ usermode. + +--- + +### Storage Configuration + +- If you use **SD over SPI**, you must manually set the build flag: + `-D WLED_USE_SD_SPI` + +- 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 + +--- + +## Summary + +The SD & FSEQ Usermod for WLED enables FSEQ playback from an SD card with a full-featured web interface and UDP synchronization. + +For further customization or support, please refer to the project documentation or open an issue on GitHub. \ No newline at end of file diff --git a/usermods/FSEQ/fseq_player.cpp b/usermods/FSEQ/fseq_player.cpp new file mode 100644 index 0000000000..08ed73dca2 --- /dev/null +++ b/usermods/FSEQ/fseq_player.cpp @@ -0,0 +1,332 @@ +#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 = "FSEQ"; + +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); + 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..9c01b63176 --- /dev/null +++ b/usermods/FSEQ/library.json @@ -0,0 +1,7 @@ +{ + "name": "FSEQ", + "build": {"libArchive": false}, + "dependencies": { + "bitbank2/unzipLIB":"1.0.0" + } +} \ No newline at end of file diff --git a/usermods/FSEQ/register_usermod.cpp b/usermods/FSEQ/register_usermod.cpp new file mode 100644 index 0000000000..af65e7f308 --- /dev/null +++ b/usermods/FSEQ/register_usermod.cpp @@ -0,0 +1,9 @@ +#include "usermod_fpp.h" +#include "usermod_fseq.h" +#include "wled.h" + +UsermodFseq usermodFseq; +REGISTER_USERMOD(usermodFseq); + +UsermodFPP usermodFpp; +REGISTER_USERMOD(usermodFpp); diff --git a/usermods/FSEQ/usermod_fpp.h b/usermods/FSEQ/usermod_fpp.h new file mode 100644 index 0000000000..05f182a791 --- /dev/null +++ b/usermods/FSEQ/usermod_fpp.h @@ -0,0 +1,740 @@ +#pragma once + +#include "usermod_fseq.h" // Contains FSEQ playback logic and getter methods for pins +#include "xlz_unzip.h" +#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; + + // Deferred XLZ handling + bool xlzChecked = false; // startup scan done + unsigned long xlzStartTime = 0; // startup timer + bool uploadSessionActive = false; // any recent upload activity + bool xlzPendingScan = false; // .xlz uploaded and waiting for idle timeout + bool xlzProcessing = false; // guard against re-entry + unsigned long lastUploadActivity = 0; // updated on every chunk + unsigned long lastUploadFinished = 0; // updated when a file finishes + + // 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(); + doc["zip"] = true; + + JsonObject utilization = doc.createNestedObject("Utilization"); + utilization["MemoryFree"] = ESP.getFreeHeap(); + utilization["Uptime"] = millis(); + + doc["rssi"] = WiFi.RSSI(); + + JsonArray ips = doc.createNestedArray("IPS"); + ips.add(WiFi.localIP().toString()); + + String json; + serializeJson(doc, json); + return json; + } + + // Build JSON with system status + String buildSystemStatusJSON() { + DynamicJsonDocument doc(2048); + + JsonObject mqtt = doc.createNestedObject("MQTT"); + mqtt["configured"] = false; + mqtt["connected"] = false; + + JsonObject currentPlaylist = doc.createNestedObject("current_playlist"); + currentPlaylist["count"] = "0"; + currentPlaylist["description"] = ""; + currentPlaylist["index"] = "0"; + currentPlaylist["playlist"] = ""; + currentPlaylist["type"] = ""; + + doc["volume"] = 70; + doc["media_filename"] = ""; + doc["fppd"] = "running"; + doc["current_song"] = ""; + + 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"; + } + + 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()); + + 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)); + + buf[0] = 'F'; + buf[1] = 'P'; + buf[2] = 'P'; + buf[3] = 'D'; + + buf[4] = 0x04; + + uint16_t dataLen = 294; + buf[5] = dataLen & 0xFF; + buf[6] = (dataLen >> 8) & 0xFF; + + buf[7] = 0x03; + buf[8] = 0x00; + + buf[9] = 0xC3; + + uint16_t versionMajor = 0; + 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; + + buf[14] = 0x08; + + IPAddress ip = WiFi.localIP(); + buf[15] = ip[0]; + buf[16] = ip[1]; + buf[17] = ip[2]; + buf[18] = ip[3]; + + String id = "WLED-" + WiFi.macAddress(); + id.replace(":", ""); + + if (id.length() > 64) + id = id.substring(0, 64); + + for (int i = 0; i < 64; i++) { + buf[19 + i] = (i < id.length()) ? id[i] : 0; + } + + String verStr = versionString; + for (int i = 0; i < 40; i++) { + buf[84 + i] = (i < verStr.length()) ? verStr[i] : 0; + } + + String hwType = "WLED"; + for (int i = 0; i < 40; i++) { + buf[125 + i] = (i < hwType.length()) ? hwType[i] : 0; + } + + String channelRanges = ""; + for (int i = 0; i < 120; i++) { + buf[166 + i] = (i < channelRanges.length()) ? channelRanges[i] : 0; + } + + udp.writeTo(buf, sizeof(buf), destination, udpPort); + } + + // UDP - process received packet + void processUdpPacket(AsyncUDPPacket packet) { + 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; + + 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); + + 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) { + 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); + + 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); + }); + + // 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) { + + // mark upload session activity on every chunk + uploadSessionActive = true; + lastUploadActivity = millis(); + + 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; + } + + String uploadedFile = currentUploadFileName; + + if (currentUploadFile) { + currentUploadFile.close(); + } + + unsigned long duration = millis() - uploadStartTime; + DEBUG_PRINTF("[FPP] Upload complete in %lu ms\n", duration); + + String lowerName = uploadedFile; + lowerName.toLowerCase(); + + if (lowerName.endsWith(".xlz")) { + xlzPendingScan = true; + DEBUG_PRINTF("[XLZ] Deferred unpack scheduled for: %s\n", + uploadedFile.c_str()); + } + + lastUploadFinished = millis(); + lastUploadActivity = lastUploadFinished; + + 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; + } + 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"); + }); + + 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")); + } + } + + // startup scan after reboot + if (xlzStartTime == 0) { + xlzStartTime = millis(); + DEBUG_PRINTF("[XLZ] start timer at %lu\n", xlzStartTime); + } + + if (!xlzChecked && (millis() - xlzStartTime >= 2000)) { + xlzChecked = true; + + DEBUG_PRINTF("[XLZ] 2s reached, starting startup scan at %lu\n", millis()); + + File root = SD_ADAPTER.open("/"); + if (!root || !root.isDirectory()) { + DEBUG_PRINTLN("[XLZ] SD root not accessible, skipping startup scan"); + if (root) root.close(); + } else { + root.close(); + DEBUG_PRINTLN("[XLZ] SD ready -> startup scanning"); + XLZUnzip::processAllPendingXLZ(); + DEBUG_PRINTLN("[XLZ] startup scan finished"); + } + } + + // deferred XLZ processing after upload inactivity + if (uploadSessionActive && xlzPendingScan && !xlzProcessing) { + if (millis() - lastUploadActivity >= 10000) { + DEBUG_PRINTF("[XLZ] upload idle for 10s -> processing pending XLZ at %lu\n", + millis()); + + xlzProcessing = true; + XLZUnzip::processAllPendingXLZ(); + xlzProcessing = false; + + xlzPendingScan = false; + uploadSessionActive = false; + + DEBUG_PRINTLN("[XLZ] deferred upload scan finished"); + } + } + } + + 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"; \ No newline at end of file diff --git a/usermods/FSEQ/usermod_fseq.h b/usermods/FSEQ/usermod_fseq.h new file mode 100644 index 0000000000..4e30f8f1a3 --- /dev/null +++ b/usermods/FSEQ/usermod_fseq.h @@ -0,0 +1,62 @@ +#pragma once + +#include "wled.h" + +#ifdef WLED_USE_SD_SPI +#include +#include +#endif + +// Define SD_ADAPTER macro if not already defined (used by FSEQ file operations) +#ifndef SD_ADAPTER +#if defined(WLED_USE_SD_SPI) +#define SD_ADAPTER SD +#elif defined(WLED_USE_SD_MMC) +#define SD_ADAPTER SD_MMC +#endif +#endif + +#include "fseq_player.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)); + + // 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); + } + void addToConfig(JsonObject &root) override {} + bool readFromConfig(JsonObject &root) override { return true; } +}; \ No newline at end of file diff --git a/usermods/FSEQ/web_ui_manager.cpp b/usermods/FSEQ/web_ui_manager.cpp new file mode 100644 index 0000000000..df2d0184c7 --- /dev/null +++ b/usermods/FSEQ/web_ui_manager.cpp @@ -0,0 +1,611 @@ +#include "web_ui_manager.h" +#include "fseq_player.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/usermods/FSEQ/xlz_unzip.cpp b/usermods/FSEQ/xlz_unzip.cpp new file mode 100644 index 0000000000..f63ef9fe67 --- /dev/null +++ b/usermods/FSEQ/xlz_unzip.cpp @@ -0,0 +1,330 @@ +#include "xlz_unzip.h" +#include "usermod_fseq.h" // Contains FSEQ playback logic and getter methods for pins + +namespace { +constexpr size_t XLZ_BUFFER_SIZE = 8192; + +// IMPORTANT: unzipLIB uses a fixed internal structure of roughly 41 KB. +// Do NOT put UNZIP on the task stack (e.g. as a local variable in loop()). +// Keeping one static instance avoids stack-canary panics in loopTask. +static UNZIP g_xlzZip; + +static bool endsWithIgnoreCase(const String& value, const char* suffix) { + const size_t n = strlen(suffix); + if (value.length() < n) return false; + return value.substring(value.length() - n).equalsIgnoreCase(suffix); +} + +static String normalizePath(const String& path) { + if (path.isEmpty()) return String("/"); + if (path[0] == '/') return path; + return String("/") + path; +} +} // namespace + +bool XLZUnzip::hasXLZExtension(const String& path) { + return endsWithIgnoreCase(path, ".xlz"); +} + +void* XLZUnzip::openZip(const char* filename, int32_t* size) { + if (size) *size = 0; + + String path = filename ? String(filename) : String(); + path = normalizePath(path); + DEBUG_PRINTF("[XLZ] openZip('%s')\n", path.c_str()); + + FsHandle* h = new FsHandle(); + h->file = SD_ADAPTER.open(path.c_str(), FILE_READ); + + if (!h->file) { + delete h; + DEBUG_PRINTF("[XLZ] Failed to open archive: %s\n", path.c_str()); + return nullptr; + } + + if (size) *size = static_cast(h->file.size()); + h->pos = 0; + return h; +} + +void XLZUnzip::closeZip(void* p) { + if (!p) return; + ZIPFILE* zf = static_cast(p); + FsHandle* h = static_cast(zf->fHandle); + if (h) { + if (h->file) h->file.close(); + delete h; + zf->fHandle = nullptr; + } +} + +int32_t XLZUnzip::readZip(void* p, uint8_t* buffer, int32_t length) { + if (!p || !buffer || length <= 0) return 0; + + ZIPFILE* zf = static_cast(p); + FsHandle* h = static_cast(zf->fHandle); + if (!h || !h->file) return 0; + + if (!h->file.seek(h->pos)) return 0; + + const int32_t bytesRead = static_cast(h->file.read(buffer, length)); + if (bytesRead > 0) h->pos += bytesRead; + return bytesRead; +} + +int32_t XLZUnzip::seekZip(void* p, int32_t position, int iType) { + if (!p) return -1; + + ZIPFILE* zf = static_cast(p); + FsHandle* h = static_cast(zf->fHandle); + if (!h || !h->file) return -1; + + int32_t newPos = position; + switch (iType) { + case SEEK_SET: + newPos = position; + break; + case SEEK_CUR: + newPos = h->pos + position; + break; + case SEEK_END: + newPos = static_cast(h->file.size()) + position; + break; + default: + return -1; + } + + if (newPos < 0) newPos = 0; + if (!h->file.seek(newPos)) return -1; + h->pos = newPos; + return h->pos; +} + +String XLZUnzip::sanitizeEntryName(const char* rawName) { + String name = rawName ? String(rawName) : String(); + name.replace('\\', '/'); + name.trim(); + + while (name.startsWith("/")) { + name.remove(0, 1); + } + while (name.startsWith("./")) { + name.remove(0, 2); + } + + // prevent path traversal on extraction + if (name.indexOf("../") >= 0 || name == "..") { + return String(); + } + + return name; +} + +bool XLZUnzip::unpackCurrentFile(UNZIP& zip, const String& outputPath, uint32_t expectedSize) { + if (zip.openCurrentFile() != UNZ_OK) { + DEBUG_PRINTLN(F("[XLZ] openCurrentFile() failed")); + return false; + } + + if (SD_ADAPTER.exists(outputPath.c_str())) { + SD_ADAPTER.remove(outputPath.c_str()); + } + + File out = SD_ADAPTER.open(outputPath.c_str(), FILE_WRITE); + if (!out) { + DEBUG_PRINTF("[XLZ] Failed to create output file: %s\n", outputPath.c_str()); + zip.closeCurrentFile(); + return false; + } + + uint8_t* buffer = static_cast(malloc(XLZ_BUFFER_SIZE)); + if (!buffer) { + DEBUG_PRINTLN(F("[XLZ] Failed to allocate unzip buffer")); + out.close(); + SD_ADAPTER.remove(outputPath.c_str()); + zip.closeCurrentFile(); + return false; + } + + bool ok = true; + uint32_t written = 0; + + while (true) { + const int rc = zip.readCurrentFile(buffer, XLZ_BUFFER_SIZE); + if (rc < 0) { + DEBUG_PRINTF("[XLZ] readCurrentFile() failed: %d\n", rc); + ok = false; + break; + } + if (rc == 0) break; + + if (out.write(buffer, static_cast(rc)) != static_cast(rc)) { + DEBUG_PRINTLN(F("[XLZ] Failed while writing decompressed data")); + ok = false; + break; + } + + written += static_cast(rc); + yield(); + } + + free(buffer); + out.flush(); + out.close(); + + const int closeRc = zip.closeCurrentFile(); + if (closeRc != UNZ_OK) { + DEBUG_PRINTF("[XLZ] closeCurrentFile() failed: %d\n", closeRc); + ok = false; + } + + if (ok && expectedSize > 0 && written != expectedSize) { + DEBUG_PRINTF("[XLZ] Size mismatch. expected=%lu actual=%lu\n", + static_cast(expectedSize), + static_cast(written)); + ok = false; + } + + if (!ok) { + SD_ADAPTER.remove(outputPath.c_str()); + } + + return ok; +} + +bool XLZUnzip::unpackArchive(const String& archivePath, String& finalOutputPath) { + const String zipPath = normalizePath(archivePath); + DEBUG_PRINTF("[XLZ] unpackArchive('%s')\n", zipPath.c_str()); + + const int openRc = g_xlzZip.openZIP(zipPath.c_str(), openZip, closeZip, readZip, seekZip); + if (openRc != UNZ_OK) { + DEBUG_PRINTF("[XLZ] openZIP() failed for %s: %d\n", zipPath.c_str(), openRc); + return false; + } + + bool ok = false; + unz_file_info fileInfo{}; + char entryName[256] = {0}; + char comment[64] = {0}; + + if (g_xlzZip.gotoFirstFile() != UNZ_OK) { + DEBUG_PRINTLN(F("[XLZ] Archive contains no files")); + g_xlzZip.closeZIP(); + return false; + } + + const int infoRc = g_xlzZip.getFileInfo(&fileInfo, entryName, sizeof(entryName), + nullptr, 0, comment, sizeof(comment)); + if (infoRc != UNZ_OK) { + DEBUG_PRINTF("[XLZ] getFileInfo() failed: %d\n", infoRc); + g_xlzZip.closeZIP(); + return false; + } + + String safeName = sanitizeEntryName(entryName); + if (safeName.isEmpty()) { + DEBUG_PRINTLN(F("[XLZ] Invalid filename inside archive")); + g_xlzZip.closeZIP(); + return false; + } + + finalOutputPath = zipPath; + if (hasXLZExtension(finalOutputPath)) { + finalOutputPath.remove(finalOutputPath.length() - 4); + finalOutputPath += ".fseq"; + } else { + finalOutputPath = normalizePath(safeName); + if (!endsWithIgnoreCase(finalOutputPath, ".fseq")) { + finalOutputPath += ".fseq"; + } + } + + const uint64_t totalBytes = SD_ADAPTER.totalBytes(); + const uint64_t usedBytes = SD_ADAPTER.usedBytes(); + const uint64_t freeBytes = (totalBytes >= usedBytes) ? (totalBytes - usedBytes) : 0; + if (fileInfo.uncompressed_size > freeBytes) { + DEBUG_PRINTF("[XLZ] Not enough free space. need=%lu free=%lu\n", + static_cast(fileInfo.uncompressed_size), + static_cast(freeBytes)); + g_xlzZip.closeZIP(); + return false; + } + + DEBUG_PRINTF("[XLZ] Extracting %s -> %s\n", zipPath.c_str(), finalOutputPath.c_str()); + ok = unpackCurrentFile(g_xlzZip, finalOutputPath, static_cast(fileInfo.uncompressed_size)); + + const int nextRc = g_xlzZip.gotoNextFile(); + if (ok && nextRc == UNZ_OK) { + DEBUG_PRINTLN(F("[XLZ] Warning: archive contains more than one file; only the first file was extracted")); + } + + g_xlzZip.closeZIP(); + return ok; +} + +bool XLZUnzip::unpackAndDelete(const String& archivePath, String* outFile) { + DEBUG_PRINTF("[XLZ] raw archivePath='%s'\n", archivePath.c_str()); + + const String zipPath = normalizePath(archivePath); + DEBUG_PRINTF("[XLZ] normalized archivePath='%s'\n", zipPath.c_str()); + + if (!hasXLZExtension(zipPath)) { + DEBUG_PRINTF("[XLZ] Not an .xlz file: %s\n", zipPath.c_str()); + return false; + } + + if (!SD_ADAPTER.exists(zipPath.c_str())) { + DEBUG_PRINTF("[XLZ] Archive not found: %s\n", zipPath.c_str()); + return false; + } + + String finalOutputPath; + const bool ok = unpackArchive(zipPath, finalOutputPath); + if (!ok) return false; + + if (!SD_ADAPTER.remove(zipPath.c_str())) { + DEBUG_PRINTF("[XLZ] Extracted, but could not delete archive: %s\n", zipPath.c_str()); + } + + if (outFile) { + *outFile = finalOutputPath; + } + + DEBUG_PRINTF("[XLZ] Done: %s\n", finalOutputPath.c_str()); + return true; +} + +uint8_t XLZUnzip::processAllPendingXLZ() { + DEBUG_PRINTLN("[XLZ] processAllPendingXLZ() entered"); + + File root = SD_ADAPTER.open("/"); + if (!root || !root.isDirectory()) { + DEBUG_PRINTLN("[XLZ] failed to open root directory"); + return 0; + } + + uint8_t count = 0; + File file = root.openNextFile(); + while (file) { + String path = String(file.name()); + path = normalizePath(path); + DEBUG_PRINTF("[XLZ] found entry: %s\n", path.c_str()); + + const bool isDir = file.isDirectory(); + file.close(); + + if (!isDir && hasXLZExtension(path)) { + DEBUG_PRINTF("[XLZ] unpacking: %s\n", path.c_str()); + if (unpackAndDelete(path, nullptr)) { + ++count; + } + } + + file = root.openNextFile(); + yield(); + } + + root.close(); + DEBUG_PRINTF("[XLZ] processAllPendingXLZ() done, count=%u\n", count); + return count; +} diff --git a/usermods/FSEQ/xlz_unzip.h b/usermods/FSEQ/xlz_unzip.h new file mode 100644 index 0000000000..030c478b8d --- /dev/null +++ b/usermods/FSEQ/xlz_unzip.h @@ -0,0 +1,37 @@ +#pragma once + +#include "wled.h" +#include + +#ifdef WLED_USE_SD_SPI + #include + #include +#elif defined(WLED_USE_SD_MMC) + #include +#endif + +class XLZUnzip { +public: + // Unpacks one .xlz archive to the SD card. + // On success, the .xlz file is deleted and the final .fseq path is returned in outFile. + static bool unpackAndDelete(const String& archivePath, String* outFile = nullptr); + + // Optional helper: scan the SD root and unpack all .xlz files. + static uint8_t processAllPendingXLZ(); + +private: + struct FsHandle { + File file; + int32_t pos = 0; + }; + + static void* openZip(const char* filename, int32_t* size); + static void closeZip(void* p); + static int32_t readZip(void* p, uint8_t* buffer, int32_t length); + static int32_t seekZip(void* p, int32_t position, int iType); + + static bool unpackArchive(const String& archivePath, String& finalOutputPath); + static bool unpackCurrentFile(UNZIP& zip, const String& outputPath, uint32_t expectedSize); + static String sanitizeEntryName(const char* rawName); + static bool hasXLZExtension(const String& path); +}; diff --git a/usermods/sd_card/sd_card.cpp b/usermods/sd_card/sd_card.cpp index 4e68b97a34..03d8dadd91 100644 --- a/usermods/sd_card/sd_card.cpp +++ b/usermods/sd_card/sd_card.cpp @@ -15,7 +15,13 @@ #ifdef WLED_USE_SD_MMC #elif defined(WLED_USE_SD_SPI) + #if CONFIG_IDF_TARGET_ESP32 SPIClass spiPort = SPIClass(VSPI); + #elif CONFIG_IDF_TARGET_ESP32S3 + SPIClass spiPort = SPI; + #else + SPIClass spiPort = SPI; + #endif #endif void listDir( const char * dirname, uint8_t levels); 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();