oled-rpi-i2c-bus-async
Version:
Asynchronous NodeJS module for controlling oled devices on the Raspbery Pi (including the SSD1306 and SH1106 OLED screens)
653 lines (575 loc) • 17.6 kB
JavaScript
class SSD1306 {
constructor(i2c, opts) {
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;
// create command buffers
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_START_LINE = 0x00;
this.CHARGE_PUMP = 0x8d;
this.EXTERNAL_VCC = false;
this.MEMORY_MODE = 0x20;
this.SEG_REMAP = 0xa1;
this.COM_SCAN_DEC = 0xc8;
this.COM_SCAN_INC = 0xc0;
this.SET_COM_PINS = 0xda;
this.SET_CONTRAST = 0x81;
this.SET_PRECHARGE = 0xd9;
this.SET_VCOM_DETECT = 0xdb;
this.DISPLAY_ALL_ON_RESUME = 0xa4;
this.NORMAL_DISPLAY = 0xa6;
this.COLUMN_ADDR = 0x21;
this.PAGE_ADDR = 0x22;
this.INVERT_DISPLAY = 0xa7;
this.ACTIVATE_SCROLL = 0x2f;
this.DEACTIVATE_SCROLL = 0x2e;
this.SET_VERTICAL_SCROLL_AREA = 0xa3;
this.RIGHT_HORIZONTAL_SCROLL = 0x26;
this.LEFT_HORIZONTAL_SCROLL = 0x27;
this.VERTICAL_AND_RIGHT_HORIZONTAL_SCROLL = 0x29;
this.VERTICAL_AND_LEFT_HORIZONTAL_SCROLL = 0x02;
this.SET_CONTRAST_CTRL_MODE = 0x81;
this.cursor_x = 0;
this.cursor_y = 0;
// new blank buffer (1 byte per pixel)
this.buffer = Buffer.alloc((this.WIDTH * this.HEIGHT) / 8);
this.buffer.fill(0xff);
this.dirtyBytes = [];
const config = {
'128x32': {
multiplex: 0x1f,
compins: 0x02,
coloffset: 0,
},
'128x64': {
multiplex: 0x3f,
compins: 0x12,
coloffset: 0,
},
'96x16': {
multiplex: 0x0f,
compins: 0x02,
coloffset: 0,
},
};
this.wire = i2c;
const screenSize = `${this.WIDTH}x${this.HEIGHT}`;
this.screenConfig = config[screenSize];
this._initialise();
}
/* ##################################################################################################
* OLED controls
* ##################################################################################################
*/
// Turn OLED on
turnOnDisplay = async () => {
await this._transfer('cmd', this.DISPLAY_ON);
};
// Turn OLED on
turnOnDisplay = async () => {
await this._transfer('cmd', this.DISPLAY_ON);
};
// Send dim display command to oled
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 pixels on oled
invertDisplay = async (bool) => {
if (bool) {
await this._transfer('cmd', this.INVERT_DISPLAY); // inverted
} else {
await this._transfer('cmd', this.NORMAL_DISPLAY); // non inverted
}
};
// Activate scrolling for rows start through stop
startScroll = async (dir, start, stop) => {
let scrollHeader,
cmdSeq = [];
switch (dir) {
case 'right':
cmdSeq.push(this.RIGHT_HORIZONTAL_SCROLL);
break;
case 'left':
cmdSeq.push(this.LEFT_HORIZONTAL_SCROLL);
break;
// TODO: left diag and right diag not working yet
case 'left diagonal':
cmdSeq.push(
this.SET_VERTICAL_SCROLL_AREA,
0x00,
this.VERTICAL_AND_LEFT_HORIZONTAL_SCROLL,
this.HEIGHT
);
break;
// TODO: left diag and right diag not working yet
case 'right diagonal':
cmdSeq.push(
this.SET_VERTICAL_SCROLL_AREA,
0x00,
this.VERTICAL_AND_RIGHT_HORIZONTAL_SCROLL,
this.HEIGHT
);
break;
}
await this._waitUntilReady();
cmdSeq.push(
0x00,
start,
0x00,
stop,
// TODO: these need to change when diagonal
0x00,
0xff,
this.ACTIVATE_SCROLL
);
for (let i = 0; i < cmdSeq.length; i++) {
await this._transfer('cmd', cmdSeq[i]);
}
};
// Stop scrolling display contents
stopScroll = async () => {
await this._transfer('cmd', this.DEACTIVATE_SCROLL); // stahp
};
// send the entire framebuffer to the oled
update = async () => {
// wait for oled to be ready
await this._waitUntilReady();
// set the start and end byte locations for oled display update
const displaySeq = [
this.COLUMN_ADDR,
this.screenConfig.coloffset,
this.screenConfig.coloffset + this.WIDTH - 1, // column start and end address
this.PAGE_ADDR,
0,
this.HEIGHT / 8 - 1, // page start and end address
];
// send intro seq
for (let i = 0; i < displaySeq.length; i++) {
await this._transfer('cmd', displaySeq[i]);
}
// write buffer data
const bufferToSend = Buffer.concat([Buffer.from([0x40]), this.buffer]);
await this.wire.i2cWrite(this.ADDRESS, bufferToSend.length, bufferToSend);
};
/* ##################################################################################################
* OLED drawings
* ##################################################################################################
*/
// clear all pixels currently on the display
clearDisplay = async (sync) => {
for (let i = 0; i < this.buffer.length; i += 1) {
if (this.buffer[i] !== 0x00) {
this.buffer[i] = 0x00;
if (!this.dirtyBytes.includes(i)) {
this.dirtyBytes.push(i);
}
}
}
if (sync) {
await this._updateDirtyBytes(this.dirtyBytes);
}
};
// set starting position of a text string on the oled
setCursor = (x, y) => {
this.cursor_x = x;
this.cursor_y = y;
};
// buffer/ram test
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);
}
};
// draw one or many pixels on oled
drawPixel = async (pixels, sync) => {
// handle lazy single pixel case
if (typeof pixels[0] !== 'object') {
pixels = [pixels];
}
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;
}
let byte = 0;
const page = Math.floor(y / 8);
const pageShift = 0x01 << (y - 8 * page);
// is the pixel on the first row of the page?
if (page === 0) {
byte = x;
} else {
byte = x + this.WIDTH * page;
}
// colors! Well, monochrome.
if (color === 'BLACK' || !color) {
this.buffer[byte] &= ~pageShift;
} else if (color === 'WHITE' || color) {
this.buffer[byte] |= pageShift;
}
// push byte to dirty if not already there
if (!this.dirtyBytes.includes(byte)) {
this.dirtyBytes.push(byte);
}
});
if (sync) {
await this._updateDirtyBytes(this.dirtyBytes);
}
};
// 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 on the oled
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) {
// draws a vert line
await this.drawLine(i, y, i, y + h - 1, color, false);
}
if (sync) {
await this._updateDirtyBytes(this.dirtyBytes);
}
};
// write text to the oled
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 a touch of padding too if it's a non space char
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, then loop again for next char
this.setCursor(offset, this.cursor_y);
}
}
}
if (immed) {
await this._updateDirtyBytes(this.dirtyBytes);
}
};
// draw an RGBA image at the specified coordinates
drawRGBAImage = async (image, dx, dy, sync) => {
const immed = typeof sync === 'undefined' ? true : sync;
// translate image data to buffer
let x, y, dataIndex, buffIndex, buffByte, bit, pixelByte;
const dyp = this.WIDTH * Math.floor(dy / 8); // calc once
const dxyp = dyp + dx;
for (x = 0; x < image.width; x++) {
const dxx = dx + x;
if (dxx < 0 || dxx >= this.WIDTH) {
// negative, off the screen
continue;
}
// start buffer index for image column
buffIndex = x + dxyp;
buffByte = this.buffer[buffIndex];
for (y = 0; y < image.height; y++) {
const dyy = dy + y; // calc once
if (dyy < 0 || dyy >= this.HEIGHT) {
// negative, off the screen
continue;
}
const dyyp = Math.floor(dyy / 8); // calc once
// check if start of buffer page
if (!(dyy % 8)) {
// check if we need to save previous byte
if ((x || y) && buffByte !== this.buffer[buffIndex]) {
// save current byte and get next buffer byte
this.buffer[buffIndex] = buffByte;
this.dirtyBytes.push(buffIndex);
}
// new buffer page
buffIndex = dx + x + this.WIDTH * dyyp;
buffByte = this.buffer[buffIndex];
}
// process pixel into buffer byte
dataIndex = (image.width * y + x) << 2; // 4 bytes per pixel (RGBA)
if (!image.data[dataIndex + 3]) {
// transparent, continue to next pixel
continue;
}
pixelByte = 0x01 << (dyy - 8 * dyyp);
bit =
image.data[dataIndex] ||
image.data[dataIndex + 1] ||
image.data[dataIndex + 2];
if (bit) {
buffByte |= pixelByte;
} else {
buffByte &= ~pixelByte;
}
}
if ((x || y) && buffByte !== this.buffer[buffIndex]) {
// save current byte
this.buffer[buffIndex] = buffByte;
this.dirtyBytes.push(buffIndex);
}
}
if (immed) {
await this._updateDirtyBytes(this.dirtyBytes);
}
};
// draw an image pixel array on the screen
drawBitmap = async (pixels, sync) => {
let x;
let y;
for (let i = 0; i < pixels.length; i++) {
x = Math.floor(i % this.WIDTH);
y = Math.floor(i / this.WIDTH);
await this.drawPixel([x, y, pixels[i]], false);
}
if (sync) {
await this._updateDirtyBytes(this.dirtyBytes);
}
};
/* ##################################################################################################
* Private utilities
* ##################################################################################################
*/
// Initialize the display
_initialise = async () => {
// sequence of bytes to initialise with
const initSeq = [
this.DISPLAY_OFF,
this.SET_DISPLAY_CLOCK_DIV,
0x80,
this.SET_MULTIPLEX,
this.screenConfig.multiplex, // set the last value dynamically based on screen size requirement
this.SET_DISPLAY_OFFSET,
0x00, // sets offset pro to 0
this.SET_START_LINE,
this.CHARGE_PUMP,
0x14, // charge pump val
this.MEMORY_MODE,
0x00, // 0x0 act like ks0108
this.SEG_REMAP, // screen orientation
this.COM_SCAN_DEC, // screen orientation change to INC to flip
this.SET_COM_PINS,
this.screenConfig.compins, // com pins val sets dynamically to match each screen size requirement
this.SET_CONTRAST,
0x8f, // contrast val
this.SET_PRECHARGE,
0xf1, // precharge val
this.SET_VCOM_DETECT,
0x40, // vcom detect
this.DISPLAY_ALL_ON_RESUME,
this.NORMAL_DISPLAY,
this.DISPLAY_ON,
];
// write init seq commands
for (let i = 0; i < initSeq.length; i++) {
await this._transfer('cmd', initSeq[i]);
}
};
// 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);
};
// 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();
};
// 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 in order to send to framebuffer
_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;
};
// looks at dirty bytes, and sends the updated bytes to the display
_updateDirtyBytes = async (dirtyByteArray) => {
const dirtyByteArrayLen = dirtyByteArray.length;
// check to see if this will even save time
if (dirtyByteArrayLen > this.buffer.length / 7) {
// just call regular update at this stage, saves on bytes sent
await this.update();
// now that all bytes are synced, reset dirty state
this.dirtyBytes = [];
} else {
await this._waitUntilReady();
// iterate through dirty bytes
for (let i = 0; i < dirtyByteArrayLen; i += 1) {
const dirtyByteIndex = dirtyByteArray[i];
const page = Math.floor(dirtyByteIndex / this.WIDTH);
const col = Math.floor(dirtyByteIndex % this.WIDTH);
const displaySeq = [
this.COLUMN_ADDR,
col,
col, // column start and end address
this.PAGE_ADDR,
page,
page, // page start and end address
];
// send intro seq
for (let v = 0; v < displaySeq.length; v += 1) {
await this._transfer('cmd', displaySeq[v]);
}
// send byte, then move on to next byte
await this._transfer('data', this.buffer[dirtyByteIndex]);
}
// now that all bytes are synced, reset dirty state
this.dirtyBytes = [];
}
};
}
export default SSD1306;