/** * i2c-lcd-coupling-gap.test.ts * * Faithful reproduction of the "LCD I2C is currently unavailable" * complaint reported on Discord. * * What works today * ---------------- * VirtualPCF8574 is registered as a part and at the bus level * correctly receives every byte the Arduino's Wire library * writes to the I2C backpack address (default 0x27). Its * outputLatch holds the last byte, and its onWrite callback * fires per byte. protocol-parts and virtual-i2c-devices tests * both confirm this. * * What does NOT work * ------------------ * The PCF8574 backpack carries an HD44780-style 4-bit interface * to a separate LCD1602 (or LCD2004) character module. Every * byte the Arduino writes is a control bundle: RS, RW, EN, BL * in the low 4 bits and a 4-bit data nibble in the high 4 bits. * Two writes per character: one with EN=1 latches the high * nibble, then a follow-up with EN=0 settles it; same for the * low nibble. * * Velxio's wokwi-lcd1602 element accepts characters through its * own DOM property (`characters`) — it is NOT driven by the * PCF8574 device. There is no decoder in the codebase that * watches PCF8574.onWrite, reassembles bytes into HD44780 * commands, and updates the LCD element accordingly. The two * components are visually independent. * * This test reproduces the gap end-to-end using the REAL * VirtualPCF8574 and the REAL I2CBusManager. It drives the * same byte sequence that LiquidCrystal_I2C(addr, 16, 2) * followed by lcd.print("Hi") would emit on the wire. * * It then checks two things: * * 1. The PCF8574 receives the bytes (this should pass — the * I2C path is fine). * 2. A mock wokwi-lcd1602 element wired alongside should end * up with the characters "Hi" rendered at row 0. This * currently FAILS because nothing couples the PCF8574 to * the LCD element. * * The fix is to either: * (a) ship a `pcf8574-lcd` virtual coupler part that, on * PCF8574.onWrite, runs an HD44780 4-bit decoder and * writes into the connected LCD element's characters * array, OR * (b) ship a `wokwi-lcd-i2c` web component / metadata entry * that bundles the PCF8574 + LCD1602 into one part. */ import { describe, it, expect, vi } from 'vitest'; import { I2CBusManager, VirtualPCF8574, } from '../simulation/I2CBusManager'; import { HD44780Decoder } from '../simulation/HD44780Decoder'; import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry'; import '../simulation/parts/ProtocolParts'; // Minimal mock of AVRTWI shaped exactly as I2CBusManager calls it. // We don't need a real CPU for this test — the bug is in what // happens AFTER bytes reach the bus. function makeTWI() { return { set eventHandler(_handler: unknown) { /* installed by I2CBusManager */ }, completeStart() {}, completeStop() {}, completeConnect(_ack: boolean) {}, completeWrite(_ack: boolean) {}, completeRead(_value: number) {}, }; } // ── HD44780-on-PCF8574 wire format helpers ────────────────────────────────── // // PCF8574 byte layout used by LiquidCrystal_I2C (Frank de Brabander): // // bit 7 6 5 4 | 3 2 1 0 // D7..D4 | BL EN RW RS // // where D7..D4 is the high or low data nibble of the byte to // send to the HD44780. EN must pulse high then low for the // LCD to latch each nibble. const RS_DATA = 0x01; const RW_WRITE = 0x00; const EN_HIGH = 0x04; const BL_ON = 0x08; function encodeNibble(nibble: number, rs: number): number { return ((nibble & 0x0f) << 4) | BL_ON | EN_HIGH | RW_WRITE | rs; } function settleNibble(nibble: number, rs: number): number { return ((nibble & 0x0f) << 4) | BL_ON | RW_WRITE | rs; } /** Emit the 4-byte sequence a single character takes on the wire. */ function emitChar(bus: I2CBusManager, addr: number, c: number) { const high = (c >> 4) & 0x0f; const low = c & 0x0f; bus.connectToSlave(addr, true); bus.writeByte(encodeNibble(high, RS_DATA)); bus.writeByte(settleNibble(high, RS_DATA)); bus.writeByte(encodeNibble(low, RS_DATA)); bus.writeByte(settleNibble(low, RS_DATA)); bus.stop(); } // ── Tests ─────────────────────────────────────────────────────────────────── describe('I2C bug — LCD-I2C coupling gap (Discord: "i2c lcd is unavailable")', () => { it('PCF8574 backpack receives every byte the LCD library writes (sanity)', () => { const twi = makeTWI(); const bus = new I2CBusManager(twi as any); const pcf = new VirtualPCF8574(0x27); const seen: number[] = []; pcf.onWrite = (v) => seen.push(v); bus.addDevice(pcf); emitChar(bus, 0x27, 'H'.charCodeAt(0)); emitChar(bus, 0x27, 'i'.charCodeAt(0)); // 4 bytes per char × 2 chars expect(seen.length).toBe(8); // The last byte should be the low-nibble settle for 'i' // (0x69 → low = 0x9 → settle byte = 0x90 | 0x08 (BL) | 0x01 (RS) // = 0x99). expect(seen[seen.length - 1]).toBe(0x99); }); it('FIXED: HD44780 decoder turns PCF8574 byte stream into character writes', () => { // The fix: HD44780Decoder consumes the same byte stream and // exposes `characters` to the LCD element. const twi = makeTWI(); const bus = new I2CBusManager(twi as any); const pcf = new VirtualPCF8574(0x27); const decoder = new HD44780Decoder({ cols: 16, rows: 2 }); pcf.onWrite = (v) => decoder.feedPCF8574Byte(v); bus.addDevice(pcf); // Before printing characters the sketch issues a Set DDRAM // Address command (0x80 | row*0x40 | col) to position the // cursor at row 0, col 0. LiquidCrystal_I2C does this // before every print. const sendCmd = (cmd: number) => { const high = (cmd >> 4) & 0x0f; const low = cmd & 0x0f; bus.connectToSlave(0x27, true); // High nibble — RS=0 (command), EN pulse bus.writeByte((high << 4) | 0x08 | 0x04); // EN=1 bus.writeByte((high << 4) | 0x08); // EN=0 (latch) bus.writeByte((low << 4) | 0x08 | 0x04); bus.writeByte((low << 4) | 0x08); bus.stop(); }; sendCmd(0x80); // Set DDRAM address = 0 (row 0, col 0) emitChar(bus, 0x27, 'H'.charCodeAt(0)); emitChar(bus, 0x27, 'i'.charCodeAt(0)); const snap = decoder.snapshot(); expect(snap.characters[0]).toBe('H'.charCodeAt(0)); expect(snap.characters[1]).toBe('i'.charCodeAt(0)); }); it('FIXED: `lcd1602-i2c` part is registered and wires everything up', () => { expect(PartSimulationRegistry.get('lcd1602-i2c')).toBeDefined(); expect(PartSimulationRegistry.get('lcd2004-i2c')).toBeDefined(); }); it('FIXED: end-to-end — lcd1602-i2c part renders text on the underlying element', () => { // Build a mock wokwi-lcd1602-shaped element and a minimal // sim that exposes addI2CDevice — exactly the contract the // part attaches against. const el: any = { pins: 'full', characters: new Uint8Array(32), backlight: true, i2cAddress: '0x27', addEventListener: vi.fn(), removeEventListener: vi.fn(), }; const twi = makeTWI(); const bus = new I2CBusManager(twi as any); const sim: any = { addI2CDevice: (d: any) => bus.addDevice(d), removeI2CDevice: (a: number) => bus.removeDevice(a), i2cBus: bus, pinManager: { onPinChange: () => () => {} }, }; const detach = PartSimulationRegistry.get('lcd1602-i2c')!.attachEvents!( el as HTMLElement, sim, () => null, 'lcd-1', ); // The part should have forced I2C pin mode and registered // a virtual PCF8574 device at 0x27. expect(el.pins).toBe('i2c'); // Now emit a typical LiquidCrystal_I2C "Hello" sequence: // Set DDRAM addr 0x00, then write 'H' 'e' 'l' 'l' 'o'. const sendCmd = (cmd: number) => { const h = (cmd >> 4) & 0x0f; const l = cmd & 0x0f; bus.connectToSlave(0x27, true); bus.writeByte((h << 4) | 0x0c); bus.writeByte((h << 4) | 0x08); bus.writeByte((l << 4) | 0x0c); bus.writeByte((l << 4) | 0x08); bus.stop(); }; sendCmd(0x80); for (const ch of 'Hello') emitChar(bus, 0x27, ch.charCodeAt(0)); // The element's `characters` should now hold "Hello" in the // first 5 slots. const chars = Array.from(el.characters as Uint8Array); expect(chars[0]).toBe('H'.charCodeAt(0)); expect(chars[1]).toBe('e'.charCodeAt(0)); expect(chars[2]).toBe('l'.charCodeAt(0)); expect(chars[3]).toBe('l'.charCodeAt(0)); expect(chars[4]).toBe('o'.charCodeAt(0)); detach(); }); it('FIXED: clear command wipes the display and resets cursor', () => { const twi = makeTWI(); const bus = new I2CBusManager(twi as any); const pcf = new VirtualPCF8574(0x27); const decoder = new HD44780Decoder({ cols: 16, rows: 2 }); pcf.onWrite = (v) => decoder.feedPCF8574Byte(v); bus.addDevice(pcf); // Write some content first. const sendCmd = (cmd: number) => { const h = (cmd >> 4) & 0x0f; const l = cmd & 0x0f; bus.connectToSlave(0x27, true); bus.writeByte((h << 4) | 0x0c); bus.writeByte((h << 4) | 0x08); bus.writeByte((l << 4) | 0x0c); bus.writeByte((l << 4) | 0x08); bus.stop(); }; sendCmd(0x80); emitChar(bus, 0x27, 0x41); // 'A' expect(decoder.snapshot().characters[0]).toBe(0x41); // Issue Clear Display (0x01). sendCmd(0x01); const snap = decoder.snapshot(); // All 32 cells should be spaces (0x20). expect(snap.characters.every((c) => c === 0x20)).toBe(true); expect(snap.cursorAddress).toBe(0); }); it('FIXED: row 1 maps to DDRAM 0x40-0x4F', () => { const twi = makeTWI(); const bus = new I2CBusManager(twi as any); const pcf = new VirtualPCF8574(0x27); const decoder = new HD44780Decoder({ cols: 16, rows: 2 }); pcf.onWrite = (v) => decoder.feedPCF8574Byte(v); bus.addDevice(pcf); const sendCmd = (cmd: number) => { const h = (cmd >> 4) & 0x0f; const l = cmd & 0x0f; bus.connectToSlave(0x27, true); bus.writeByte((h << 4) | 0x0c); bus.writeByte((h << 4) | 0x08); bus.writeByte((l << 4) | 0x0c); bus.writeByte((l << 4) | 0x08); bus.stop(); }; sendCmd(0xc0); // Set DDRAM addr 0x40 = row 1, col 0 emitChar(bus, 0x27, 'X'.charCodeAt(0)); const snap = decoder.snapshot(); // Index 16 = row 1 col 0 in the flat 16x2 grid. expect(snap.characters[16]).toBe('X'.charCodeAt(0)); }); });