fix POV Display usermod (#4427)

* POV Display usermod

this usermod adds a new effect called "POV Image".
To get it to work:
- read the README :)
- upload a bmp image to the ESP filesystem using "/edit" url.
- select "POV Image" effect.
- set the filename (ie: "/myimage.bmp") as segment name.
- rotate the segment at approximately 20 RPM.
- enjoy the show!
* improve file extension checks
* improve README, remove PNGdec reference, clean usermod
* restrain to esp32 platform + reduce memory footprint with malloc
pull/4874/head^2
Liliputech 2025-08-29 20:42:54 +02:00 zatwierdzone przez GitHub
rodzic d5d7fde30f
commit da7f107273
Nie znaleziono w bazie danych klucza dla tego podpisu
ID klucza GPG: B5690EEEBB952194
9 zmienionych plików z 395 dodań i 78 usunięć

Wyświetl plik

@ -28,7 +28,6 @@ lib_deps = ${esp8266.lib_deps}
; robtillaart/SHT85@~0.3.3
; ;gmag11/QuickESPNow @ ~0.7.0 # will also load QuickDebug
; https://github.com/blazoncek/QuickESPNow.git#optional-debug ;; exludes debug library
; bitbank2/PNGdec@^1.0.1 ;; used for POV display uncomment following
; ${esp32.AR_lib_deps} ;; needed for USERMOD_AUDIOREACTIVE
build_unflags = ${common.build_unflags}

Wyświetl plik

@ -0,0 +1,48 @@
## POV Display usermod
This usermod adds a new effect called “POV Image”.
![the usermod at work](pov_display.gif?raw=true)
###How does it work?
With proper configuration (see below) the main segment will display a single row of pixels from an image stored on the ESP.
It displays the image row by row at a high refresh rate.
If you move the pixel segment at the right speed, you will see the full image floating in the air thanks to the persistence of vision.
RGB LEDs only (no RGBW), with grouping set to 1 and spacing set to 0.
Best results with high-density strips (e.g., 144 LEDs/m).
To get it working:
- Resize your image. The height must match the number of LEDs in your strip/segment.
- Rotate your image 90° clockwise (height becomes width).
- Upload a BMP image (24-bit, uncompressed) to the ESP filesystem using the “/edit” URL.
- Select the “POV Image” effect.
- Set the segment name to the absolute filesystem path of the image (e.g., “/myimage.bmp”).
- The path is case-sensitive and must start with “/”.
- Rotate the pixel strip at approximately 20 RPM.
- Tune as needed so that one full revolution maps to the image width (if the image appears stretched or compressed, adjust RPM slightly).
- Enjoy the show!
Notes:
- Only 24-bit uncompressed BMP files are supported.
- The image must fit into ~64 KB of RAM (width × height × 3 bytes, plus row padding to a 4-byte boundary).
- Examples (approximate, excluding row padding):
- 128×128 (49,152 bytes) fits.
- 160×160 (76,800 bytes) does NOT fit.
- 96×192 (55,296 bytes) fits; padding may add a small overhead.
- If the rendered image appears mirrored or upsidedown, rotate 90° the other way or flip horizontally in your editor and try again.
- The path must be absolute.
### Requirements
- 1D rotating LED strip/segment (POV setup). Ensure the segment length equals the number of physical LEDs.
- BMP image saved as 24bit, uncompressed (no alpha, no palette).
- Sufficient free RAM (~64 KB) for the image buffer.
### Troubleshooting
- Nothing displays: verify the file exists at the exact absolute path (casesensitive) and is a 24bit uncompressed BMP.
- Garbled colors or wrong orientation: reexport as 24bit BMP and retry the rotation/flip guidance above.
- Image too large: reduce width and/or height until it fits within ~64 KB (see examples).
- Path issues: confirm you uploaded the file via the “/edit” URL and can see it in the filesystem browser.
### Safety
- Secure the rotating assembly and keep clear of moving parts.
- Balance the strip/hub to minimize vibration before running at speed.

Wyświetl plik

