velxio/frontend/src/simulation/HD44780Decoder.ts

342 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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());
}
}