297 lines
10 KiB
TypeScript
297 lines
10 KiB
TypeScript
|
|
/**
|
|||
|
|
* 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));
|
|||
|
|
});
|
|||
|
|
});
|