diff --git a/.gitignore b/.gitignore index 0002d27..8ff3d53 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ node_modules/ *~ .*~ tmp/ +*.tgz diff --git a/EPDBase.js b/EPDBase.js index baf8ff8..fdb11be 100644 --- a/EPDBase.js +++ b/EPDBase.js @@ -13,16 +13,26 @@ class EPDBase { bitsPerWord: 8 }; - // GPIO pins (using gpiod for RPi5) + // GPIO pins (using gpiod for RPi5). Chip select is not listed here: + // it is driven by the SPI controller's CE line, not a GPIO. this.pins = { RST: validatePin('rstPin', options.rstPin ?? 17), DC: validatePin('dcPin', options.dcPin ?? 25), - CS: validatePin('csPin', options.csPin ?? 22), BUSY: validatePin('busyPin', options.busyPin ?? 24), PWR: validatePin('pwrPin', options.pwrPin ?? 18) }; + if (options.csPin !== undefined) { + console.warn("waveshare-epaper: csPin is ignored - chip select is driven by the SPI controller's CE line (deviceNumber selects CE0/CE1)"); + } this.gpioChip = options.gpioChip || 'gpiochip0'; + // How long waitUntilIdle() polls the BUSY pin before throwing + this.busyTimeoutMs = options.busyTimeoutMs ?? 10000; + + // Sanity cap on PNG dimensions to prevent huge allocations from + // malformed or hostile image files + this.maxImagePixels = options.maxImagePixels ?? (1 << 24); // ~16.7M pixels + // GPIO level of the BUSY pin while the panel is busy. SSD-family // controllers hold BUSY high while busy (the default); UC8176-class // and IT8951 controllers hold it low (those drivers override with 0). @@ -34,6 +44,7 @@ class EPDBase { this.spi = options.spi || new SpiDeviceBackend(this.busNumber, this.deviceNumber, this.spiOptions); this.initialized = false; + this.displayInProgress = false; // These will be set by subclasses this.width = 0; @@ -144,16 +155,16 @@ class EPDBase { } async waitUntilIdle() { - let timeout = 0; - const maxTimeout = 100; // 10 seconds max wait + const pollMs = 100; + const maxPolls = Math.ceil(this.busyTimeoutMs / pollMs); + let polls = 0; while (await this.readGPIO(this.pins.BUSY) === this.busyActiveLevel) { - await this.delay(100); - timeout++; + await this.delay(pollMs); + polls++; - if (timeout >= maxTimeout) { - console.log('Warning: Display busy timeout - continuing anyway'); - break; + if (polls >= maxPolls) { + throw new Error(`Display busy timeout after ${this.busyTimeoutMs}ms - check wiring and busyPin setting`); } } } @@ -208,9 +219,17 @@ class EPDBase { if (!this.initialized) { throw new Error('Display not initialized. Call init() first.'); } + if (this.displayInProgress) { + throw new Error('display() already in progress - concurrent refreshes would corrupt the panel data stream'); + } - // Display-specific implementation (implemented by subclasses) - await this.displayImage(); + this.displayInProgress = true; + try { + // Display-specific implementation (implemented by subclasses) + await this.displayImage(); + } finally { + this.displayInProgress = false; + } } // Abstract methods to be implemented by subclasses @@ -428,10 +447,15 @@ class EPDBase { fs.createReadStream(filePath) .pipe(new PNG()) .on('parsed', function() { + if (this.width * this.height > self.maxImagePixels) { + reject(new Error(`PNG too large: ${this.width}x${this.height} exceeds the ${self.maxImagePixels} pixel limit (maxImagePixels option)`)); + return; + } + const imageData = { width: this.width, height: this.height, - pixels: new Array(this.width * this.height) + pixels: new Uint8Array(this.width * this.height) }; // Convert RGBA pixels to display format @@ -547,6 +571,16 @@ class EPDBase { } async cleanup() { + // Put the panel into deep sleep before cutting power - leaving + // e-paper active with a static charge degrades the panel over time + if (this.initialized) { + try { + await this.sleep(); + } catch (error) { + // Ignore errors during cleanup + } + } + try { this.spi.close(); } catch (error) { @@ -568,6 +602,8 @@ class EPDBase { } catch (error) { // Ignore errors during cleanup } + + this.initialized = false; } delay(ms) { diff --git a/README.md b/README.md index 052d640..8e9882f 100644 --- a/README.md +++ b/README.md @@ -166,12 +166,17 @@ Creates a display instance for the specified model. - `rstPin` (number): Reset GPIO pin (default: 17) - `dcPin` (number): Data/Command GPIO pin (default: 25) - `busyPin` (number): Busy GPIO pin (default: 24) -- `csPin` (number): Chip Select GPIO pin (default: 22) - `pwrPin` (number): Power control GPIO pin (default: 18) +- `busyTimeoutMs` (number): How long to wait for the BUSY pin before throwing (default: 10000) +- `maxImagePixels` (number): Reject PNGs larger than this many pixels (default: ~16.7M) - `gpioChip` (string): GPIO chip name (default: 'gpiochip0') - `busNumber` (number): SPI bus number (default: 0) - `deviceNumber` (number): SPI device number (default: 0) - `maxSpeedHz` (number): SPI max speed (default: 4000000) + +Note: there is no `csPin` option — chip select is driven by the SPI +controller's CE line (wire the panel's CS to CE0, or CE1 with +`deviceNumber: 1`), not by a GPIO. - `accentColor` (string): For 3-color displays, specify 'red' or 'yellow' accent color - `vcom` (number): VCOM voltage for IT8951 displays (default: -2.30) - `gpio` (object): Custom GPIO backend (see Hardware Backends below) @@ -213,7 +218,7 @@ Returns array of supported models with their specifications. - `await epd.clear()` - Clear display to background color - `await epd.display()` - Update the display with current buffer - `await epd.sleep()` - Put display into low power mode -- `await epd.cleanup()` - Clean up resources +- `await epd.cleanup()` - Put the panel into deep sleep, power it down and release SPI/GPIO resources #### Power Control - `await epd.powerOn()` - Turn on display power (automatically called during init) diff --git a/displays/EPD7in3f.js b/displays/EPD7in3f.js index 264acc5..bb7f92d 100644 --- a/displays/EPD7in3f.js +++ b/displays/EPD7in3f.js @@ -146,6 +146,12 @@ class EPD7in3f extends EPDBase { this.drawLine(x0, y0, x1, y1, color); } + async sleep() { + // Deep sleep (per EPD_7IN3F_Sleep in the Waveshare C reference) + await this.sendCommand(0x07); + await this.sendData(0xA5); + } + // Factory method static create(options = {}) { return new EPD7in3f(options); diff --git a/displays/EPD7in5.js b/displays/EPD7in5.js index 145d808..8986ccc 100644 --- a/displays/EPD7in5.js +++ b/displays/EPD7in5.js @@ -73,6 +73,15 @@ class EPD7in5 extends EPDBase { await this.sendCommand(0x12); await this.waitUntilIdle(); } + + async sleep() { + // Power off, then deep sleep + // (per EPD_7IN5_Sleep in the Waveshare C reference) + await this.sendCommand(0x02); + await this.waitUntilIdle(); + await this.sendCommand(0x07); + await this.sendData(0xA5); + } } module.exports = { diff --git a/test/run-tests.js b/test/run-tests.js index 35d6d01..83c090e 100644 --- a/test/run-tests.js +++ b/test/run-tests.js @@ -2,6 +2,10 @@ // Run with: npm test const assert = require('assert'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); +const { PNG } = require('pngjs'); const { createDisplay } = require('..'); // --- Mock HAL ------------------------------------------------------------- @@ -190,6 +194,104 @@ test('cleanup closes SPI and releases GPIO', async () => { assert.ok(gpio.released); }); +// --- Robustness ----------------------------------------------------------- + +test('waitUntilIdle throws on busy timeout', async () => { + const { gpio, spi } = createMockHal(); + gpio.read = async () => 1; // stuck busy (SSD-family polarity) + const epd = createDisplay('13in3k', 'mono', { gpio, spi, busyTimeoutMs: 250 }); + + await assert.rejects(() => epd.waitUntilIdle(), /busy timeout after 250ms/); +}); + +test('display() rejects concurrent calls', async () => { + const { gpio, spi } = createMockHal(); + const epd = createDisplay('2in13', 'mono', { gpio, spi }); + epd.initialized = true; + + let finish; + epd.displayImage = () => new Promise(resolve => { finish = resolve; }); + + const first = epd.display(); + await assert.rejects(() => epd.display(), /already in progress/); + + finish(); + await first; + + // A completed refresh unlocks the next one + epd.displayImage = async () => {}; + await epd.display(); +}); + +test('cleanup puts an initialized panel to sleep before power-off', async () => { + const { gpio, spi, log } = createMockHal(); + const epd = createDisplay('13in3k', 'mono', { gpio, spi }); + epd.initialized = true; + + await epd.cleanup(); + + const sleep = parsePackets(log).find(p => p.command === 0x10); + assert.ok(sleep, 'expected a 0x10 (deep sleep) command'); + assert.deepStrictEqual(Array.from(sleep.data), [0x01]); + assert.strictEqual(epd.initialized, false); +}); + +test('cleanup on an uninitialized panel sends no SPI traffic', async () => { + const { gpio, spi, log } = createMockHal(); + const epd = createDisplay('13in3k', 'mono', { gpio, spi }); + + await epd.cleanup(); + assert.strictEqual(log.filter(e => e.type === 'spi').length, 0); +}); + +test('7in5 and 7in3f use UC-family deep sleep sequences', async () => { + { + const { gpio, spi, log } = createMockHal(); + gpio.read = async () => 1; // idle for active-low BUSY + const epd = createDisplay('7in5', 'mono', { gpio, spi }); + await epd.sleep(); + const packets = parsePackets(log); + assert.deepStrictEqual(packets.map(p => p.command), [0x02, 0x07]); + assert.deepStrictEqual(Array.from(packets[1].data), [0xA5]); + } + { + const { gpio, spi, log } = createMockHal(); + const epd = createDisplay('7in3f', '7color', { gpio, spi }); + await epd.sleep(); + const packets = parsePackets(log); + assert.deepStrictEqual(packets.map(p => p.command), [0x07]); + assert.deepStrictEqual(Array.from(packets[0].data), [0xA5]); + } +}); + +test('csPin option is ignored and CS is absent from the pin map', () => { + const { gpio, spi } = createMockHal(); + const epd = createDisplay('2in13', 'mono', { gpio, spi, csPin: 22 }); + assert.strictEqual(epd.pins.CS, undefined); +}); + +test('loadPNG enforces the pixel cap and converts pixels', async () => { + // 2x2 PNG: opaque black at (0,0), opaque white elsewhere + const png = new PNG({ width: 2, height: 2 }); + png.data.fill(255); + png.data[0] = png.data[1] = png.data[2] = 0; + const file = path.join(os.tmpdir(), `waveshare-epaper-test-${process.pid}.png`); + fs.writeFileSync(file, PNG.sync.write(png)); + + try { + const { gpio, spi } = createMockHal(); + const epd = createDisplay('2in13', 'mono', { gpio, spi }); + const image = await epd.loadPNG(file); + assert.strictEqual(image.pixels[0], 0); // black + assert.strictEqual(image.pixels[1], 1); // white + + const capped = createDisplay('2in13', 'mono', { gpio, spi, maxImagePixels: 3 }); + await assert.rejects(() => capped.loadPNG(file), /PNG too large/); + } finally { + fs.unlinkSync(file); + } +}); + // --- Runner --------------------------------------------------------------- (async () => {