diff --git a/EPDBase.js b/EPDBase.js index fdb11be..234bf9f 100644 --- a/EPDBase.js +++ b/EPDBase.js @@ -1,6 +1,6 @@ const fs = require('fs'); const { PNG } = require('pngjs'); -const { CliGpio, SpiDeviceBackend, validatePin } = require('./hal'); +const { createDefaultGpio, SpiDeviceBackend, validatePin } = require('./hal'); class EPDBase { constructor(options = {}) { @@ -40,7 +40,7 @@ class EPDBase { // Hardware backends - injectable for testing or alternate platforms // (see hal.js for the gpio/spi interfaces) - this.gpio = options.gpio || new CliGpio(this.gpioChip); + this.gpio = options.gpio || createDefaultGpio(this.gpioChip); this.spi = options.spi || new SpiDeviceBackend(this.busNumber, this.deviceNumber, this.spiOptions); this.initialized = false; diff --git a/README.md b/README.md index 8e9882f..816a171 100644 --- a/README.md +++ b/README.md @@ -38,6 +38,23 @@ The `canvas` module is an optional extra — install it separately (`npm install canvas`) if you want to render with `drawCanvas()`; everything else (including PNG loading) works without it. +### Faster GPIO (Recommended) + +By default GPIO pins are toggled by spawning libgpiod's `gpioset`/`gpioget` +CLI tools — a process per toggle. Installing the optional native binding +makes pin toggles native calls instead, which speeds up refreshes +substantially: + +```bash +sudo apt install libgpiod-dev +npm install node-libgpiod +``` + +No code changes needed — the library detects `node-libgpiod` and uses it +automatically, falling back to the CLI tools when it isn't available. You can +check which backend is active with `epd.gpio.constructor.name` +(`LibgpiodGpio` vs `CliGpio`). + ### GPIO Permissions (Recommended) For security, add your user to the `gpio` group instead of running as root: @@ -203,10 +220,12 @@ const epd = createDisplay('13in3k', 'mono', { }); ``` -The default backends shell out to libgpiod's `gpioset`/`gpioget` (with input -validation, no shell interpolation) and use the `spi-device` module. The -`spi-device` module is only loaded when the default SPI backend is used, so -the library also runs on machines without SPI support (e.g. in CI). +The default GPIO backend is `LibgpiodGpio` (native `node-libgpiod` binding, +lines requested once and held) when that module is installed, otherwise +`CliGpio` (shells out to libgpiod's `gpioset`/`gpioget` via `execFile` with +input validation — no shell interpolation). The default SPI backend uses the +`spi-device` module, loaded only when actually used, so the library also runs +on machines without SPI support (e.g. in CI). #### `getSupportedModels()` Returns array of supported models with their specifications. diff --git a/hal.js b/hal.js index c63ba64..7d7a124 100644 --- a/hal.js +++ b/hal.js @@ -27,6 +27,64 @@ function validateGpioChip(chip) { return chip; } +// GPIO backend over the node-libgpiod native binding. Requests each line +// once and holds it, so a pin toggle is a native call instead of a process +// spawn - orders of magnitude faster than CliGpio. Used automatically when +// node-libgpiod is installed (npm install node-libgpiod). +class LibgpiodGpio { + constructor(chip = 'gpiochip0', binding = null) { + this.chipName = validateGpioChip(chip); + this.binding = binding || require('node-libgpiod'); + this.chip = new this.binding.Chip(this.chipName); + this.lines = new Map(); // pin -> { line, direction } + } + + requestLine(pin, direction) { + const p = validatePin('pin', pin); + let entry = this.lines.get(p); + + // Re-request if the pin changes direction (shouldn't happen in + // practice - BUSY is the only input and is never written) + if (entry && entry.direction !== direction) { + entry.line.release(); + this.lines.delete(p); + entry = null; + } + + if (!entry) { + const line = this.chip.getLine(p); + if (direction === 'out') { + line.requestOutputMode('waveshare-epaper'); + } else { + line.requestInputMode('waveshare-epaper'); + } + entry = { line, direction }; + this.lines.set(p, entry); + } + + return entry.line; + } + + async write(pin, value) { + this.requestLine(pin, 'out').setValue(value ? 1 : 0); + } + + async read(pin) { + return this.requestLine(pin, 'in').getValue(); + } + + async release() { + for (const { line } of this.lines.values()) { + try { + line.release(); + } catch (error) { + // Ignore errors during release + } + } + this.lines.clear(); + } +} + // GPIO backend that drives libgpiod's gpioset/gpioget CLI tools. Inputs are // validated and passed as discrete execFile arguments so nothing ever goes // through a shell. @@ -101,9 +159,21 @@ class SpiDeviceBackend { } } +// Pick the best available GPIO backend: the node-libgpiod native binding if +// it is installed and the chip can be opened, otherwise the CLI tools. +function createDefaultGpio(chip) { + try { + return new LibgpiodGpio(chip); + } catch (error) { + return new CliGpio(chip); + } +} + module.exports = { CliGpio, + LibgpiodGpio, SpiDeviceBackend, + createDefaultGpio, validatePin, validateGpioChip }; diff --git a/package.json b/package.json index 096d186..32eb1ec 100644 --- a/package.json +++ b/package.json @@ -22,6 +22,14 @@ "devDependencies": { "canvas": "^3.2.0" }, + "peerDependencies": { + "node-libgpiod": ">=0.5.0" + }, + "peerDependenciesMeta": { + "node-libgpiod": { + "optional": true + } + }, "keywords": [ "waveshare", "e-paper", diff --git a/test/run-tests.js b/test/run-tests.js index 83c090e..4df3a35 100644 --- a/test/run-tests.js +++ b/test/run-tests.js @@ -146,6 +146,80 @@ test('waitUntilIdle: UC8176-class (7in5, 7in3f) waits while BUSY reads 0', async assert.strictEqual(await countBusyPolls('7in3f', [0, 1]), 2); }); +// --- Native GPIO backend -------------------------------------------------- + +// Fake node-libgpiod binding matching the Chip/Line API of v0.6 +function createFakeLibgpiod() { + const state = { values: new Map(), requested: [], released: [] }; + + class FakeLine { + constructor(pin) { this.pin = pin; } + requestOutputMode() { state.requested.push({ pin: this.pin, direction: 'out' }); } + requestInputMode() { state.requested.push({ pin: this.pin, direction: 'in' }); } + setValue(v) { state.values.set(this.pin, v); } + getValue() { return state.values.get(this.pin) ?? 0; } + release() { state.released.push(this.pin); } + } + + class FakeChip { + constructor(identifier) { state.chipIdentifier = identifier; } + getLine(pin) { return new FakeLine(pin); } + } + + return { binding: { Chip: FakeChip, Line: FakeLine }, state }; +} + +test('LibgpiodGpio caches lines and round-trips values', async () => { + const { LibgpiodGpio } = require('../hal'); + const { binding, state } = createFakeLibgpiod(); + const gpio = new LibgpiodGpio('gpiochip0', binding); + + assert.strictEqual(state.chipIdentifier, 'gpiochip0'); + + await gpio.write(17, 1); + await gpio.write(17, 0); + await gpio.write(17, 1); + // Three writes, but the line is requested only once + assert.deepStrictEqual(state.requested, [{ pin: 17, direction: 'out' }]); + assert.strictEqual(state.values.get(17), 1); + + state.values.set(24, 1); + assert.strictEqual(await gpio.read(24), 1); + assert.deepStrictEqual(state.requested[1], { pin: 24, direction: 'in' }); +}); + +test('LibgpiodGpio re-requests on direction change and releases all lines', async () => { + const { LibgpiodGpio } = require('../hal'); + const { binding, state } = createFakeLibgpiod(); + const gpio = new LibgpiodGpio('gpiochip0', binding); + + await gpio.write(5, 1); + await gpio.read(5); // direction change: old line released, new one requested + assert.deepStrictEqual(state.released, [5]); + assert.deepStrictEqual(state.requested.map(r => r.direction), ['out', 'in']); + + await gpio.write(6, 0); + await gpio.release(); + assert.ok(state.released.includes(6)); + assert.strictEqual(gpio.lines.size, 0); +}); + +test('LibgpiodGpio validates chip name and pins like CliGpio', () => { + const { LibgpiodGpio } = require('../hal'); + const { binding } = createFakeLibgpiod(); + + assert.throws(() => new LibgpiodGpio('gpiochip0; rm -rf /', binding), /Invalid GPIO chip name/); + const gpio = new LibgpiodGpio('gpiochip0', binding); + assert.rejects(() => gpio.write('7; reboot', 1), /Invalid GPIO pin/); +}); + +test('createDefaultGpio falls back to CliGpio when node-libgpiod is unavailable', () => { + const { createDefaultGpio, CliGpio } = require('../hal'); + // node-libgpiod is not installed in this environment + const gpio = createDefaultGpio('gpiochip0'); + assert.ok(gpio instanceof CliGpio); +}); + // --- Full display cycle --------------------------------------------------- test('13in3k mono: init + display sends the full framebuffer', async () => {