@ -0,0 +1,146 @@
#include "bmpimage.h"
#define BUF_SIZE 64000
byte * _buffer = nullptr;
uint16_t read16(File &f) {
uint16_t result;
f.read((uint8_t *)&result,2);
return result;
}
uint32_t read32(File &f) {
uint32_t result;
f.read((uint8_t *)&result,4);
return result;
}
bool BMPimage::init(const char * fn) {
File bmpFile;
int bmpDepth;
//first, check if filename exists
if (!WLED_FS.exists(fn)) {
return false;
}
bmpFile = WLED_FS.open(fn);
if (!bmpFile) {
_valid=false;
return false;
}
//so, the file exists and is opened
// Parse BMP header
uint16_t header = read16(bmpFile);
if(header != 0x4D42) { // BMP signature
_valid=false;
bmpFile.close();
return false;
}
//read and ingnore file size
read32(bmpFile);
(void)read32(bmpFile); // Read & ignore creator bytes
_imageOffset = read32(bmpFile); // Start of image data
// Read DIB header
read32(bmpFile);
_width = read32(bmpFile);
_height = read32(bmpFile);
if(read16(bmpFile) != 1) { // # planes -- must be '1'
_valid=false;
bmpFile.close();
return false;
}
bmpDepth = read16(bmpFile); // bits per pixel
if((bmpDepth != 24) || (read32(bmpFile) != 0)) { // 0 = uncompressed {
_width=0;
_valid=false;
bmpFile.close();
return false;
}
// If _height is negative, image is in top-down order.
// This is not canon but has been observed in the wild.
if(_height < 0) {
_height = -_height;
}
//now, we have successfully got all the basics
// BMP rows are padded (if needed) to 4-byte boundary
_rowSize = (_width * 3 + 3) & ~3;
//check image size - if it is too large, it will be unusable
if (_rowSize*_height>BUF_SIZE) {
_valid=false;
bmpFile.close();
return false;
}
bmpFile.close();
// Ensure filename fits our buffer (segment name length constraint).
size_t len = strlen(fn);
if (len > WLED_MAX_SEGNAME_LEN) {
return false;
}
strncpy(filename, fn, sizeof(filename));
filename[sizeof(filename) - 1] = '\0';
_valid = true;
return true;
}
void BMPimage::clear(){
strcpy(filename, "");
_width=0;
_height=0;
_rowSize=0;
_imageOffset=0;
_loaded=false;
_valid=false;
}
bool BMPimage::load(){
const size_t size = (size_t)_rowSize * (size_t)_height;
if (size > BUF_SIZE) {
return false;
}
File bmpFile = WLED_FS.open(filename);
if (!bmpFile) {
return false;
}
if (_buffer != nullptr) free(_buffer);
_buffer = (byte*)malloc(size);
if (_buffer == nullptr) return false;
bmpFile.seek(_imageOffset);
const size_t readBytes = bmpFile.read(_buffer, size);
bmpFile.close();
if (readBytes != size) {
_loaded = false;
return false;
}
_loaded = true;
return true;
}
byte* BMPimage::line(uint16_t n){
if (_loaded) {
return (_buffer+n*_rowSize);
} else {
return NULL;
}
}
uint32_t BMPimage::pixelColor(uint16_t x, uint16_t y){
uint32_t pos;
byte b,g,r; //colors
if (! _loaded) {
return 0;
}
if ( (x>=_width) || (y>=_height) ) {
return 0;
}
pos=y*_rowSize + 3*x;
//get colors. Note that in BMP files, they go in BGR order
b= _buffer[pos++];
g= _buffer[pos++];
r= _buffer[pos];
return (r<<16|g<<8|b);
}

Wyświetl plik

