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)

759 lines (637 loc) 25.3 kB
// Base OLED driver implementation // Contains shared functionality for SSD1306 and SH1106 drivers import { createLogger } from '../utils/logger.mjs'; class BaseOLED { constructor(i2c, opts) { // Create a driver-specific logger this.logger = createLogger('BaseOLED'); // Use optional chaining and nullish coalescing for options this.HEIGHT = opts?.height ?? 64; this.WIDTH = opts?.width ?? 128; this.ADDRESS = opts?.address ?? 0x3c; this.MAX_PAGE_COUNT = this.HEIGHT / 8; this.LINESPACING = opts?.linespacing ?? 1; this.LETTERSPACING = opts?.letterspacing ?? 1; // Set logger level if provided if (opts?.logLevel) { this.logger.setLevel(opts.logLevel); } // Common command definitions for all OLED displays this.DISPLAY_OFF = 0xae; this.DISPLAY_ON = 0xaf; this.SET_DISPLAY_CLOCK_DIV = 0xd5; this.SET_MULTIPLEX = 0xa8; this.SET_DISPLAY_OFFSET = 0xd3; this.SET_CONTRAST = 0x81; this.SET_PRECHARGE = 0xd9; this.SET_VCOM_DETECT = 0xdb; this.DISPLAY_ALL_ON_RESUME = 0xa4; this.NORMAL_DISPLAY = 0xa6; this.INVERT_DISPLAY = 0xa7; this.SET_CONTRAST_CTRL_MODE = 0x81; // Common addressing modes used by both drivers this.MEMORY_MODE = 0x20; this.HORIZONTAL_ADDRESSING_MODE = 0x00; this.VERTICAL_ADDRESSING_MODE = 0x01; this.PAGE_ADDRESSING_MODE = 0x02; // Initialize cursor position this.cursor_x = 0; this.cursor_y = 0; // Create blank buffer (1 byte per pixel) this.buffer = Buffer.alloc((this.WIDTH * this.HEIGHT) / 8); this.buffer.fill(0xff); this.dirtyBytes = []; // Store the i2c interface this.wire = i2c; } /* ################################################################################################## * Common OLED control methods * ################################################################################################## */ // Turn OLED on turnOnDisplay = async () => { await this._transfer('cmd', this.DISPLAY_ON); }; // Turn OLED off turnOffDisplay = async () => { await this._transfer('cmd', this.DISPLAY_OFF); }; // Dim display by adjusting contrast dimDisplay = async (bool) => { const contrast = bool ? 0 : 0xff; // Dimmed display if true, bright display if false await this._transfer('cmd', this.SET_CONTRAST_CTRL_MODE); await this._transfer('cmd', contrast); }; // Invert display pixels invertDisplay = async (bool) => { if (bool) { await this._transfer('cmd', this.INVERT_DISPLAY); // inverted } else { await this._transfer('cmd', this.NORMAL_DISPLAY); // non inverted } }; /* ################################################################################################## * Enhanced OLED control methods - provide optimized implementations that can be overridden * ################################################################################################## */ // Enhanced turnOnDisplay with optimized power sequence _enhancedTurnOnDisplay = async (options) => { try { // Optimized standard power-on sequence based on both SSD1306 and SH1106 datasheets await this._transferBatch('cmd', [ this.DISPLAY_OFF, // Display off during changes this.SET_CONTRAST_CTRL_MODE, // Set contrast control options?.contrast ?? 0x8F, // Default contrast this.DISPLAY_ALL_ON_RESUME, // Resume to RAM content this.NORMAL_DISPLAY, // Normal (non-inverted) display this.DISPLAY_ON // Turn display on ]); } catch (err) { this._handleError('turning on display', err); } }; // Enhanced turnOffDisplay with optimized power sequence _enhancedTurnOffDisplay = async (options) => { try { // Standard power-off sequence with optional commands const commands = [this.DISPLAY_OFF]; // Add any additional power-down commands if provided if (options?.additionalCommands) { commands.push(...options.additionalCommands); } await this._transferBatch('cmd', commands); } catch (err) { this._handleError('turning off display', err); } }; // Enhanced dimDisplay with optimized contrast control _enhancedDimDisplay = async (bool, options = {}) => { try { // Default contrast values based on common values for OLED displays const brightContrast = options.brightContrast ?? 0xCF; const dimContrast = options.dimContrast ?? 0x0F; // Apply contrast based on bool parameter const contrast = bool ? dimContrast : brightContrast; // Use batch commands for better performance await this._transferBatch('cmd', [this.SET_CONTRAST_CTRL_MODE, contrast]); } catch (err) { this._handleError('dimming display', err); } }; // Enhanced invertDisplay with optimal contrast adjustment _enhancedInvertDisplay = async (bool, options = {}) => { try { // Default contrast values optimized for normal and inverted modes const normalContrast = options.normalContrast ?? 0x8F; const invertedContrast = options.invertedContrast ?? 0x60; // Commands for setting display mode and adjusting contrast accordingly const commands = bool ? [this.INVERT_DISPLAY, this.SET_CONTRAST_CTRL_MODE, invertedContrast] : [this.NORMAL_DISPLAY, this.SET_CONTRAST_CTRL_MODE, normalContrast]; await this._transferBatch('cmd', commands); } catch (err) { this._handleError('inverting display', err); } }; /* ################################################################################################## * Enhanced display control methods that can be used by derived classes * ################################################################################################## */ // Enhanced dim display with device-specific contrast values _enhancedDimDisplay = async (bool, options = {}) => { const { brightContrast = 0xff, dimContrast = 0x00 } = options; const contrast = bool ? dimContrast : brightContrast; await this._transferBatch('cmd', [this.SET_CONTRAST_CTRL_MODE, contrast]); }; // Enhanced invert display with device-specific contrast adjustments _enhancedInvertDisplay = async (bool, options = {}) => { const { normalContrast = 0x80, invertedContrast = 0x60 } = options; if (bool) { // When inverting, adjust contrast for better readability in inverted mode await this._transferBatch('cmd', [ this.INVERT_DISPLAY, // Invert display command this.SET_CONTRAST, // Follow with contrast adjustment invertedContrast // Contrast value optimized for inverted display ]); } else { // When restoring normal display, also restore optimal contrast await this._transferBatch('cmd', [ this.NORMAL_DISPLAY, // Normal display command this.SET_CONTRAST, // Follow with contrast adjustment normalContrast // Default contrast value ]); } }; // Enhanced turn on display with device-specific power-on sequence _enhancedTurnOnDisplay = async (options = {}) => { const { contrast = 0x80, additionalCommands = [] } = options; // Build the command sequence with display off during changes const commands = [ this.DISPLAY_OFF, // Display off during changes ...additionalCommands, this.DISPLAY_ON // Display on ]; await this._transferBatch('cmd', commands); }; // Enhanced turn off display with device-specific power-down sequence _enhancedTurnOffDisplay = async (options = {}) => { const { additionalCommands = [] } = options; // Build the command sequence for power-optimized shutdown const commands = [ this.DISPLAY_OFF, // Display off ...additionalCommands ]; await this._transferBatch('cmd', commands); }; /* ################################################################################################## * Common drawing methods * ################################################################################################## */ // Set cursor position for text setCursor = (x, y) => { this.cursor_x = x; this.cursor_y = y; }; // Optimized method to clear all pixels on the display clearDisplay = async (sync) => { try { // Fast buffer clear operation using fill this.buffer.fill(0x00); // Since we're clearing the entire display, mark everything as dirty this.dirtyBytes = Array.from({ length: this.buffer.length }, (_, i) => i); if (sync) { // For a full clear, do a full update - more efficient than updating every dirty byte await this.update(); this.dirtyBytes = []; // Reset dirty bytes after update } } catch (err) { this.logger.error('Error clearing display:', err); throw err; } }; // Draw a segment of a page on the oled drawPageSeg = async (page, seg, byte, sync) => { if (page < 0 || page >= this.MAX_PAGE_COUNT || seg < 0 || seg >= this.WIDTH) { return; } // Wait for oled to be ready await this._waitUntilReady(); // Set the start and end byte locations for oled display update const bufferIndex = seg + page * this.WIDTH; this.buffer[bufferIndex] = byte; if (!this.dirtyBytes.includes(bufferIndex)) { this.dirtyBytes.push(bufferIndex); } if (sync) { await this._updateDirtyBytes(this.dirtyBytes); } }; // Optimized method to draw one or many pixels drawPixel = async (pixels, sync) => { try { // Handle lazy single pixel case if (typeof pixels[0] !== 'object') { pixels = [pixels]; } // Process all pixels in a batch pixels.forEach((el) => { // Return if the pixel is out of range const x = el[0]; const y = el[1]; const color = el[2]; if (x < 0 || x >= this.WIDTH || y < 0 || y >= this.HEIGHT) { return; } // More efficient calculation of byte position const page = Math.floor(y / 8); const byte = x + this.WIDTH * page; const pageShift = 0x01 << (y & 0x07); // Faster than (y - 8 * page) // Optimize setting pixel (monochrome) if (color === 'BLACK' || !color) { this.buffer[byte] &= ~pageShift; } else { this.buffer[byte] |= pageShift; } // Track dirty bytes if (!this.dirtyBytes.includes(byte)) { this.dirtyBytes.push(byte); } }); if (sync) { await this._updateDirtyBytes(this.dirtyBytes); } } catch (err) { this.logger.error('Error drawing pixels:', err); throw err; } }; // Draw a line using Bresenham's line algorithm drawLine = async (x0, y0, x1, y1, color, sync = true) => { const dx = Math.abs(x1 - x0), sx = x0 < x1 ? 1 : -1; const dy = Math.abs(y1 - y0), sy = y0 < y1 ? 1 : -1; let err = (dx > dy ? dx : -dy) / 2; while (true) { await this.drawPixel([x0, y0, color], false); if (x0 === x1 && y0 === y1) break; const e2 = err; if (e2 > -dx) { err -= dy; x0 += sx; } if (e2 < dy) { err += dx; y0 += sy; } } if (sync) { await this._updateDirtyBytes(this.dirtyBytes); } }; // Draw a filled rectangle fillRect = async (x, y, w, h, color, sync = true) => { // One iteration for each column of the rectangle for (let i = x; i < x + w; i += 1) { // Draw a vertical line await this.drawLine(i, y, i, y + h - 1, color, false); } if (sync) { await this._updateDirtyBytes(this.dirtyBytes); } }; // Write text to the display writeString = async (font, size, string, color, wrap, sync) => { const immed = typeof sync === 'undefined' ? true : sync; const wordArr = string.split(' '); const len = wordArr.length; // Start x offset at cursor pos let offset = this.cursor_x; // Loop through words for (let w = 0; w < len; w += 1) { // Put the word space back in for all in between words or empty words if (w < len - 1 || !wordArr[w].length) { wordArr[w] += ' '; } const stringArr = wordArr[w].split(''); const slen = stringArr.length; const compare = font.width * size * slen + size * (len - 1); // Wrap words if necessary if (wrap && len > 1 && w > 0 && offset >= this.WIDTH - compare) { offset = 0; this.cursor_y += font.height * size + this.LINESPACING; this.setCursor(offset, this.cursor_y); } // Loop through the array of each char to draw for (let i = 0; i < slen; i += 1) { if (stringArr[i] === '\n') { offset = 0; this.cursor_y += font.height * size + this.LINESPACING; this.setCursor(offset, this.cursor_y); } else { // Look up the position of the char, pull out the buffer slice const charBuf = this._findCharBuf(font, stringArr[i]); // Read the bits in the bytes that make up the char const charBytes = this._readCharBytes(charBuf, font.height); // Draw the entire character await this._drawChar(charBytes, font.height, size, false); // Calc new x position for the next char, add padding offset += font.width * size + this.LETTERSPACING; // Wrap letters if necessary if (wrap && offset >= this.WIDTH - font.width - this.LETTERSPACING) { offset = 0; this.cursor_y += font.height * size + this.LINESPACING; } // Set the 'cursor' for the next char to be drawn this.setCursor(offset, this.cursor_y); } } } if (immed) { await this._updateDirtyBytes(this.dirtyBytes); } }; // Draw an RGBA image (optimized) drawRGBAImage = async (image, dx, dy, sync) => { try { const immed = sync ?? true; // Pre-calculate constants to avoid repeated calculations const dyp = this.WIDTH * Math.floor(dy / 8); const dxyp = dyp + dx; const imageWidth = image.width; const imageHeight = image.height; const dirtySet = new Set(); // Use Set for more efficient uniqueness check // Process image data by columns for (let x = 0; x < imageWidth; x++) { const dxx = dx + x; if (dxx < 0 || dxx >= this.WIDTH) continue; let buffIndex = x + dxyp; let buffByte = this.buffer[buffIndex]; for (let y = 0; y < imageHeight; y++) { const dyy = dy + y; if (dyy < 0 || dyy >= this.HEIGHT) continue; const dyyp = Math.floor(dyy / 8); // Check if start of buffer page if (!(dyy & 0x07)) { // Equivalent to (dyy % 8) but faster // Check if we need to save previous byte if ((x || y) && buffByte !== this.buffer[buffIndex]) { this.buffer[buffIndex] = buffByte; dirtySet.add(buffIndex); } // New buffer page buffIndex = dx + x + this.WIDTH * dyyp; buffByte = this.buffer[buffIndex]; } // Process pixel into buffer byte - more efficient indexing const dataIndex = (imageWidth * y + x) << 2; // 4 bytes per pixel (RGBA) // Skip transparent pixels if (!image.data[dataIndex + 3]) continue; // Check if any color channel is non-zero const bit = image.data[dataIndex] | image.data[dataIndex + 1] | image.data[dataIndex + 2]; const pixelByte = 1 << (dyy & 0x07); // Equivalent to (dyy - 8 * dyyp) but faster // Set or clear the bit if (bit) { buffByte |= pixelByte; } else { buffByte &= ~pixelByte; } } // Save the final byte for this column if changed if (buffByte !== this.buffer[buffIndex]) { this.buffer[buffIndex] = buffByte; dirtySet.add(buffIndex); } } // Convert Set to Array and append to dirtyBytes this.dirtyBytes.push(...dirtySet); if (immed) { await this._updateDirtyBytes(this.dirtyBytes); } } catch (err) { this.logger.error('Error drawing RGBA image:', err); throw err; } }; // Draw a bitmap efficiently drawBitmap = async (pixels, sync) => { try { // Use Set for tracking dirty bytes efficiently const dirtySet = new Set(); const pixelLength = pixels.length; const width = this.WIDTH; // Process pixels in chunks for better performance const CHUNK_SIZE = 1024; // Process 1KB at a time for (let chunkStart = 0; chunkStart < pixelLength; chunkStart += CHUNK_SIZE) { const chunkEnd = Math.min(chunkStart + CHUNK_SIZE, pixelLength); // Process this chunk of pixels for (let i = chunkStart; i < chunkEnd; i++) { const x = Math.floor(i % width); const y = Math.floor(i / width); if (x < 0 || x >= this.WIDTH || y < 0 || y >= this.HEIGHT) { continue; } const page = Math.floor(y / 8); const byte = x + this.WIDTH * page; const pageShift = 0x01 << (y & 0x07); // Set or clear the pixel if (pixels[i]) { this.buffer[byte] |= pageShift; } else { this.buffer[byte] &= ~pageShift; } // Mark as dirty dirtySet.add(byte); } } // Add dirty bytes to the tracking array this.dirtyBytes.push(...dirtySet); if (sync) { await this._updateDirtyBytes(this.dirtyBytes); } } catch (err) { this.logger.error('Error drawing bitmap:', err); throw err; } }; /* ################################################################################################## * Common helper methods * ################################################################################################## */ // Draw an individual character to the screen _drawChar = async (byteArray, charHeight, size, _sync) => { // Take your positions... const x = this.cursor_x, y = this.cursor_y; // Loop through the byte array containing the hexes for the char for (let i = 0; i < byteArray.length; i += 1) { for (let j = 0; j < charHeight; j += 1) { // Pull color out const color = byteArray[i][j]; let xpos, ypos; // Standard font size if (size === 1) { xpos = x + i; ypos = y + j; await this.drawPixel([xpos, ypos, color], false); } else { // MATH! Calculating pixel size multiplier to primitively scale the font xpos = x + i * size; ypos = y + j * size; await this.fillRect(xpos, ypos, size, size, color, false); } } } }; // Get character bytes from the supplied font object _readCharBytes = (byteArray, charHeight) => { let bitArr = []; const bitCharArr = []; // Loop through each byte supplied for a char for (let i = 0; i < byteArray.length; i += 1) { // Set current byte const byte = byteArray[i]; // Read each byte for (let j = 0; j < charHeight; j += 1) { // Shift bits right until all are read const bit = (byte >> j) & 1; bitArr.push(bit); } // Push to array containing flattened bit sequence bitCharArr.push(bitArr); // Clear bits for next byte bitArr = []; } return bitCharArr; }; // Find where the character exists within the font object _findCharBuf = (font, c) => { // Use the lookup array as a ref to find where the current char bytes start const cBufPos = font.lookup.indexOf(c) * font.width; // Slice just the current char's bytes out of the fontData array and return const cBuf = font.fontData.slice(cBufPos, cBufPos + font.width); return cBuf; }; /* ################################################################################################## * Common I2C communication methods * ################################################################################################## */ // Writes both commands and data buffers to this device _transfer = async (type, val) => { let control; if (type === 'data') { control = 0x40; } else if (type === 'cmd') { control = 0x00; } else { return; } const bufferForSend = Buffer.from([control, val]); // Send control and actual val await this.wire.i2cWrite(this.ADDRESS, 2, bufferForSend); }; // Batch multiple commands or data values into a single I2C transfer for better efficiency _transferBatch = async (type, values) => { const control = type === 'data' ? 0x40 : 0x00; // Create buffer large enough for all commands (control byte + value for each command) const buffer = Buffer.alloc(values.length * 2); // Fill buffer with alternating control bytes and command/data bytes for (let i = 0; i < values.length; i++) { buffer[i * 2] = control; buffer[i * 2 + 1] = values[i]; } // Send all commands in a single I2C transaction await this.wire.i2cWrite(this.ADDRESS, buffer.length, buffer); }; // Read a byte from the oled _readI2C = async () => { const buffer = Buffer.alloc(1); const { bytesRead, buffer: data } = await this.wire.i2cRead(this.ADDRESS, 1, buffer); return bytesRead > 0 ? data[0] : 0; }; // Sometimes the oled gets a bit busy with lots of bytes. // Read the response byte to see if this is the case _waitUntilReady = async () => { const tick = async () => { const byte = await this._readI2C(); const busy = (byte >> 7) & 1; if (!busy) { return; } else { await new Promise((resolve) => setTimeout(resolve, 0)); await tick(); } }; await tick(); }; /* ################################################################################################## * Base initialization helper methods (to be used by derived classes) * ################################################################################################## */ // Base initialization method that follows a standard flow structure _baseInitialize = async (initSequences) => { try { // Process each sequence batch in order for (const { name, commands } of initSequences) { this.logger.debug(`Processing initialization batch: ${name}`); await this._transferBatch('cmd', commands); } this.logger.debug('Display initialized successfully'); return true; } catch (err) { this.logger.error('Error initializing display:', err); throw err; } }; /* ################################################################################################## * Common error handling method * ################################################################################################## */ // Handle errors consistently across all driver implementations _handleError = (operation, error) => { this.logger.error(`Error during ${operation}:`, error); throw error; }; /* ################################################################################################## * Methods that must be implemented by derived classes * ################################################################################################## */ // These methods must be implemented by derived classes _initialise() { throw new Error('_initialise method must be implemented by derived class'); } update() { throw new Error('update method must be implemented by derived class'); } _updateDirtyBytes() { throw new Error('_updateDirtyBytes method must be implemented by derived class'); } /* ################################################################################################## * Common update strategy methods - provide default implementations that can be overridden * ################################################################################################## */ // Common logic for determining whether to do a full update or partial update _shouldDoFullUpdate = (dirtyByteArray) => { // If there are more than ~14% of bytes dirty, full update is more efficient (1/7) return dirtyByteArray.length > this.buffer.length / 7; }; // Group dirty bytes by page for more efficient updates - used by both drivers _groupDirtyBytesByPage = (dirtyByteArray) => { const pageGroups = new Map(); // Sort dirty bytes by page and column for more efficient I2C commands for (let i = 0; i < dirtyByteArray.length; i++) { const byteIndex = dirtyByteArray[i]; const page = Math.floor(byteIndex / this.WIDTH); const col = Math.floor(byteIndex % this.WIDTH); if (!pageGroups.has(page)) { pageGroups.set(page, []); } pageGroups.get(page).push({ col, byteIndex, }); } return pageGroups; }; } export default BaseOLED;