From 3aa29789025d40b70c3695e6cca61ff261fb0935 Mon Sep 17 00:00:00 2001 From: Anders Brownworth Date: Sun, 2 Aug 2026 08:11:12 -0400 Subject: [PATCH] extract pixel format strategies and data-driven driver registry - add formats.js: one strategy class per color mode (mono, 4gray, 3color, 7color) owning bit packing, clear values, transparency background and RGB quantization - EPDBase delegates setPixel/clear/rgbToColor/transparency to the active format, replacing ~180 lines of per-mode if/else chains; color buffer allocation driven by the format's usesColorBuffer flag - fold EPD7in3f's setPixel override (clamping) into SevenColorFormat - each driver exports meta + create; displays/index.js builds the factory lookup and getSupportedModels() from that registry, replacing the hand-maintained switch (getSupportedModels entries now include aliases) - pin format bit-packing and registry behavior with 6 new tests (29 total) - bump version to 0.4.0 Behavior is unchanged: all pixel/RGB logic was moved verbatim and the byte-stream tests pass untouched. --- EPDBase.js | 180 +++++------------------------------ README.md | 21 +++- displays/EPD13in3Gray.js | 11 ++- displays/EPD13in3b.js | 5 +- displays/EPD13in3k.js | 4 +- displays/EPD2in13.js | 4 +- displays/EPD2in7.js | 4 +- displays/EPD2in7b.js | 5 +- displays/EPD7in3f.js | 26 +---- displays/EPD7in5.js | 4 +- displays/index.js | 87 ++++++----------- formats.js | 200 +++++++++++++++++++++++++++++++++++++++ package-lock.json | 12 ++- package.json | 3 +- test/run-tests.js | 86 +++++++++++++++++ 15 files changed, 401 insertions(+), 251 deletions(-) create mode 100644 formats.js diff --git a/EPDBase.js b/EPDBase.js index 234bf9f..46ab42e 100644 --- a/EPDBase.js +++ b/EPDBase.js @@ -1,6 +1,7 @@ const fs = require('fs'); const { PNG } = require('pngjs'); const { createDefaultGpio, SpiDeviceBackend, validatePin } = require('./hal'); +const { createFormat } = require('./formats'); class EPDBase { constructor(options = {}) { @@ -68,12 +69,24 @@ class EPDBase { }; } + // Pixel format strategy for the current color mode (see formats.js). + // Created lazily because subclasses set colorMode after super() runs. + // Null for modes without a format (the experimental 16gray driver + // overrides the pixel operations itself). + get format() { + if (this._formatMode !== this.colorMode) { + this._format = createFormat(this.colorMode, this.colors); + this._formatMode = this.colorMode; + } + return this._format; + } + initializeBuffer() { const totalBits = this.width * this.height * this.bitsPerPixel; this.imageBuffer = Buffer.alloc(Math.ceil(totalBits / 8)); // Initialize color buffer for 3-color displays - if (this.colorMode === '3color') { + if (this.format && this.format.usesColorBuffer) { this.colorBuffer = Buffer.alloc(Math.ceil(this.width * this.height / 8)); } } @@ -200,17 +213,8 @@ class EPDBase { } async clear() { - if (this.colorMode === 'mono') { - this.imageBuffer.fill(0xFF); - } else if (this.colorMode === '4gray') { - // For 4-gray mode, white = 0xC0 -> top 2 bits = 11 - // 4 white pixels per byte = 11 11 11 11 = 0xFF - this.imageBuffer.fill(0xFF); - } else if (this.colorMode === '3color') { - this.imageBuffer.fill(0xFF); // White background - if (this.colorBuffer) this.colorBuffer.fill(0x00); // No color - } else if (this.colorMode === '7color') { - this.imageBuffer.fill(0x11); // White pixels (all pixels set to WHITE = 1) + if (this.format) { + this.format.clear({ image: this.imageBuffer, color: this.colorBuffer }); } await this.display(); } @@ -247,76 +251,8 @@ class EPDBase { return; } - if (this.colorMode === 'mono') { - const byteIndex = Math.floor((x + y * this.width) / 8); - const bitIndex = 7 - ((x + y * this.width) % 8); - - if (color === 0) { - // Black - this.imageBuffer[byteIndex] &= ~(1 << bitIndex); - } else { - // White - this.imageBuffer[byteIndex] |= (1 << bitIndex); - } - } else if (this.colorMode === '4gray') { - // For 4-gray mode, store pixels in format expected by C encoding algorithm - const pixelIndex = x + y * this.width; - const byteIndex = Math.floor(pixelIndex / 4); - const pixelPos = pixelIndex % 4; - const bitShift = 6 - (pixelPos * 2); // Top 2 bits first: 6,4,2,0 - - // Based on your actual test results (mapping appears inverted): - let rawValue; - switch (color) { - case 0: rawValue = 0x00; break; // Black -> 00 (darkest available) - case 1: rawValue = 0x01; break; // Dark gray -> 01 - case 2: rawValue = 0x02; break; // Light gray -> 10 - case 3: rawValue = 0x03; break; // White -> 11 - default: rawValue = 0x03; break; // Default to white - } - - // Clear the 2 bits for this pixel and set new value - const mask = 0x03 << bitShift; // Create mask for 2 bits at correct position - this.imageBuffer[byteIndex] &= ~mask; // Clear the bits - this.imageBuffer[byteIndex] |= (rawValue << bitShift); // Set the bits - } else if (this.colorMode === '3color') { - // For 3-color displays, color parameter can be: - // 0 = black, 1 = white, 2 = red/yellow (accent color) - const byteIndex = Math.floor((x + y * this.width) / 8); - const bitIndex = 7 - ((x + y * this.width) % 8); - - if (color === 2) { - // Accent color (red/yellow) - set in color buffer, clear in main buffer - this.imageBuffer[byteIndex] |= (1 << bitIndex); // White in main buffer - if (this.colorBuffer) { - this.colorBuffer[byteIndex] |= (1 << bitIndex); // Set color bit - } - } else if (color === 0) { - // Black - clear in both buffers - this.imageBuffer[byteIndex] &= ~(1 << bitIndex); - if (this.colorBuffer) { - this.colorBuffer[byteIndex] &= ~(1 << bitIndex); - } - } else { - // White - set in main buffer, clear in color buffer - this.imageBuffer[byteIndex] |= (1 << bitIndex); - if (this.colorBuffer) { - this.colorBuffer[byteIndex] &= ~(1 << bitIndex); - } - } - } else if (this.colorMode === '7color') { - // For 7-color displays, pack 2 pixels per byte (4 bits each, but only 3 bits used) - const pixelIndex = x + y * this.width; - const byteIndex = Math.floor(pixelIndex / 2); - const pixelPos = pixelIndex % 2; - - if (pixelPos === 0) { - // First pixel (upper 4 bits) - this.imageBuffer[byteIndex] = (this.imageBuffer[byteIndex] & 0x0F) | ((color & 0x07) << 4); - } else { - // Second pixel (lower 4 bits) - this.imageBuffer[byteIndex] = (this.imageBuffer[byteIndex] & 0xF0) | (color & 0x07); - } + if (this.format) { + this.format.setPixel({ image: this.imageBuffer, color: this.colorBuffer }, this.width, x, y, color); } } @@ -382,57 +318,7 @@ class EPDBase { // Convert RGB color to display format based on color mode rgbToColor(r, g, b) { - if (this.colorMode === 'mono') { - const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); - return gray < 128 ? 0 : 1; // Black or white - } else if (this.colorMode === '4gray') { - const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); - // Map to 4 levels: 0=black, 1=dark gray, 2=light gray, 3=white - if (gray < 64) return 0; - else if (gray < 128) return 1; - else if (gray < 192) return 2; - else return 3; - } else if (this.colorMode === '3color') { - // Simple color detection for 3-color displays - const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); - - // Check if it's predominantly red or yellow - if (r > g + 50 && r > b + 50 && r > 150) return 2; // Red-ish - if (r > 150 && g > 150 && b < 100) return 2; // Yellow-ish - - // Otherwise black or white based on brightness - return gray < 128 ? 0 : 1; - } else if (this.colorMode === '7color') { - // Advanced color detection for 7-color displays - const maxComponent = Math.max(r, g, b); - const minComponent = Math.min(r, g, b); - - // Very dark colors - if (maxComponent < 50) return this.colors.BLACK; - - // Very bright colors - if (minComponent > 200) return this.colors.WHITE; - - // Color detection based on dominant component - if (r > g + 30 && r > b + 30) { - // Red-dominant - if (g > 150) return this.colors.ORANGE; // Red + Green = Orange - return this.colors.RED; - } else if (g > r + 30 && g > b + 30) { - // Green-dominant - if (r > 150) return this.colors.YELLOW; // Red + Green = Yellow - return this.colors.GREEN; - } else if (b > r + 30 && b > g + 30) { - // Blue-dominant - return this.colors.BLUE; - } - - // Fallback to grayscale - const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); - return gray < 128 ? this.colors.BLACK : this.colors.WHITE; - } - - return 0; + return this.format ? this.format.rgbToColor(r, g, b) : 0; } // Keep backward compatibility @@ -468,17 +354,9 @@ class EPDBase { const a = this.data[idx + 3]; // Handle transparency - treat transparent as white/background - let pixelValue; - if (a < 128) { - // Transparent pixels become background color - if (self.colorMode === 'mono') pixelValue = 1; // White - else if (self.colorMode === '4gray') pixelValue = 3; // White - else if (self.colorMode === '3color') pixelValue = 1; // White - else if (self.colorMode === '7color') pixelValue = self.colors.WHITE; - else pixelValue = 1; - } else { - pixelValue = self.rgbToColor(r, g, b); - } + const pixelValue = (a < 128) + ? (self.format ? self.format.background : 1) + : self.rgbToColor(r, g, b); imageData.pixels[y * this.width + x] = pixelValue; } @@ -538,17 +416,9 @@ class EPDBase { const a = imageData.data[dataIndex + 3]; // Handle transparency - treat transparent as white/background - let pixelValue; - if (a < 128) { - // Transparent pixels become background color - if (this.colorMode === 'mono') pixelValue = 1; // White - else if (this.colorMode === '4gray') pixelValue = 3; // White - else if (this.colorMode === '3color') pixelValue = 1; // White - else if (this.colorMode === '7color') pixelValue = this.colors.WHITE; - else pixelValue = 1; - } else { - pixelValue = this.rgbToColor(r, g, b); - } + const pixelValue = (a < 128) + ? (this.format ? this.format.background : 1) + : this.rgbToColor(r, g, b); this.setPixel(screenX, screenY, pixelValue); } diff --git a/README.md b/README.md index 3621843..b4d12fb 100644 --- a/README.md +++ b/README.md @@ -293,10 +293,11 @@ Available colors: `BLACK`, `WHITE`, `RED`, `GREEN`, `BLUE`, `YELLOW`, `ORANGE` ### File Structure ``` ├── index.js # Main entry point and factory functions -├── EPDBase.js # Base class with common functionality +├── EPDBase.js # Base class: SPI/GPIO protocol, drawing, image import ├── hal.js # Hardware abstraction layer (GPIO/SPI backends) +├── formats.js # Pixel format strategies (one class per color mode) ├── displays/ -│ ├── index.js # Display module exports +│ ├── index.js # Driver registry built from each driver's meta │ ├── EPD2in13.js # 2.13" monochrome display driver │ ├── EPD2in7.js # 2.7" mono/4-grayscale display driver │ ├── EPD2in7b.js # 2.7" 3-color display driver @@ -338,8 +339,20 @@ To add support for a new display model: } ``` -3. Add to `displays/index.js` exports -4. Add case to `createDisplay()` function +3. Export `meta` and `create` from the module so the registry picks it up — + the factory lookup and `getSupportedModels()` are both derived from this: + ```javascript + module.exports = { + EPDNewModel, + meta: { model: 'newmodel', aliases: ['n.m'], size: '200x200', colorModes: ['mono'], description: 'New 200x200 panel' }, + create: (colorMode, options) => new EPDNewModel(options) + }; + ``` +4. Add the module to the `modules` list in `displays/index.js` + +Pixel packing, clear values and RGB quantization come from the color mode's +format class in `formats.js` — a new panel with an existing color mode needs +no pixel code at all. A new color mode means adding one format class there. ## Hardware Requirements diff --git a/displays/EPD13in3Gray.js b/displays/EPD13in3Gray.js index 97a6864..48b1fec 100644 --- a/displays/EPD13in3Gray.js +++ b/displays/EPD13in3Gray.js @@ -336,5 +336,14 @@ class EPD13in3Gray extends EPDBase { module.exports = { EPD13in3Gray, - create16Gray: (options) => EPD13in3Gray.create16Gray(options) + create16Gray: (options) => EPD13in3Gray.create16Gray(options), + meta: { + model: '13in3gray', + aliases: ['13.3gray', '13in3_16gray', '13.3_16gray'], + size: '1600x1200', + colorModes: ['16gray'], + description: '13.3" 16-level grayscale (IT8951) - EXPERIMENTAL, driver not yet functional', + experimental: true + }, + create: (colorMode, options) => EPD13in3Gray.create16Gray(options) }; \ No newline at end of file diff --git a/displays/EPD13in3b.js b/displays/EPD13in3b.js index 6d0a970..d97d6a4 100644 --- a/displays/EPD13in3b.js +++ b/displays/EPD13in3b.js @@ -148,5 +148,8 @@ module.exports = { EPD13in3b, create3Color: (accentColor = 'red', options) => EPD13in3b.create(accentColor, options), createRed: (options) => EPD13in3b.create('red', options), - createYellow: (options) => EPD13in3b.create('yellow', options) + createYellow: (options) => EPD13in3b.create('yellow', options), + meta: { model: '13in3b', aliases: ['13.3b'], size: '960x680', colorModes: ['3color'], description: '13.3" black/white/red' }, + // The colorMode argument doubles as the accent color for 3-color panels + create: (colorMode, options) => EPD13in3b.create(colorMode || 'red', options) }; \ No newline at end of file diff --git a/displays/EPD13in3k.js b/displays/EPD13in3k.js index 4e5d5aa..29a292c 100644 --- a/displays/EPD13in3k.js +++ b/displays/EPD13in3k.js @@ -216,5 +216,7 @@ class EPD13in3k extends EPDBase { module.exports = { EPD13in3k, createMono: (options) => EPD13in3k.create('mono', options), - create4Gray: (options) => EPD13in3k.create('4gray', options) + create4Gray: (options) => EPD13in3k.create('4gray', options), + meta: { model: '13in3k', aliases: ['13.3k'], size: '960x680', colorModes: ['mono', '4gray'], description: '13.3" mono/4-grayscale' }, + create: (colorMode, options) => EPD13in3k.create(colorMode === '4gray' ? '4gray' : 'mono', options) }; \ No newline at end of file diff --git a/displays/EPD2in13.js b/displays/EPD2in13.js index 8737b0f..28dd62b 100644 --- a/displays/EPD2in13.js +++ b/displays/EPD2in13.js @@ -81,5 +81,7 @@ class EPD2in13 extends EPDBase { module.exports = { EPD2in13, - createMono: (options) => new EPD2in13(options) + createMono: (options) => new EPD2in13(options), + meta: { model: '2in13', aliases: ['2.13'], size: '122x250', colorModes: ['mono'], description: '2.13" monochrome' }, + create: (colorMode, options) => new EPD2in13(options) }; \ No newline at end of file diff --git a/displays/EPD2in7.js b/displays/EPD2in7.js index bfa1ff9..08d68fd 100644 --- a/displays/EPD2in7.js +++ b/displays/EPD2in7.js @@ -215,5 +215,7 @@ class EPD2in7 extends EPDBase { module.exports = { EPD2in7, createMono: (options) => EPD2in7.create('mono', options), - create4Gray: (options) => EPD2in7.create('4gray', options) + create4Gray: (options) => EPD2in7.create('4gray', options), + meta: { model: '2in7', aliases: ['2.7'], size: '176x264', colorModes: ['mono', '4gray'], description: '2.7" mono/4-grayscale' }, + create: (colorMode, options) => EPD2in7.create(colorMode === '4gray' ? '4gray' : 'mono', options) }; \ No newline at end of file diff --git a/displays/EPD2in7b.js b/displays/EPD2in7b.js index 41383b7..0b50576 100644 --- a/displays/EPD2in7b.js +++ b/displays/EPD2in7b.js @@ -127,5 +127,8 @@ class EPD2in7b extends EPDBase { module.exports = { EPD2in7b, - create3Color: (accentColor = 'red', options) => EPD2in7b.create(accentColor, options) + create3Color: (accentColor = 'red', options) => EPD2in7b.create(accentColor, options), + meta: { model: '2in7b', aliases: ['2.7b'], size: '176x264', colorModes: ['3color'], description: '2.7" black/white/red' }, + // The colorMode argument doubles as the accent color for 3-color panels + create: (colorMode, options) => EPD2in7b.create(colorMode || 'red', options) }; \ No newline at end of file diff --git a/displays/EPD7in3f.js b/displays/EPD7in3f.js index bb7f92d..bc9f18e 100644 --- a/displays/EPD7in3f.js +++ b/displays/EPD7in3f.js @@ -113,28 +113,6 @@ class EPD7in3f extends EPDBase { await this.display(); } - // Override setPixel for optimized 7-color handling - setPixel(x, y, color) { - if (x >= this.width || y >= this.height || x < 0 || y < 0) { - return; - } - - const pixelIndex = x + y * this.width; - const byteIndex = Math.floor(pixelIndex / 2); - const pixelPos = pixelIndex % 2; - - // Ensure color is in valid range - const validColor = Math.max(0, Math.min(7, color)); - - if (pixelPos === 0) { - // First pixel (upper 4 bits) - this.imageBuffer[byteIndex] = (this.imageBuffer[byteIndex] & 0x0F) | ((validColor & 0x0F) << 4); - } else { - // Second pixel (lower 4 bits) - this.imageBuffer[byteIndex] = (this.imageBuffer[byteIndex] & 0xF0) | (validColor & 0x0F); - } - } - // Convenience methods for color drawing drawColorRect(x, y, width, height, colorName, filled = false) { const color = this.colors[colorName] !== undefined ? this.colors[colorName] : this.colors.WHITE; @@ -160,5 +138,7 @@ class EPD7in3f extends EPDBase { module.exports = { EPD7in3f, - create7Color: (options) => EPD7in3f.create(options) + create7Color: (options) => EPD7in3f.create(options), + meta: { model: '7in3f', aliases: ['7.3f'], size: '800x480', colorModes: ['7color'], description: '7.3" full color (7 colors)' }, + create: (colorMode, options) => EPD7in3f.create(options) }; \ No newline at end of file diff --git a/displays/EPD7in5.js b/displays/EPD7in5.js index 8986ccc..da0455c 100644 --- a/displays/EPD7in5.js +++ b/displays/EPD7in5.js @@ -86,5 +86,7 @@ class EPD7in5 extends EPDBase { module.exports = { EPD7in5, - createMono: (options) => new EPD7in5(options) + createMono: (options) => new EPD7in5(options), + meta: { model: '7in5', aliases: ['7.5'], size: '640x384', colorModes: ['mono'], description: '7.5" monochrome' }, + create: (colorMode, options) => new EPD7in5(options) }; \ No newline at end of file diff --git a/displays/index.js b/displays/index.js index 49095a5..b85593b 100644 --- a/displays/index.js +++ b/displays/index.js @@ -7,6 +7,26 @@ const EPD13in3k = require('./EPD13in3k'); const EPD13in3b = require('./EPD13in3b'); const EPD13in3Gray = require('./EPD13in3Gray'); +// Each display module exports { meta, create } - the factory lookup and the +// supported-models list are both derived from that, so they cannot drift. +const modules = [ + EPD2in13, + EPD2in7, + EPD2in7b, + EPD7in5, + EPD7in3f, + EPD13in3k, + EPD13in3b, + EPD13in3Gray +]; + +const registry = new Map(); +for (const mod of modules) { + for (const name of [mod.meta.model, ...(mod.meta.aliases || [])]) { + registry.set(name.toLowerCase(), mod); + } +} + module.exports = { EPD2in13, EPD2in7, @@ -19,67 +39,16 @@ module.exports = { // Convenience function to create display by model name createDisplay: (model, colorMode, options = {}) => { - const modelName = model.toLowerCase(); + const mod = registry.get(String(model).toLowerCase()); - switch (modelName) { - case '2in13': - case '2.13': - return EPD2in13.createMono(options); - - case '2in7': - case '2.7': - if (colorMode === '4gray') { - return EPD2in7.create4Gray(options); - } else { - return EPD2in7.createMono(options); - } - - case '2in7b': - case '2.7b': - return EPD2in7b.create3Color(colorMode || 'red', options); - - case '7in5': - case '7.5': - return EPD7in5.createMono(options); - - case '7in3f': - case '7.3f': - return EPD7in3f.create7Color(options); - - case '13in3k': - case '13.3k': - if (colorMode === '4gray') { - return EPD13in3k.create4Gray(options); - } else { - return EPD13in3k.createMono(options); - } - - case '13in3b': - case '13.3b': - return EPD13in3b.create3Color(colorMode || 'red', options); - - case '13in3gray': - case '13.3gray': - case '13in3_16gray': - case '13.3_16gray': - return EPD13in3Gray.create16Gray(options); - - default: - throw new Error(`Unsupported display model: ${model}. Supported models: 2in13, 2in7, 2in7b, 7in5, 7in3f, 13in3k, 13in3b, 13in3gray`); + if (!mod) { + const supported = modules.map(m => m.meta.model).join(', '); + throw new Error(`Unsupported display model: ${model}. Supported models: ${supported}`); } + + return mod.create(colorMode, options); }, // List all supported models - getSupportedModels: () => { - return [ - { model: '2in13', size: '122x250', colorModes: ['mono'], description: '2.13" monochrome' }, - { model: '2in7', size: '176x264', colorModes: ['mono', '4gray'], description: '2.7" mono/4-grayscale' }, - { model: '2in7b', size: '176x264', colorModes: ['3color'], description: '2.7" black/white/red' }, - { model: '7in5', size: '640x384', colorModes: ['mono'], description: '7.5" monochrome' }, - { model: '7in3f', size: '800x480', colorModes: ['7color'], description: '7.3" full color (7 colors)' }, - { model: '13in3k', size: '960x680', colorModes: ['mono', '4gray'], description: '13.3" mono/4-grayscale' }, - { model: '13in3b', size: '960x680', colorModes: ['3color'], description: '13.3" black/white/red' }, - { model: '13in3gray', size: '1600x1200', colorModes: ['16gray'], description: '13.3" 16-level grayscale (IT8951) - EXPERIMENTAL, driver not yet functional', experimental: true } - ]; - } -}; \ No newline at end of file + getSupportedModels: () => modules.map(mod => ({ ...mod.meta })) +}; diff --git a/formats.js b/formats.js new file mode 100644 index 0000000..016b154 --- /dev/null +++ b/formats.js @@ -0,0 +1,200 @@ +// Pixel format strategies - one class per color mode. +// +// Each format owns everything that depends on how pixels are packed into the +// framebuffer: bit packing, the clear/background values, and RGB +// quantization. EPDBase delegates to the active format, so drawing code and +// drivers stay format-agnostic and adding a color mode means adding one +// class here instead of editing every method in the base class. +// +// A format operates on `buffers` = { image, color } where `color` is the +// second plane used by 3-color panels (null otherwise). + +class MonoFormat { + constructor() { + this.bitsPerPixel = 1; + this.background = 1; // white + } + + clear(buffers) { + buffers.image.fill(0xFF); // white + } + + setPixel(buffers, width, x, y, color) { + const index = x + y * width; + const byteIndex = Math.floor(index / 8); + const bitIndex = 7 - (index % 8); + + if (color === 0) { + buffers.image[byteIndex] &= ~(1 << bitIndex); // black + } else { + buffers.image[byteIndex] |= (1 << bitIndex); // white + } + } + + rgbToColor(r, g, b) { + const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + return gray < 128 ? 0 : 1; // black or white + } +} + +class Gray4Format { + constructor() { + this.bitsPerPixel = 2; + this.background = 3; // white + } + + clear(buffers) { + // White = 11 per pixel, 4 pixels per byte = 0xFF + buffers.image.fill(0xFF); + } + + setPixel(buffers, width, x, y, color) { + // Store pixels in the format expected by the C encoding algorithm: + // 2 bits per pixel, top bits first (shifts 6,4,2,0) + const index = x + y * width; + const byteIndex = Math.floor(index / 4); + const bitShift = 6 - ((index % 4) * 2); + + // 0=black, 1=dark gray, 2=light gray, 3=white + const rawValue = (color >= 0 && color <= 3) ? color : 3; + + const mask = 0x03 << bitShift; + buffers.image[byteIndex] &= ~mask; + buffers.image[byteIndex] |= (rawValue << bitShift); + } + + rgbToColor(r, g, b) { + const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + if (gray < 64) return 0; + else if (gray < 128) return 1; + else if (gray < 192) return 2; + else return 3; + } +} + +class ThreeColorFormat { + constructor() { + this.bitsPerPixel = 1; + this.background = 1; // white + this.usesColorBuffer = true; + } + + clear(buffers) { + buffers.image.fill(0xFF); // white background + if (buffers.color) buffers.color.fill(0x00); // no accent color + } + + setPixel(buffers, width, x, y, color) { + // 0 = black, 1 = white, 2 = red/yellow (accent color) + const index = x + y * width; + const byteIndex = Math.floor(index / 8); + const bitIndex = 7 - (index % 8); + + if (color === 2) { + // Accent color - white in main buffer, set in color buffer + buffers.image[byteIndex] |= (1 << bitIndex); + if (buffers.color) { + buffers.color[byteIndex] |= (1 << bitIndex); + } + } else if (color === 0) { + // Black - clear in both buffers + buffers.image[byteIndex] &= ~(1 << bitIndex); + if (buffers.color) { + buffers.color[byteIndex] &= ~(1 << bitIndex); + } + } else { + // White - set in main buffer, clear in color buffer + buffers.image[byteIndex] |= (1 << bitIndex); + if (buffers.color) { + buffers.color[byteIndex] &= ~(1 << bitIndex); + } + } + } + + rgbToColor(r, g, b) { + const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + + // Check if it's predominantly red or yellow + if (r > g + 50 && r > b + 50 && r > 150) return 2; // Red-ish + if (r > 150 && g > 150 && b < 100) return 2; // Yellow-ish + + return gray < 128 ? 0 : 1; + } +} + +class SevenColorFormat { + constructor(colors) { + this.bitsPerPixel = 4; // 2 pixels per byte, 3 bits used per pixel + this.colors = colors; + this.background = colors.WHITE; + } + + clear(buffers) { + buffers.image.fill(0x11); // each nibble = WHITE + } + + setPixel(buffers, width, x, y, color) { + const index = x + y * width; + const byteIndex = Math.floor(index / 2); + + // Clamp to the valid color range + const validColor = Math.max(0, Math.min(7, color)); + + if (index % 2 === 0) { + // First pixel (upper 4 bits) + buffers.image[byteIndex] = (buffers.image[byteIndex] & 0x0F) | ((validColor & 0x0F) << 4); + } else { + // Second pixel (lower 4 bits) + buffers.image[byteIndex] = (buffers.image[byteIndex] & 0xF0) | (validColor & 0x0F); + } + } + + rgbToColor(r, g, b) { + const maxComponent = Math.max(r, g, b); + const minComponent = Math.min(r, g, b); + + // Very dark colors + if (maxComponent < 50) return this.colors.BLACK; + + // Very bright colors + if (minComponent > 200) return this.colors.WHITE; + + // Color detection based on dominant component + if (r > g + 30 && r > b + 30) { + // Red-dominant + if (g > 150) return this.colors.ORANGE; // Red + Green = Orange + return this.colors.RED; + } else if (g > r + 30 && g > b + 30) { + // Green-dominant + if (r > 150) return this.colors.YELLOW; // Red + Green = Yellow + return this.colors.GREEN; + } else if (b > r + 30 && b > g + 30) { + // Blue-dominant + return this.colors.BLUE; + } + + // Fallback to grayscale + const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b); + return gray < 128 ? this.colors.BLACK : this.colors.WHITE; + } +} + +// Returns null for modes without a format (e.g. the experimental 16gray +// driver, which overrides the pixel operations itself). +function createFormat(colorMode, colors) { + switch (colorMode) { + case 'mono': return new MonoFormat(); + case '4gray': return new Gray4Format(); + case '3color': return new ThreeColorFormat(); + case '7color': return new SevenColorFormat(colors); + default: return null; + } +} + +module.exports = { + MonoFormat, + Gray4Format, + ThreeColorFormat, + SevenColorFormat, + createFormat +}; diff --git a/package-lock.json b/package-lock.json index 1089bc7..174108d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "waveshare-epaper", - "version": "0.3.0", + "version": "0.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "waveshare-epaper", - "version": "0.3.0", + "version": "0.4.0", "license": "MIT", "dependencies": { "pngjs": "^7.0.0", @@ -17,6 +17,14 @@ }, "engines": { "node": ">=14.0.0" + }, + "peerDependencies": { + "node-libgpiod": ">=0.5.0" + }, + "peerDependenciesMeta": { + "node-libgpiod": { + "optional": true + } } }, "node_modules/base64-js": { diff --git a/package.json b/package.json index 32eb1ec..8d74da8 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "version": "0.3.0", + "version": "0.4.0", "name": "waveshare-epaper", "description": "A modular Node.js driver for Waveshare E-Paper displays that supports multiple display models with different resolutions and color modes", "main": "index.js", @@ -53,6 +53,7 @@ "index.js", "EPDBase.js", "hal.js", + "formats.js", "displays/", "examples/", "README.md", diff --git a/test/run-tests.js b/test/run-tests.js index b391306..1071454 100644 --- a/test/run-tests.js +++ b/test/run-tests.js @@ -146,6 +146,92 @@ test('waitUntilIdle: UC8176-class (7in5, 7in3f) waits while BUSY reads 0', async assert.strictEqual(await countBusyPolls('7in3f', [0, 1]), 2); }); +// --- Pixel formats -------------------------------------------------------- + +test('4gray format packs 2-bit pixels top bits first', () => { + const { gpio, spi } = createMockHal(); + const epd = createDisplay('13in3k', '4gray', { gpio, spi }); + + epd.setPixel(0, 0, 0); // black -> 00 + epd.setPixel(1, 0, 1); // dark -> 01 + epd.setPixel(2, 0, 2); // light -> 10 + epd.setPixel(3, 0, 3); // white -> 11 + assert.strictEqual(epd.imageBuffer[0], 0b00011011); + + epd.setPixel(0, 0, 99); // out of range -> white + assert.strictEqual(epd.imageBuffer[0], 0b11011011); +}); + +test('3color format maintains both planes per pixel', () => { + const { gpio, spi } = createMockHal(); + const epd = createDisplay('2in7b', 'red', { gpio, spi }); + + epd.setPixel(0, 0, 2); // accent: white in image plane, set in color plane + assert.strictEqual(epd.imageBuffer[0] & 0x80, 0x80); + assert.strictEqual(epd.colorBuffer[0] & 0x80, 0x80); + + epd.setPixel(0, 0, 0); // black: cleared in both planes + assert.strictEqual(epd.imageBuffer[0] & 0x80, 0); + assert.strictEqual(epd.colorBuffer[0] & 0x80, 0); + + epd.setPixel(0, 0, 1); // white: set in image plane, cleared in color plane + assert.strictEqual(epd.imageBuffer[0] & 0x80, 0x80); + assert.strictEqual(epd.colorBuffer[0] & 0x80, 0); +}); + +test('7color format packs nibbles and clamps out-of-range colors', () => { + const { gpio, spi } = createMockHal(); + const epd = createDisplay('7in3f', '7color', { gpio, spi }); + + epd.setPixel(0, 0, epd.colors.RED); // upper nibble + epd.setPixel(1, 0, 9); // clamped to 7, lower nibble + assert.strictEqual(epd.imageBuffer[0], (4 << 4) | 7); +}); + +test('rgbToColor quantizes per color mode', () => { + const { gpio, spi } = createMockHal(); + + const mono = createDisplay('2in13', 'mono', { gpio, spi }); + assert.strictEqual(mono.rgbToColor(30, 30, 30), 0); + assert.strictEqual(mono.rgbToColor(220, 220, 220), 1); + + const gray = createDisplay('2in7', '4gray', { gpio, spi }); + assert.strictEqual(gray.rgbToColor(100, 100, 100), 1); + assert.strictEqual(gray.rgbToColor(170, 170, 170), 2); + + const tri = createDisplay('2in7b', 'red', { gpio, spi }); + assert.strictEqual(tri.rgbToColor(255, 0, 0), 2); // red -> accent + + const seven = createDisplay('7in3f', '7color', { gpio, spi }); + assert.strictEqual(seven.rgbToColor(255, 0, 0), seven.colors.RED); + assert.strictEqual(seven.rgbToColor(255, 200, 0), seven.colors.ORANGE); +}); + +// --- Driver registry ------------------------------------------------------ + +test('every model and alias resolves through the registry', () => { + const { gpio, spi } = createMockHal(); + const { getSupportedModels } = require('..'); + + for (const meta of getSupportedModels()) { + const options = { gpio, spi, experimental: true }; + for (const name of [meta.model, ...meta.aliases]) { + const epd = createDisplay(name, meta.colorModes[0], options); + const [w] = meta.size.split('x').map(Number); + assert.strictEqual(epd.width, w, `${name} should create a ${meta.size} display`); + } + } + + assert.strictEqual(getSupportedModels().length, 8); +}); + +test('unknown model throws with the supported list', () => { + assert.throws( + () => createDisplay('9in99', 'mono'), + /Unsupported display model: 9in99.*13in3k/ + ); +}); + // --- Experimental driver gating ------------------------------------------- test('13in3gray (IT8951) is gated behind the experimental option', () => {