@ -0,0 +1,50 @@
#ifndef _BMPIMAGE_H
#define _BMPIMAGE_H
#include "Arduino.h"
#include "wled.h"
/*
* This class describes a bitmap image. Each object refers to a bmp file on
* filesystem fatfs.
* To initialize, call init(), passign to it name of a bitmap file
* at the root of fatfs filesystem:
*
* BMPimage myImage;
* myImage.init("logo.bmp");
*
* For performance reasons, before actually usign the image, you need to load
* it from filesystem to RAM:
* myImage.load();
* All load() operations use the same reserved buffer in RAM, so you can only
* have one file loaded at a time. Before loading a new file, always unload the
* previous one:
* myImage.unload();
*/
class BMPimage {
public:
int height() {return _height; }
int width() {return _width; }
int rowSize() {return _rowSize;}
bool isLoaded() {return _loaded; }
bool load();
void unload() {_loaded=false; }
byte * line(uint16_t n);
uint32_t pixelColor(uint16_t x,uint16_t y);
bool init(const char* fn);
void clear();
char * getFilename() {return filename;};
private:
char filename[WLED_MAX_SEGNAME_LEN+1]="";
int _width=0;
int _height=0;
int _rowSize=0;
int _imageOffset=0;
bool _loaded=false;
bool _valid=false;
};
extern byte * _buffer;
#endif

Wyświetl plik

@ -1,7 +1,5 @@
{
"name:": "pov_display",
"build": { "libArchive": false},
"dependencies": {
"bitbank2/PNGdec":"^1.0.3"
}
"platforms": ["espressif32"]
}

Wyświetl plik

@ -0,0 +1,47 @@
#include "pov.h"
POV::POV() {}
void POV::showLine(const byte * line, uint16_t size){
uint16_t i, pos;
uint8_t r, g, b;
if (!line) {
// All-black frame on null input
for (i = 0; i < SEGLEN; i++) {
SEGMENT.setPixelColor(i, CRGB::Black);
}
strip.show();
lastLineUpdate = micros();
return;
}
for (i = 0; i < SEGLEN; i++) {
if (i < size) {
pos = 3 * i;
// using bgr order
b = line[pos++];
g = line[pos++];
r = line[pos];
SEGMENT.setPixelColor(i, CRGB(r, g, b));
} else {
SEGMENT.setPixelColor(i, CRGB::Black);
}
}
strip.show();
lastLineUpdate = micros();
}
bool POV::loadImage(const char * filename){
if(!image.init(filename)) return false;
if(!image.load()) return false;
currentLine=0;
return true;
}
int16_t POV::showNextLine(){
if (!image.isLoaded()) return 0;
//move to next line
showLine(image.line(currentLine), image.width());
currentLine++;
if (currentLine == image.height()) {currentLine=0;}
return currentLine;
}

Wyświetl plik

@ -0,0 +1,42 @@
#ifndef _POV_H
#define _POV_H
#include "bmpimage.h"
class POV {
public:
POV();
/* Shows one line. line should be pointer to array which holds pixel colors
* (3 bytes per pixel, in BGR order). Note: 3, not 4!!!
* size should be size of array (number of pixels, not number of bytes)
*/
void showLine(const byte * line, uint16_t size);
/* Reads from file an image and making it current image */
bool loadImage(const char * filename);
/* Show next line of active image
Retunrs the index of next line to be shown (not yet shown!)
If it retunrs 0, it means we have completed showing the image and
next call will start again
*/
int16_t showNextLine();
//time since strip was last updated, in micro sec
uint32_t timeSinceUpdate() {return (micros()-lastLineUpdate);}
BMPimage * currentImage() {return &image;}
char * getFilename() {return image.getFilename();}
private:
BMPimage image;
int16_t currentLine=0; //next line to be shown
uint32_t lastLineUpdate=0; //time in microseconds
};
#endif

Wyświetl plik

