342 lines
12 KiB
TypeScript
342 lines
12 KiB
TypeScript
/**
|
||
* HD44780Decoder.ts
|
||
*
|
||
* Decodes the bytes that a PCF8574-style I2C backpack writes when an
|
||
* Arduino sketch uses the LiquidCrystal_I2C / hd44780 libraries to
|
||
* drive an HD44780-compatible character LCD.
|
||
*
|
||
* Wire format (LiquidCrystal_I2C / Frank de Brabander convention)
|
||
* ----------------------------------------------------------------
|
||
* Each byte the Arduino writes to the PCF8574 is laid out as:
|
||
*
|
||
* bit 7 6 5 4 | bit 3 2 1 0
|
||
* D7..D4 | BL EN RW RS
|
||
*
|
||
* The HD44780 latches one 4-bit nibble on every falling edge of EN.
|
||
* To send one HD44780 command/data byte the library emits two pairs:
|
||
*
|
||
* write A: high-nibble | RS | RW | BL | EN=1
|
||
* write B: high-nibble | RS | RW | BL | EN=0 ← latch
|
||
* write C: low-nibble | RS | RW | BL | EN=1
|
||
* write D: low-nibble | RS | RW | BL | EN=0 ← latch
|
||
*
|
||
* After both nibbles have been latched the decoder reassembles them
|
||
* into the original 8-bit HD44780 byte and runs it through the
|
||
* controller's command/data interpreter.
|
||
*
|
||
* HD44780 controller subset
|
||
* -------------------------
|
||
* Implemented commands (the ones the Arduino LiquidCrystal_I2C
|
||
* library actually emits during normal operation):
|
||
*
|
||
* 0x01 Clear display (fill DDRAM with 0x20, address := 0)
|
||
* 0x02 / 0x03 Return home (address := 0)
|
||
* 0x04 - 0x07 Entry mode set (I/D, S)
|
||
* 0x08 - 0x0F Display ON/OFF control (D, C, B)
|
||
* 0x10 - 0x1F Cursor / display shift (recognised, no-op visual)
|
||
* 0x20 - 0x3F Function set (N=line count latched, F/DL noted)
|
||
* 0x40 - 0x7F Set CGRAM address
|
||
* 0x80 - 0xFF Set DDRAM address
|
||
*
|
||
* Data writes (RS=1) deposit into DDRAM or CGRAM at the current
|
||
* address and advance the pointer per the entry-mode I/D flag.
|
||
*
|
||
* The decoder produces a flat `characters` array sized
|
||
* (cols × rows) that matches the layout `wokwi-lcd1602` /
|
||
* `wokwi-lcd2004` expect on their `characters` property — so a part
|
||
* can wire `onCharsChange = (c) => element.characters = c` directly.
|
||
*
|
||
* What the decoder is NOT
|
||
* -----------------------
|
||
* - Pixel-accurate: it does not render dot-matrix bitmaps; it just
|
||
* tracks the character codes the controller has at each cell.
|
||
* The downstream LCD web component owns rendering.
|
||
* - Timing-accurate: HD44780 commands have execution times (37 µs
|
||
* for most, 1.52 ms for clear/home). The decoder applies state
|
||
* transitions synchronously; sketches that respect the typical
|
||
* library delays will see the right state.
|
||
* - Cursor-shift accurate: the optional 0x10..0x1F shift command
|
||
* moves cursor/display. We acknowledge the command but do not
|
||
* slide DDRAM contents — the visible state still reflects the
|
||
* true character contents at DDRAM addresses 0..N-1.
|
||
*/
|
||
|
||
export interface HD44780Geometry {
|
||
cols: number;
|
||
rows: number;
|
||
/**
|
||
* Mapping from display row index → starting DDRAM address for that
|
||
* row. Defaults match standard 1602 / 2004 layouts:
|
||
* 1602: [0x00, 0x40]
|
||
* 2004: [0x00, 0x40, 0x14, 0x54]
|
||
*/
|
||
rowOffsets?: number[];
|
||
}
|
||
|
||
export interface HD44780Snapshot {
|
||
characters: number[];
|
||
cursorAddress: number;
|
||
cursorRow: number;
|
||
cursorCol: number;
|
||
displayOn: boolean;
|
||
cursorOn: boolean;
|
||
cursorBlink: boolean;
|
||
backlight: boolean;
|
||
}
|
||
|
||
const DEFAULT_ROW_OFFSETS_1602 = [0x00, 0x40];
|
||
const DEFAULT_ROW_OFFSETS_2004 = [0x00, 0x40, 0x14, 0x54];
|
||
|
||
export class HD44780Decoder {
|
||
// ── Public events ──────────────────────────────────────────────────────
|
||
/** Fired after every DDRAM write or clear; receives the full flat character grid. */
|
||
public onCharsChange: ((chars: number[]) => void) | null = null;
|
||
/** Fired when the BL bit of an incoming PCF8574 byte changes. */
|
||
public onBacklightChange: ((on: boolean) => void) | null = null;
|
||
/** Fired on any cursor/display config change so the host can re-render. */
|
||
public onCursorChange: ((s: HD44780Snapshot) => void) | null = null;
|
||
|
||
// ── Geometry ───────────────────────────────────────────────────────────
|
||
private readonly cols: number;
|
||
private readonly rows: number;
|
||
private readonly rowOffsets: number[];
|
||
|
||
// ── Controller state ──────────────────────────────────────────────────
|
||
private readonly ddram = new Uint8Array(128); // upper bound; covers 2004
|
||
private readonly cgram = new Uint8Array(64);
|
||
private address = 0;
|
||
private cgramMode = false;
|
||
|
||
private displayOn = false;
|
||
private cursorOn = false;
|
||
private cursorBlink = false;
|
||
private cursorInc = true; // I/D bit — true = address increments after each write
|
||
private shiftOnEntry = false;
|
||
private twoLine = false;
|
||
|
||
// ── 4-bit assembly state ──────────────────────────────────────────────
|
||
private highNibble: number | null = null;
|
||
/** Last RS bit observed when the high nibble landed; both nibbles must share it. */
|
||
private highRs = false;
|
||
private lastEN = false;
|
||
private backlight = true;
|
||
|
||
/**
|
||
* Number of nibbles seen since power-on. HD44780 init sequence
|
||
* sends three 0x3 nibbles in 8-bit mode (LCD reset path) before
|
||
* switching to 4-bit, so the first few "lone" nibbles are normal
|
||
* and must not assemble into a bogus byte. We require strict
|
||
* pairing only AFTER the controller is observed to be in 4-bit
|
||
* mode (Function set with DL=0).
|
||
*/
|
||
private fourBitMode = false;
|
||
|
||
constructor(geometry: HD44780Geometry) {
|
||
this.cols = geometry.cols;
|
||
this.rows = geometry.rows;
|
||
if (geometry.rowOffsets) {
|
||
this.rowOffsets = [...geometry.rowOffsets];
|
||
} else if (this.rows === 4) {
|
||
this.rowOffsets = DEFAULT_ROW_OFFSETS_2004;
|
||
} else {
|
||
this.rowOffsets = DEFAULT_ROW_OFFSETS_1602;
|
||
}
|
||
// Initialise DDRAM as spaces so the LCD shows a clean screen
|
||
// even before the sketch issues an explicit Clear.
|
||
this.ddram.fill(0x20);
|
||
}
|
||
|
||
// ── Public entry points ────────────────────────────────────────────────
|
||
|
||
/**
|
||
* Feed one PCF8574 backpack byte (LiquidCrystal_I2C wire format).
|
||
* Latches a nibble on the EN falling edge.
|
||
*/
|
||
feedPCF8574Byte(byte: number): void {
|
||
const newBacklight = (byte & 0x08) !== 0;
|
||
if (newBacklight !== this.backlight) {
|
||
this.backlight = newBacklight;
|
||
this.onBacklightChange?.(newBacklight);
|
||
}
|
||
|
||
const en = (byte & 0x04) !== 0;
|
||
const rs = (byte & 0x01) !== 0;
|
||
const nibble = (byte >> 4) & 0x0f;
|
||
|
||
// HD44780 latches data on the falling edge of EN.
|
||
if (this.lastEN && !en) {
|
||
this.feedNibble(nibble, rs);
|
||
}
|
||
this.lastEN = en;
|
||
}
|
||
|
||
/** Reset all decoder state — call when the part detaches. */
|
||
reset(): void {
|
||
this.ddram.fill(0x20);
|
||
this.cgram.fill(0);
|
||
this.address = 0;
|
||
this.cgramMode = false;
|
||
this.displayOn = false;
|
||
this.cursorOn = false;
|
||
this.cursorBlink = false;
|
||
this.cursorInc = true;
|
||
this.shiftOnEntry = false;
|
||
this.twoLine = false;
|
||
this.highNibble = null;
|
||
this.highRs = false;
|
||
this.lastEN = false;
|
||
this.backlight = true;
|
||
this.fourBitMode = false;
|
||
}
|
||
|
||
/** Snapshot the current visible state — useful for tests and UI overlays. */
|
||
snapshot(): HD44780Snapshot {
|
||
const { row, col } = this.addressToRowCol(this.address);
|
||
return {
|
||
characters: this.buildCharacterGrid(),
|
||
cursorAddress: this.address,
|
||
cursorRow: row,
|
||
cursorCol: col,
|
||
displayOn: this.displayOn,
|
||
cursorOn: this.cursorOn,
|
||
cursorBlink: this.cursorBlink,
|
||
backlight: this.backlight,
|
||
};
|
||
}
|
||
|
||
// ── Internal nibble + byte interpreter ────────────────────────────────
|
||
|
||
private feedNibble(nibble: number, rs: boolean): void {
|
||
if (this.highNibble === null) {
|
||
this.highNibble = nibble;
|
||
this.highRs = rs;
|
||
return;
|
||
}
|
||
// If RS changed between the two nibbles we are out of sync (the
|
||
// controller would treat the second nibble as a new command or
|
||
// data write). Re-anchor on the new nibble.
|
||
if (rs !== this.highRs) {
|
||
this.highNibble = nibble;
|
||
this.highRs = rs;
|
||
return;
|
||
}
|
||
const full = (this.highNibble << 4) | nibble;
|
||
this.highNibble = null;
|
||
this.processByte(full, rs);
|
||
}
|
||
|
||
private processByte(byte: number, rs: boolean): void {
|
||
if (rs) {
|
||
this.writeData(byte);
|
||
return;
|
||
}
|
||
this.writeCommand(byte);
|
||
}
|
||
|
||
private writeData(byte: number): void {
|
||
if (this.cgramMode) {
|
||
this.cgram[this.address & 0x3f] = byte;
|
||
this.advanceAddress(64);
|
||
} else {
|
||
this.ddram[this.address & 0x7f] = byte;
|
||
this.advanceAddress(128);
|
||
this.emitChars();
|
||
}
|
||
}
|
||
|
||
private writeCommand(byte: number): void {
|
||
if (byte === 0x01) {
|
||
this.ddram.fill(0x20);
|
||
this.address = 0;
|
||
this.cgramMode = false;
|
||
this.emitChars();
|
||
this.emitCursor();
|
||
return;
|
||
}
|
||
if ((byte & 0xfe) === 0x02) {
|
||
this.address = 0;
|
||
this.cgramMode = false;
|
||
this.emitCursor();
|
||
return;
|
||
}
|
||
if ((byte & 0xfc) === 0x04) {
|
||
this.cursorInc = (byte & 0x02) !== 0;
|
||
this.shiftOnEntry = (byte & 0x01) !== 0;
|
||
return;
|
||
}
|
||
if ((byte & 0xf8) === 0x08) {
|
||
this.displayOn = (byte & 0x04) !== 0;
|
||
this.cursorOn = (byte & 0x02) !== 0;
|
||
this.cursorBlink = (byte & 0x01) !== 0;
|
||
this.emitCursor();
|
||
return;
|
||
}
|
||
if ((byte & 0xf0) === 0x10) {
|
||
// Cursor / display shift — recognised but visual effect is a no-op
|
||
// because we don't slide DDRAM contents.
|
||
return;
|
||
}
|
||
if ((byte & 0xe0) === 0x20) {
|
||
// Function set: DL (bit 4), N (bit 3), F (bit 2)
|
||
const dl8bit = (byte & 0x10) !== 0;
|
||
this.fourBitMode = !dl8bit;
|
||
this.twoLine = (byte & 0x08) !== 0;
|
||
return;
|
||
}
|
||
if ((byte & 0xc0) === 0x40) {
|
||
this.address = byte & 0x3f;
|
||
this.cgramMode = true;
|
||
return;
|
||
}
|
||
if ((byte & 0x80) === 0x80) {
|
||
this.address = byte & 0x7f;
|
||
this.cgramMode = false;
|
||
this.emitCursor();
|
||
return;
|
||
}
|
||
// Unknown command — silently ignored (consistent with how a real
|
||
// HD44780 treats reserved bits: undefined behaviour, not a crash).
|
||
}
|
||
|
||
private advanceAddress(modulo: number): void {
|
||
if (this.cursorInc) {
|
||
this.address = (this.address + 1) % modulo;
|
||
} else {
|
||
this.address = (this.address - 1 + modulo) % modulo;
|
||
}
|
||
}
|
||
|
||
// ── Output helpers ────────────────────────────────────────────────────
|
||
|
||
private buildCharacterGrid(): number[] {
|
||
const out = new Array<number>(this.cols * this.rows).fill(0x20);
|
||
for (let r = 0; r < this.rows; r++) {
|
||
const base = this.rowOffsets[r] ?? 0;
|
||
for (let c = 0; c < this.cols; c++) {
|
||
const addr = (base + c) & 0x7f;
|
||
out[r * this.cols + c] = this.ddram[addr];
|
||
}
|
||
}
|
||
return out;
|
||
}
|
||
|
||
private addressToRowCol(addr: number): { row: number; col: number } {
|
||
// Walk the row offset table to find which row this address belongs to.
|
||
for (let r = 0; r < this.rows; r++) {
|
||
const base = this.rowOffsets[r] ?? 0;
|
||
if (addr >= base && addr < base + this.cols) {
|
||
return { row: r, col: addr - base };
|
||
}
|
||
}
|
||
return { row: 0, col: 0 };
|
||
}
|
||
|
||
private emitChars(): void {
|
||
this.onCharsChange?.(this.buildCharacterGrid());
|
||
}
|
||
|
||
private emitCursor(): void {
|
||
this.onCursorChange?.(this.snapshot());
|
||
}
|
||
}
|