diff --git a/platformio_override.sample.ini b/platformio_override.sample.ini index ee2b17714..d11d1f50b 100644 --- a/platformio_override.sample.ini +++ b/platformio_override.sample.ini @@ -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} diff --git a/usermods/pov_display/README.md b/usermods/pov_display/README.md new file mode 100644 index 000000000..2b1659085 --- /dev/null +++ b/usermods/pov_display/README.md @@ -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 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. \ No newline at end of file diff --git a/usermods/pov_display/bmpimage.cpp b/usermods/pov_display/bmpimage.cpp new file mode 100644 index 000000000..2aea5c8d6 --- /dev/null +++ b/usermods/pov_display/bmpimage.cpp @@ -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); +} diff --git a/usermods/pov_display/bmpimage.h b/usermods/pov_display/bmpimage.h new file mode 100644 index 000000000..a83d1fa90 --- /dev/null +++ b/usermods/pov_display/bmpimage.h @@ -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 diff --git a/usermods/pov_display/library.json.disabled b/usermods/pov_display/library.json similarity index 54% rename from usermods/pov_display/library.json.disabled rename to usermods/pov_display/library.json index 2dd944a8a..461b1e2d4 100644 --- a/usermods/pov_display/library.json.disabled +++ b/usermods/pov_display/library.json @@ -1,7 +1,5 @@ { "name:": "pov_display", "build": { "libArchive": false}, - "dependencies": { - "bitbank2/PNGdec":"^1.0.3" - } + "platforms": ["espressif32"] } diff --git a/usermods/pov_display/pov.cpp b/usermods/pov_display/pov.cpp new file mode 100644 index 000000000..ea5a43ed6 --- /dev/null +++ b/usermods/pov_display/pov.cpp @@ -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; +} diff --git a/usermods/pov_display/pov.h b/usermods/pov_display/pov.h new file mode 100644 index 000000000..cb543d2ea --- /dev/null +++ b/usermods/pov_display/pov.h @@ -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 diff --git a/usermods/pov_display/pov_display.cpp b/usermods/pov_display/pov_display.cpp index b2b91f7d5..ac68e1b20 100644 --- a/usermods/pov_display/pov_display.cpp +++ b/usermods/pov_display/pov_display.cpp @@ -1,88 +1,75 @@ #include "wled.h" -#include +#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(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(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); \ No newline at end of file +static PovDisplayUsermod pov_display("POV Display", false); +REGISTER_USERMOD(pov_display); diff --git a/usermods/pov_display/pov_display.gif b/usermods/pov_display/pov_display.gif new file mode 100644 index 000000000..58f8ee0c1 Binary files /dev/null and b/usermods/pov_display/pov_display.gif differ