@ -1,88 +1,75 @@
#include "wled.h"
#include <PNGdec.h>
#include "pov.h"
void * openFile(const char *filename, int32_t *size) {
f = WLED_FS.open(filename);
*size = f.size();
return &f;
}
static const char _data_FX_MODE_POV_IMAGE[] PROGMEM = "POV Image@!;;;;";
void closeFile(void *handle) {
if (f) f.close();
}
int32_t readFile(PNGFILE *pFile, uint8_t *pBuf, int32_t iLen)
{
int32_t iBytesRead;
iBytesRead = iLen;
File *f = static_cast<File *>(pFile->fHandle);
// Note: If you read a file all the way to the last byte, seek() stops working
if ((pFile->iSize - pFile->iPos) < iLen)
iBytesRead = pFile->iSize - pFile->iPos - 1; // <-- ugly work-around
if (iBytesRead <= 0)
return 0;
iBytesRead = (int32_t)f->read(pBuf, iBytesRead);
pFile->iPos = f->position();
return iBytesRead;
}
int32_t seekFile(PNGFILE *pFile, int32_t iPosition)
{
int i = micros();
File *f = static_cast<File *>(pFile->fHandle);
f->seek(iPosition);
pFile->iPos = (int32_t)f->position();
i = micros() - i;
return pFile->iPos;
}
void draw(PNGDRAW *pDraw) {
uint16_t usPixels[SEGLEN];
png.getLineAsRGB565(pDraw, usPixels, PNG_RGB565_LITTLE_ENDIAN, 0xffffffff);
for(int x=0; x < SEGLEN; x++) {
uint16_t color = usPixels[x];
byte r = ((color >> 11) & 0x1F);
byte g = ((color >> 5) & 0x3F);
byte b = (color & 0x1F);
SEGMENT.setPixelColor(x, RGBW32(r,g,b,0));
}
strip.show();
}
static POV s_pov;
uint16_t mode_pov_image(void) {
const char * filepath = SEGMENT.name;
int rc = png.open(filepath, openFile, closeFile, readFile, seekFile, draw);
if (rc == PNG_SUCCESS) {
rc = png.decode(NULL, 0);
png.close();
return FRAMETIME;
}
Segment& mainseg = strip.getMainSegment();
const char* segName = mainseg.name;
if (!segName) {
return FRAMETIME;
}
// Only proceed for files ending with .bmp (case-insensitive)
size_t segLen = strlen(segName);
if (segLen < 4) return FRAMETIME;
const char* ext = segName + (segLen - 4);
// compare case-insensitive to ".bmp"
if (!((ext[0]=='.') &&
(ext[1]=='b' || ext[1]=='B') &&
(ext[2]=='m' || ext[2]=='M') &&
(ext[3]=='p' || ext[3]=='P'))) {
return FRAMETIME;
}
const char* current = s_pov.getFilename();
if (current && strcmp(segName, current) == 0) {
s_pov.showNextLine();
return FRAMETIME;
}
static unsigned long s_lastLoadAttemptMs = 0;
unsigned long nowMs = millis();
// Retry at most twice per second if the image is not yet loaded.
if (nowMs - s_lastLoadAttemptMs < 500) return FRAMETIME;
s_lastLoadAttemptMs = nowMs;
s_pov.loadImage(segName);
return FRAMETIME;
}
class PovDisplayUsermod : public Usermod
{
public:
static const char _data_FX_MODE_POV_IMAGE[] PROGMEM = "POV Image@!;;;1";
class PovDisplayUsermod : public Usermod {
protected:
bool enabled = false; //WLEDMM
const char *_name; //WLEDMM
bool initDone = false; //WLEDMM
unsigned long lastTime = 0; //WLEDMM
public:
PNG png;
File f;
PovDisplayUsermod(const char *name, bool enabled)
: enabled(enabled) , _name(name) {}
void setup() override {
strip.addEffect(255, &mode_pov_image, _data_FX_MODE_POV_IMAGE);
//initDone removed (unused)
}
void setup() {
strip.addEffect(255, &mode_pov_image, _data_FX_MODE_POV_IMAGE);
void loop() override {
// if usermod is disabled or called during strip updating just exit
// NOTE: on very long strips strip.isUpdating() may always return true so update accordingly
if (!enabled || strip.isUpdating()) return;
// do your magic here
if (millis() - lastTime > 1000) {
lastTime = millis();
}
}
void loop() {
}
uint16_t getId()
{
return USERMOD_ID_POV_DISPLAY;
}
void connected() {}
uint16_t getId() override {
return USERMOD_ID_POV_DISPLAY;
}
};
static PovDisplayUsermod pov_display;
REGISTER_USERMOD(pov_display);
static PovDisplayUsermod pov_display("POV Display", false);
REGISTER_USERMOD(pov_display);

Plik binarny nie jest wyświetlany.

Po

Szerokość:  |  Wysokość:  |  Rozmiar: 988 KiB