kopia lustrzana https://github.com/Aircoookie/WLED
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 mallocpull/4874/head^2
rodzic
d5d7fde30f
commit
da7f107273
|
@ -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}
|
||||
|
|
|
@ -0,0 +1,48 @@
|
|||
## POV Display usermod
|
||||
|
||||
This usermod adds a new effect called “POV Image”.
|
||||
|
||||

|
||||
|
||||
###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 upside‑down, 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 24‑bit, 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 (case‑sensitive) and is a 24‑bit uncompressed BMP.
|
||||
- Garbled colors or wrong orientation: re‑export as 24‑bit 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.
|
|
@ -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);
|
||||
}
|
|
@ -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
|
|
@ -1,7 +1,5 @@
|
|||
{
|
||||
"name:": "pov_display",
|
||||
"build": { "libArchive": false},
|
||||
"dependencies": {
|
||||
"bitbank2/PNGdec":"^1.0.3"
|
||||
}
|
||||
"platforms": ["espressif32"]
|
||||
}
|
|
@ -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;
|
||||
}
|
|
@ -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 ℑ}
|
||||
|
||||
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
|
|
@ -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 |
Ładowanie…
Reference in New Issue