velxio/frontend/src/__tests__/i2c-lcd-coupling-gap.test.ts

297 lines
10 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.

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