Skip to content
Merged
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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ node_modules/
*~
.*~
tmp/
*.tgz
60 changes: 48 additions & 12 deletions EPDBase.js
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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;
Expand Down Expand Up @@ -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`);
}
}
}
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -568,6 +602,8 @@ class EPDBase {
} catch (error) {
// Ignore errors during cleanup
}

this.initialized = false;
}

delay(ms) {
Expand Down
9 changes: 7 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
6 changes: 6 additions & 0 deletions displays/EPD7in3f.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions displays/EPD7in5.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
102 changes: 102 additions & 0 deletions test/run-tests.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 -------------------------------------------------------------
Expand Down Expand Up @@ -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 () => {
Expand Down
Loading