Skip to content

added support for fseq files #4570

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 9 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

117 changes: 117 additions & 0 deletions usermods/FSEQ/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# ✨ Usermod FSEQ ✨

> **Created by: Andrej Chrcek**

Welcome to the **Usermod FSEQ** project! This innovative module empowers your WLED setup by enabling FSEQ file playback from an SD card, complete with a sleek web UI and UDP remote control. Dive into a world where creativity meets functionality and transform your lighting experience.

# SWEB UI http://yourIP/sd/ui

# 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 also supports configurable SPI pin settings for SD 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 FSEQ playback.
- **UDP Synchronization:** Remote control via UDP packets.
- **Configurable SPI Pins:** SPI pin assignments are configurable through WLED’s configuration JSON.

---

## Installation

### 1. Configure PlatformIO

Add the following configuration to your `platformio_override.ini` (or `platformio.ini`) file:

```ini
[env:esp32dev_V4]
build_flags =
-D WLED_USE_SD_SPI
-D USERMOD_FPP
-D USERMOD_FSEQ
-I wled00/src/dependencies/json
; optional:
; -D WLED_DEBUG


### 2. Update the WLED Web Interface (Optional)

To integrate the new FSEQ functionality into the WLED UI, add a new button to the navigation area in your `wled00/data/index.htm` file. For example:

<!-- New button for SD & FSEQ Manager -->
<button onclick="window.location.href=getURL('/fsequi');">
<i class="icons">&#xe0d2;</i>
<p class="tab-label">Fseq</p>
</button>

### 3. Modify usermods_list.cpp

Register the FSEQ usermod by adding the following lines to `usermods_list.cpp`. Ensure no conflicting modules are included:

```cpp
#ifdef USERMOD_FSEQ
#include "../usermods/FSEQ/usermod_fseq.h"
#endif

#ifdef USERMOD_FSEQ
UsermodManager::add(new UsermodFseq());
#endif

// Remove or comment out any conflicting SD card usermod:
// //#include "../usermods/sd_card/usermod_sd_card.h"
// //#ifdef SD_ADAPTER
// //UsermodManager::add(new UsermodSdCard());
// //#endif

HTTP Endpoints

The following endpoints are available:
• GET /sd/ui
Description: Returns an HTML page with the SD & FSEQ Manager interface.
Usage: Open this URL in a browser.
• GET /sd/list
Description: Displays an HTML page listing all files on the SD card, with options to delete files and a form to upload new files.
Usage: Access this URL to view the SD file list.
• POST /sd/upload
Description: Handles file uploads to the SD card using a multipart/form-data POST request.
Usage: Use a file upload form or an HTTP client.
• GET /sd/delete
Description: Deletes a specified file from the SD card. Requires a query parameter path indicating the file path.
Usage: Example: /sd/delete?path=/example.fseq.
• GET /fseq/list
Description: Returns an HTML page listing all FSEQ files (with .fseq or .FSEQ extensions) on the SD card. Each file includes a button to play it.
Usage: Open this URL in a browser to view and interact with the file list.
• GET /fseq/start
Description: Starts playback of a selected FSEQ file. Requires a file query parameter and optionally a t parameter (time offset in seconds).
Usage: Example: /fseq/start?file=/animation.fseq&t=10.
• GET /fseq/stop
Description: Stops the current FSEQ playback and clears the active playback session.
Usage: Send an HTTP GET request to stop playback.
• GET /fseqfilelist
Description: Returns a JSON list of all FSEQ files found on the SD card.
Usage: Open this URL or send an HTTP GET request to retrieve the file list.


Configurable SPI Pin Settings

The default SPI pin assignments for SD SPI are defined as follows:

#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 WLED’s configuration JSON using the addToConfig() and readFromConfig() methods. This allows you to change the pin settings without recompiling the firmware.

Summary

The SD & FSEQ Usermod for WLED enables FSEQ file playback from an SD card with a full-featured web UI and UDP synchronization for remote control. With configurable SPI pin settings, this usermod integrates seamlessly into WLED, providing additional functionality without modifying core code.

For further customization or support, please refer to the project documentation or open an issue on GitHub.

