UNPKG

oled-rpi-i2c-bus-async

Version:

Asynchronous NodeJS module for controlling oled devices on the Raspbery Pi (including the SSD1306 and SH1106 OLED screens)

629 lines (574 loc) 18.4 kB
/** * Main OLED package entry point * @module oled-rpi-i2c-bus-async * @fileoverview Asynchronous NodeJS module for controlling OLED displays on Raspberry Pi */ import SSD1306 from './drivers/ssd1306.mjs'; import SH1106 from './drivers/sh1106.mjs'; import { createLogger } from './utils/logger.mjs'; import fs from 'fs'; import { PNG } from 'pngjs'; // Create main logger for the OLED class const logger = createLogger('OLED'); /** * Resource management for animations and timers * @type {Object} */ const resourceTracker = { timers: [], animationVars: { pdx: null, pdy: null }, /** * Clean up a specific timer * @param {number} timerId - The timer ID to clear */ clearTimer: (timerId) => { clearInterval(timerId); const index = resourceTracker.timers.indexOf(timerId); if (index > -1) { resourceTracker.timers.splice(index, 1); } }, /** * Clean up all timers */ clearAllTimers: () => { resourceTracker.timers.forEach((id) => clearInterval(id)); resourceTracker.timers = []; }, }; /** * Main OLED driver class that delegates to specific hardware implementations * @class */ class Oled { /** * Creates a new OLED display instance * @param {Object} i2c - I2C bus instance * @param {Object} opts - Configuration options * @param {string} [opts.driver='SSD1306'] - Driver type ('SSD1306' or 'SH1106') * @param {number} [opts.height=64] - Display height in pixels * @param {number} [opts.width=128] - Display width in pixels * @param {string} [opts.logLevel] - Logging level */ constructor(i2c, opts) { // Set basic properties from options, with defaults this.DRIVER = opts?.driver ?? 'SSD1306'; this.HEIGHT = opts?.height ?? 64; this.WIDTH = opts?.width ?? 128; // Set logger level from options or use default if (opts?.logLevel) { logger.setLevel(opts.logLevel); } try { // Create the appropriate driver implementation switch (this.DRIVER) { case 'SSD1306': this.api = new SSD1306(i2c, opts); break; case 'SH1106': this.api = new SH1106(i2c, opts); break; default: throw new Error(`Unknown driver: ${this.DRIVER}`); } logger.info( `Initialized ${this.DRIVER} display with dimensions ${this.WIDTH}x${this.HEIGHT}` ); } catch (err) { logger.error('Failed to initialize display:', err); throw err; } } /* ###################################################################### * OLED Controls - delegate to driver API * ###################################################################### */ /** * Turn on the display * @async * @returns {Promise<void>} */ turnOnDisplay = async () => { await this.api.turnOnDisplay(); }; /** * Turn off the display * @async * @returns {Promise<void>} */ turnOffDisplay = async () => { await this.api.turnOffDisplay(); }; /** * Dim the display * @async * @param {boolean} bool - Whether to dim the display * @returns {Promise<void>} */ dimDisplay = async (bool) => { await this.api.dimDisplay(bool); }; /** * Invert pixels on oled * @async * @param {boolean} bool - Whether to invert display colors * @returns {Promise<void>} */ invertDisplay = async (bool) => { await this.api.invertDisplay(bool); }; /** * Activate scrolling for rows start through stop * @async * @param {string} dir - Scroll direction ('left' or 'right') * @param {number} start - Start row * @param {number} stop - End row * @returns {Promise<void>} */ startScroll = async (dir, start, stop) => { await this.api.startScroll(dir, start, stop); }; /** * Stop scrolling display contents * @async * @returns {Promise<void>} */ stopScroll = async () => { await this.api.stopScroll(); }; /** * Send the entire framebuffer to the oled * @async * @returns {Promise<void>} */ update = async () => { await this.api.update(); }; /* ###################################################################### * OLED Drawings - delegate to driver API * ###################################################################### */ /** * Clear all pixels on the display * @async * @param {boolean} sync - Whether to update display immediately * @returns {Promise<void>} */ clearDisplay = async (sync) => { await this.api.clearDisplay(sync); }; /** * Set starting position of a text string on the oled * @async * @param {number} x - X coordinate * @param {number} y - Y coordinate * @returns {Promise<void>} */ setCursor = async (x, y) => { this.api.setCursor(x, y); }; /** * Draw a column of a page on the oled * @async * @param {number} page - Page number * @param {number} col - Column number * @param {number} byte - Byte value to write * @returns {Promise<void>} */ drawPageCol = async (page, col, byte) => { // Call drawPageCol if the driver has it, otherwise use drawPageSeg if (typeof this.api.drawPageCol === 'function') { await this.api.drawPageCol(page, col, byte); } else { await this.api.drawPageSeg(page, col, byte, true); } }; /** * Draw a segment of a page on the oled * @async * @param {number} page - Page number * @param {number} seg - Segment number * @param {number} byte - Byte value to write * @param {boolean} sync - Whether to update display immediately * @returns {Promise<void>} */ drawPageSeg = async (page, seg, byte, sync) => { await this.api.drawPageSeg(page, seg, byte, sync); }; /** * Draw one or many pixels on oled * @async * @param {Array} pixels - Array of pixel data * @param {boolean} sync - Whether to update display immediately * @returns {Promise<void>} */ drawPixel = async (pixels, sync) => { await this.api.drawPixel(pixels, sync); }; /** * Draw a line * @async * @param {number} x0 - Starting X coordinate * @param {number} y0 - Starting Y coordinate * @param {number} x1 - Ending X coordinate * @param {number} y1 - Ending Y coordinate * @param {number} color - Line color * @param {boolean} sync - Whether to update display immediately * @returns {Promise<void>} */ drawLine = async (x0, y0, x1, y1, color, sync) => { await this.api.drawLine(x0, y0, x1, y1, color, sync); }; /** * Draw a filled rectangle * @async * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} w - Width * @param {number} h - Height * @param {number} color - Rectangle color * @param {boolean} sync - Whether to update display immediately * @returns {Promise<void>} */ fillRect = async (x, y, w, h, color, sync) => { await this.api.fillRect(x, y, w, h, color, sync); }; /** * Write text to the oled * @async * @param {Object} font - Font object * @param {number} size - Font size * @param {string} string - Text string * @param {number} color - Text color * @param {boolean} wrap - Whether to wrap text * @param {boolean} sync - Whether to update display immediately * @returns {Promise<void>} */ writeString = async (font, size, string, color, wrap, sync) => { await this.api.writeString(font, size, string, color, wrap, sync); }; /** * Draw an RGBA image at the specified coordinates * @async * @param {Object} image - Image object * @param {number} dx - X coordinate * @param {number} dy - Y coordinate * @param {boolean} sync - Whether to update display immediately * @returns {Promise<void>} */ drawRGBAImage = async (image, dx, dy, sync) => { await this.api.drawRGBAImage(image, dx, dy, sync); }; /** * Draw an image pixel array on the screen * @async * @param {Array} pixels - Array of pixel data * @param {boolean} sync - Whether to update display immediately * @returns {Promise<void>} */ drawBitmap = async (pixels, sync) => { await this.api.drawBitmap(pixels, sync); }; /* ###################################################################### * OLED Shape/Indicators * ###################################################################### */ /** * Draw a battery icon on the oled with parallel operations * @async * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} percentage - Battery percentage * @returns {Promise<void>} */ battery = async (x, y, percentage) => { try { // Draw the battery outline in parallel await Promise.all([ this.drawLine(x, y, x + 16, y, 1), this.drawLine(x, y + 8, x + 16, y + 8, 1), this.drawLine(x, y, x, y + 8, 1), this.drawPixel([ [x + 17, y + 1, 1], [x + 17, y + 7, 1], ]), this.drawLine(x + 18, y + 1, x + 18, y + 7, 1), ]); // Draw battery level based on percentage let operations = []; if (percentage >= 70) { operations = [ this.fillRect(x + 2, y + 2, 3, 5, 1, false), this.fillRect(x + 7, y + 2, 3, 5, 1, false), this.fillRect(x + 12, y + 2, 3, 5, 1, true), ]; } else if (percentage >= 40) { operations = [ this.fillRect(x + 2, y + 2, 3, 5, 1, false), this.fillRect(x + 7, y + 2, 3, 5, 1, false), this.fillRect(x + 12, y + 2, 3, 5, 0, true), ]; } else if (percentage >= 10) { operations = [ this.fillRect(x + 2, y + 2, 3, 5, 1, false), this.fillRect(x + 7, y + 2, 3, 5, 0, false), this.fillRect(x + 12, y + 2, 3, 5, 0, true), ]; } else { operations = [ this.fillRect(x + 2, y + 2, 3, 5, 0, false), this.fillRect(x + 7, y + 2, 3, 5, 0, false), this.fillRect(x + 12, y + 2, 3, 5, 0, true), ]; } await Promise.all(operations); } catch (err) { logger.error('Error drawing battery icon:', err); throw err; } }; /** * Draw a bluetooth icon on the oled with parallel operations * @async * @param {number} x - X coordinate * @param {number} y - Y coordinate * @returns {Promise<void>} */ bluetooth = async (x, y) => { try { await Promise.all([ this.drawLine(x + 5, y + 1, x + 5, y + 11, 1), this.drawLine(x + 2, y + 3, x + 9, y + 8, 1), this.drawLine(x + 2, y + 9, x + 8, y + 3, 1), this.drawLine(x + 5, y + 1, x + 9, y + 3, 1), this.drawLine(x + 5, y + 11, x + 8, y + 9, 1), ]); } catch (err) { logger.error('Error drawing bluetooth icon:', err); throw err; } }; /** * Draw a wifi icon on the oled with parallel operations * @async * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {number} percentage - Signal strength percentage * @returns {Promise<void>} */ wifi = async (x, y, percentage) => { try { // Draw the wifi base in parallel await Promise.all([ this.drawLine(x, y, x + 8, y, 1), this.drawLine(x, y, x + 4, y + 4, 1), this.drawLine(x + 8, y, x + 4, y + 4, 1), this.drawLine(x + 4, y, x + 4, y + 9, 1), ]); // Draw signal strength based on percentage let operations = []; if (percentage >= 70) { operations = [ this.fillRect(x + 6, y + 8, 2, 2, 1, true), this.fillRect(x + 10, y + 6, 2, 4, 1, true), this.fillRect(x + 14, y + 4, 2, 6, 1, true), ]; } else if (percentage >= 40) { operations = [ this.fillRect(x + 6, y + 8, 2, 2, 1, true), this.fillRect(x + 10, y + 6, 2, 4, 1, true), this.fillRect(x + 14, y + 4, 2, 6, 0, true), ]; } else if (percentage >= 10) { operations = [ this.fillRect(x + 6, y + 8, 2, 2, 1, true), this.fillRect(x + 10, y + 6, 2, 4, 0, true), this.fillRect(x + 14, y + 4, 2, 6, 0, true), ]; } else { operations = [ this.fillRect(x + 6, y + 8, 2, 2, 0, true), this.fillRect(x + 10, y + 6, 2, 4, 0, true), this.fillRect(x + 14, y + 4, 2, 6, 0, true), ]; } await Promise.all(operations); } catch (err) { logger.error('Error drawing wifi icon:', err); throw err; } }; /** * Draw or animate an image * @async * @param {number} x - X coordinate * @param {number} y - Y coordinate * @param {string|Object} image - Image path or object * @param {Object} font - Font object * @param {boolean} clear - Whether to clear the display before drawing * @param {boolean} reset - Whether to reset the animation * @param {boolean} animated - Whether the image is animated * @param {boolean} wrapping - Whether to wrap text * @returns {Promise<void>} */ image = async (x, y, image, font, clear, reset, animated, wrapping) => { try { const dirresources = `${process.cwd()}/resources/`; let tryImage = ''; // Handle reset case if (reset) { // Clean up resources resourceTracker.clearAllTimers(); resourceTracker.animationVars.pdx = null; resourceTracker.animationVars.pdy = null; // Clear display if requested if (clear) { await this.clearDisplay(); } return; } // Handle relative path if (typeof image === 'string' && !image.includes('/')) { tryImage = image; image = `${dirresources}${image}`; } // Check if file exists try { if (!fs.statSync(image).isFile()) { logger.warn(`File ${image} does not exist.`); } } catch (err) { logger.warn(`Error accessing file ${image}:`, err); image = `${dirresources}notafile.png`; x = 0; y = 17; await this.clearDisplay(); await this.writeString(font, 1, tryImage ?? 'File not found', 1, wrapping); } // Clear display if requested if (clear) { await this.clearDisplay(); } // Process the image const _oled = this; // Use a proper Promise-based approach for handling the PNG stream await new Promise((resolve, reject) => { fs.createReadStream(image) .pipe(new PNG({ filterType: 4 })) .on('parsed', async function () { try { if (animated) { resourceTracker.animationVars.pdx = 1; resourceTracker.animationVars.pdy = -1; // Create animation interval const myInterval = setInterval(async () => { try { await _oled._drawPseudo( _oled, clear, this, resourceTracker.animationVars.pdx, resourceTracker.animationVars.pdy ); } catch (err) { logger.error('Animation error:', err); resourceTracker.clearTimer(myInterval); } }, 10); resourceTracker.timers.push(myInterval); } else { // Draw static image await _oled.api.drawRGBAImage( this, x ?? Math.floor((_oled.WIDTH - this.width) / 2), y ?? Math.floor((_oled.HEIGHT - this.height) / 2), true ); } resolve(); } catch (err) { reject(err); } }) .on('error', (err) => { logger.error('PNG processing error:', err); reject(err); }); }); } catch (err) { logger.error('Image processing error:', err); throw err; } }; /** * Animate an image on the display * @async * @param {Object} display - Display instance * @param {boolean} clear - Whether to clear the display before drawing * @param {Object} image - Image object * @param {number} pdx - X direction * @param {number} pdy - Y direction * @returns {Promise<void>} */ _drawPseudo = async (display, clear, image, pdx, pdy) => { try { // Initialize animation state if needed if ( typeof this._drawPseudo.init === 'undefined' || this._drawPseudo.init === true || this._drawPseudo.image !== image ) { this._drawPseudo.init = false; this._drawPseudo.image = image; this._drawPseudo.x = 1; this._drawPseudo.y = 1; this._drawPseudo.prevX = 1; this._drawPseudo.prevY = 1; this._drawPseudo.dx = pdx; this._drawPseudo.dy = pdy; } if (clear) { // Optimize by using Promise.all for parallel drawing await Promise.all([ display.fillRect(0, 0, display.WIDTH, display.HEIGHT, 1, true), display.fillRect(1, 1, display.WIDTH - 2, display.HEIGHT - 2, 0, true), ]); } else { // Clear previous image position await display.fillRect( this._drawPseudo.prevX, this._drawPseudo.prevY, image.width, image.height, 0, false ); // Update position tracking this._drawPseudo.prevX = this._drawPseudo.x; this._drawPseudo.prevY = this._drawPseudo.y; } // Draw image at current position await display.drawRGBAImage(image, this._drawPseudo.x, this._drawPseudo.y, true); // Handle boundary conditions and update position if ( this._drawPseudo.x + this._drawPseudo.dx > display.WIDTH - image.width || this._drawPseudo.x < 1 ) { this._drawPseudo.dx = -this._drawPseudo.dx; } if ( this._drawPseudo.y + this._drawPseudo.dy > display.HEIGHT - image.height || this._drawPseudo.y < 1 ) { this._drawPseudo.dy = -this._drawPseudo.dy; } // Update position for next frame this._drawPseudo.x += this._drawPseudo.dx; this._drawPseudo.y += this._drawPseudo.dy; } catch (err) { logger.error('Animation frame error:', err); throw err; } }; } export default Oled;