233 changes: 233 additions & 0 deletions usermods/FSEQ/fseq_player.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
#include "fseq_player.h"
#include <Arduino.h>
#include "wled.h"

#ifdef WLED_USE_SD_SPI
#include <SPI.h>
#include <SD.h>
#elif defined(WLED_USE_SD_MMC)
#include "SD_MMC.h"
#endif

File FSEQPlayer::recordingFile;
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() {
char buffer[4];
if (recordingFile.readBytes(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() {
char buffer[3];
if (recordingFile.readBytes(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() {
char buffer[2];
if (recordingFile.readBytes(buffer, 2) < 2) return 0;
return (uint16_t)buffer[0] | ((uint16_t)buffer[1] << 8);
}

inline uint8_t FSEQPlayer::readUInt8() {
char buffer[1];
if (recordingFile.readBytes(buffer, 1) < 1) return 0;
return (uint8_t)buffer[0];
}

bool FSEQPlayer::fileOnSD(const char* filepath) {
uint8_t cardType = SD_ADAPTER.cardType();
if(cardType == CARD_NONE) return false;
return SD_ADAPTER.exists(filepath);
}

bool FSEQPlayer::fileOnFS(const char* filepath) {
return false;
}

void FSEQPlayer::printHeaderInfo() {
DEBUG_PRINTLN("FSEQ file header:");
DEBUG_PRINTF(" channel_data_offset = %d\n", file_header.channel_data_offset);
DEBUG_PRINTF(" minor_version = %d\n", file_header.minor_version);
DEBUG_PRINTF(" major_version = %d\n", file_header.major_version);
DEBUG_PRINTF(" header_length = %d\n", file_header.header_length);
DEBUG_PRINTF(" channel_count = %d\n", file_header.channel_count);
DEBUG_PRINTF(" frame_count = %d\n", file_header.frame_count);
DEBUG_PRINTF(" step_time = %d\n", file_header.step_time);
DEBUG_PRINTF(" flags = %d\n", file_header.flags);
}

void FSEQPlayer::processFrameData() {
uint16_t packetLength = file_header.channel_count;
uint16_t lastLed = min(playbackLedStop, uint16_t(playbackLedStart + (packetLength / 3)));
char frame_data[buffer_size];
CRGB* crgb = reinterpret_cast<CRGB*>(frame_data);
uint16_t bytes_remaining = packetLength;
uint16_t index = playbackLedStart;
while (index < lastLed && bytes_remaining > 0) {
uint16_t length = min(bytes_remaining, buffer_size);
recordingFile.readBytes(frame_data, length);
bytes_remaining -= length;
for (uint16_t offset = 0; offset < length / 3; offset++) {
setRealtimePixel(index, crgb[offset].r, crgb[offset].g, crgb[offset].b, 0);
if (++index > lastLed) break;
}
}
strip.show();
realtimeLock(3000, REALTIME_MODE_FSEQ);
next_time = now + file_header.step_time;
}

bool FSEQPlayer::stopBecauseAtTheEnd() {
if (!recordingFile.available()) {
if (recordingRepeats == RECORDING_REPEAT_LOOP) {
// Reset file pointer and frame counter for continuous loop
recordingFile.seek(0);
frame = 0;
} else if (recordingRepeats > 0) {
recordingFile.seek(0);
recordingRepeats--;
frame = 0;
DEBUG_PRINTF("Repeat recording again for: %d\n", recordingRepeats);
} else {
DEBUG_PRINTLN("Finished playing recording, disabling realtime mode");
realtimeLock(10, REALTIME_MODE_INACTIVE);
recordingFile.close();
clearLastPlayback();
return true;
}
}
return false;
}

void FSEQPlayer::playNextRecordingFrame() {
if (stopBecauseAtTheEnd()) return;
uint32_t offset = file_header.channel_count * frame++;
offset += file_header.channel_data_offset;
if (!recordingFile.seek(offset)) {
if (recordingFile.position() != offset) {
DEBUG_PRINTLN("Failed to seek to proper offset for channel data!");
return;
}
}
processFrameData();
}

void FSEQPlayer::handlePlayRecording() {
now = millis();
if (realtimeMode != REALTIME_MODE_FSEQ) return;
if (now < next_time) return;
playNextRecordingFrame();
}

void FSEQPlayer::loadRecording(const char* filepath, uint16_t startLed, uint16_t stopLed, float secondsElapsed) {
if (recordingFile.available()) {
clearLastPlayback();
recordingFile.close();
}
playbackLedStart = startLed;
playbackLedStop = stopLed;
if (playbackLedStart == uint16_t(-1) || playbackLedStop == uint16_t(-1)) {
Segment sg = strip.getSegment(-1);
playbackLedStart = sg.start;
playbackLedStop = sg.stop;
}
DEBUG_PRINTF("FSEQ load animation on LED %d to %d\n", playbackLedStart, playbackLedStop);
if (fileOnSD(filepath)) {
DEBUG_PRINTF("Read file from SD: %s\n", filepath);
recordingFile = SD_ADAPTER.open(filepath, "rb");
} else if (fileOnFS(filepath)) {
DEBUG_PRINTF("Read file from FS: %s\n", filepath);
} else {
DEBUG_PRINTF("File %s not found (%s)\n", filepath, USED_STORAGE_FILESYSTEMS);
return;
}
if ((uint64_t)recordingFile.available() < sizeof(file_header)) {
DEBUG_PRINTF("Invalid file size: %d\n", recordingFile.available());
recordingFile.close();
return;
}
for (int i = 0; i < 4; i++) {
file_header.identifier[i] = readUInt8();
}
file_header.channel_data_offset = readUInt16();
file_header.minor_version = readUInt8();
file_header.major_version = readUInt8();
file_header.header_length = readUInt16();
file_header.channel_count = readUInt32();
file_header.frame_count = readUInt32();
file_header.step_time = readUInt8();
file_header.flags = readUInt8();
printHeaderInfo();
if (file_header.identifier[0] != 'P' || file_header.identifier[1] != 'S' ||
file_header.identifier[2] != 'E' || file_header.identifier[3] != 'Q') {
DEBUG_PRINTF("Error reading FSEQ file %s header, invalid identifier\n", filepath);
recordingFile.close();
return;
}
if (((uint64_t)file_header.channel_count * (uint64_t)file_header.frame_count) + file_header.header_length > UINT32_MAX) {
DEBUG_PRINTF("Error reading FSEQ file %s header, file too long (max 4gb)\n", filepath);
recordingFile.close();
return;
}
if (file_header.step_time < 1) {
DEBUG_PRINTF("Invalid step time %d, using default %d instead\n", file_header.step_time, FSEQ_DEFAULT_STEP_TIME);
file_header.step_time = FSEQ_DEFAULT_STEP_TIME;
}
if (realtimeOverride == REALTIME_OVERRIDE_ONCE) {
realtimeOverride = REALTIME_OVERRIDE_NONE;
}
frame = (uint32_t)((secondsElapsed * 1000.0f) / file_header.step_time);
if (frame >= file_header.frame_count) {
frame = file_header.frame_count - 1;
}
// Set loop mode if secondsElapsed is exactly 1.0f
if (secondsElapsed == 1.0f) {
recordingRepeats = RECORDING_REPEAT_LOOP;
} else {
recordingRepeats = RECORDING_REPEAT_DEFAULT;
}
playNextRecordingFrame();
}

void FSEQPlayer::clearLastPlayback() {
for (uint16_t i = playbackLedStart; i < playbackLedStop; i++) {
setRealtimePixel(i, 0, 0, 0, 0);
}
frame = 0;
}

bool FSEQPlayer::isPlaying() {
return recordingFile && recordingFile.available();
}

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;

if (abs(diff) > 2) {
frame = expectedFrame;
uint32_t offset = file_header.channel_data_offset + file_header.channel_count * frame;
if (recordingFile.seek(offset)) {
DEBUG_PRINTF("[FSEQ] Sync: Adjusted frame to %lu (diff=%ld)\n", expectedFrame, diff);
} else {
DEBUG_PRINTLN("[FSEQ] Sync: Failed to seek to new frame");
}
} else {
DEBUG_PRINTF("[FSEQ] Sync: No adjustment needed (current frame: %lu, expected: %lu)\n", frame, expectedFrame);
}
}
Loading