/** * I2C Bus Manager — virtual I2C bus shared between an MCU peripheral * (avr8js AVRTWI or rp2040js RPI2C) and a set of JavaScript virtual * devices, with optional cross-board bridging for multi-board sims. * * Each device registers at a 7-bit I2C address. When the Arduino sketch * does Wire.beginTransmission(addr) / Wire.requestFrom(addr, ...), the * MCU peripheral's event handler routes events to the matching virtual * device on the LOCAL bus, OR — if a bridge to another board's bus is * installed and the address is registered THERE — to the remote device. * * Bridges are installed by Interconnect when both SDA and SCL of two * boards are wired together. This lets two physical-style boards * exchange I2C transactions without requiring slave-mode emulation * inside avr8js / rp2040js (neither library supports it natively). */ import type { AVRTWI, TWIEventHandler } from 'avr8js'; // ── Virtual I2C device interface ──────────────────────────────────────────── export interface I2CDevice { /** 7-bit I2C address (e.g. 0x27 for PCF8574 LCD backpack, 0x3C for SSD1306) */ address: number; /** Called when master sends a byte after addressing this device for write */ writeByte(value: number): boolean; // return true for ACK /** Called when master requests a byte from this device (read mode) */ readByte(): number; /** Optional: called on STOP condition */ stop?(): void; /** * Optional snapshot of the device's 256-byte register state. Used by * the cross-board I2C proxy path (Interconnect → Esp32Bridge) to mirror * the device into a backend `ProxySlave` so ESP32 firmware running in * QEMU can read it synchronously. Devices that don't have a register * map (write-only sinks, time-based responders) can omit this. */ dumpRegisters?(): Uint8Array; } /** * Minimal contract the I2C bus needs from the MCU peripheral that is * driving it as master. Both avr8js AVRTWI and rp2040js RPI2C * implement this shape verbatim. */ export interface I2CMaster { completeStart(): void; completeStop(): void; completeConnect(ack: boolean): void; completeWrite(ack: boolean): void; completeRead(value: number): void; } // ── I2C Bus Manager (implements TWIEventHandler for avr8js) ──────────────── export class I2CBusManager implements TWIEventHandler { private devices: Map = new Map(); private activeDevice: I2CDevice | null = null; private writeMode = true; /** Peer buses that this bus can forward transactions to when the requested address is not local. */ private bridges: I2CBusManager[] = []; /** When the master-side transaction was routed to a peer, this holds it. */ private activeExternal: I2CBusManager | null = null; /** When this bus is acting as the target of an external peer's master, this holds the addressed device. */ private externalActiveDevice: I2CDevice | null = null; /** * Construct a bus bound to an `I2CMaster`. For backward * compatibility, if the master has a settable `eventHandler` * property (the AVRTWI shape), it is wired to `this` automatically * so existing AVRSimulator code continues to work unchanged. For * peripherals with per-callback wiring (RPI2C), the caller is * responsible for routing each master event into the bus's methods. */ constructor(private master: I2CMaster) { this.bindEventHandler(master); } private bindEventHandler(master: I2CMaster): void { if ( master !== null && typeof master === 'object' && 'eventHandler' in (master as object) ) { try { (master as { eventHandler: TWIEventHandler }).eventHandler = this; } catch { /* setter rejected — caller will wire events manually */ } } } /** * Swap the master peripheral this bus drives. Used when the * I2CBusManager is constructed early (so cross-board bridges and * device registration can happen before firmware loads) and the * real MCU peripheral becomes available later (e.g. after loadHex). * Local devices and bridges are preserved. */ attachMaster(master: I2CMaster): void { this.master = master; this.bindEventHandler(master); } /** Backward-compat accessor for the underlying AVRTWI, when constructed from one. */ get twi(): AVRTWI { return this.master as AVRTWI; } /** Register a virtual I2C device on the bus */ addDevice(device: I2CDevice): void { this.devices.set(device.address, device); } /** Remove a device by address */ removeDevice(address: number): void { this.devices.delete(address); } /** * Snapshot of currently-registered local devices. Used by Interconnect * to enumerate which addresses to mirror as proxies on a bridged ESP32. */ listDevices(): I2CDevice[] { return Array.from(this.devices.values()); } /** * Read-only view of bridges currently attached to this bus. Used by * the cross-board proxy sync to walk transitive peers (BFS). Not * meant for mutation — call {@link attachBridge} / {@link detachBridge}. */ getBridges(): readonly I2CBusManager[] { return this.bridges; } // ── Cross-board bridging ──────────────────────────────────────────────── /** * Install a peer bus that this bus will forward unresolved master * transactions to. The pair is one-directional — to make traffic * flow in both directions, call attachBridge symmetrically on both * buses. Idempotent. */ attachBridge(peer: I2CBusManager): void { if (peer === this) return; if (!this.bridges.includes(peer)) this.bridges.push(peer); } /** Detach a previously-installed peer bus. */ detachBridge(peer: I2CBusManager): void { this.bridges = this.bridges.filter((b) => b !== peer); if (this.activeExternal === peer) this.activeExternal = null; } /** * Whether this bus is currently acting as a slave to an external * master. Exposed for diagnostics + tests. */ isHandlingExternal(): boolean { return this.externalActiveDevice !== null; } // ── TWIEventHandler implementation (master-side events from the local MCU) ── start(_repeated: boolean): void { this.master.completeStart(); } stop(): void { if (this.activeExternal) { this.activeExternal.handleExternalStop(); this.activeExternal = null; } else if (this.activeDevice?.stop) { this.activeDevice.stop(); } this.activeDevice = null; this.master.completeStop(); } connectToSlave(addr: number, write: boolean): void { // 1. Local devices win — fastest path and what single-board sketches expect. const local = this.devices.get(addr); if (local) { this.activeDevice = local; this.activeExternal = null; this.writeMode = write; this.master.completeConnect(true); return; } // 2. Walk the bridge graph (BFS) until a peer ACKs `addr`. The // visited Set starts with `this` so we don't bounce back into // ourselves through a peer that has us in its own bridge list. // Each peer recurses into its own bridges via // `handleExternalConnect`, also passing visited. const visited = new Set([this]); for (const bridge of this.bridges) { if (visited.has(bridge)) continue; if (bridge.handleExternalConnect(addr, write, visited)) { this.activeExternal = bridge; this.activeDevice = null; this.writeMode = write; this.master.completeConnect(true); return; } } // 3. NACK — no device anywhere in the topology knows this address. this.activeDevice = null; this.activeExternal = null; this.master.completeConnect(false); } writeByte(value: number): void { if (this.activeDevice) { this.master.completeWrite(this.activeDevice.writeByte(value)); } else if (this.activeExternal) { this.master.completeWrite(this.activeExternal.handleExternalWrite(value)); } else { this.master.completeWrite(false); } } readByte(_ack: boolean): void { if (this.activeDevice) { this.master.completeRead(this.activeDevice.readByte()); } else if (this.activeExternal) { this.master.completeRead(this.activeExternal.handleExternalRead()); } else { this.master.completeRead(0xff); } } // ── External-master inbound handlers (called by a bridged peer bus) ──── /** * Attempt to address `addr` on behalf of an external master. Returns * true when SOMEONE in the reachable bridge graph has a device at the * address, false otherwise (NACK). * * The `visited` Set tracks buses already consulted so we never loop * back through cycles in the bridge graph. When the device is on a * deeper hop (e.g. A→B→C with the device on C), this bus simply * delegates: it records the bridge that resolved the address as its * `externalActiveDevice`-proxy via a forwarding device shim, so the * subsequent write/read/stop calls walk the same chain. */ handleExternalConnect(addr: number, write: boolean, visited?: Set): boolean { const v = visited ?? new Set(); if (v.has(this)) return false; v.add(this); // First try local devices. const local = this.devices.get(addr); if (local) { this.externalActiveDevice = local; return true; } // Then recurse into peers (BFS). If one of them ACKs, install a // forwarder so this bus's read/write/stop calls delegate down the // chain transparently. for (const bridge of this.bridges) { if (v.has(bridge)) continue; if (bridge.handleExternalConnect(addr, write, v)) { this.externalActiveDevice = createForwarderDevice(addr, bridge); return true; } } return false; } /** External master is sending a byte to the previously-addressed device. */ handleExternalWrite(value: number): boolean { return this.externalActiveDevice?.writeByte(value) ?? false; } /** External master is requesting the next byte from the previously-addressed device. */ handleExternalRead(): number { return this.externalActiveDevice?.readByte() ?? 0xff; } /** External master issued STOP — release the active device and call its lifecycle hook. */ handleExternalStop(): void { this.externalActiveDevice?.stop?.(); this.externalActiveDevice = null; } } /** * A no-op `I2CMaster` used as a placeholder before the real MCU * peripheral has been constructed. Lets `I2CBusManager` be created * up-front so cross-board bridges and device registrations can land * before firmware loads, then swapped to the real peripheral via * `attachMaster()`. */ export function nullI2CMaster(): I2CMaster { return { completeStart() {}, completeStop() {}, completeConnect(_ack: boolean) {}, completeWrite(_ack: boolean) {}, completeRead(_value: number) {}, }; } /** * Internal: build a transparent `I2CDevice` shim that forwards every * write/read/stop down the bridge chain to a peer bus. Used by * `handleExternalConnect` when the requested address resolves to a * device living two or more hops away — the intermediate bus stores * one of these shims as its `externalActiveDevice` so the existing * `handleExternalWrite/Read/Stop` machinery routes through without * needing per-method visited tracking. */ function createForwarderDevice(addr: number, downstream: I2CBusManager): I2CDevice { return { address: addr, writeByte(value: number): boolean { return downstream.handleExternalWrite(value); }, readByte(): number { return downstream.handleExternalRead(); }, stop(): void { downstream.handleExternalStop(); }, }; } /** * Wire a non-AVR I2C master (e.g. rp2040js RPI2C) into an * `I2CBusManager`. Returns the bus, with the master peripheral's * `onStart` / `onConnect` / `onWriteByte` / `onReadByte` / `onStop` * callbacks routed to `bus.start` etc. Matches the per-callback * pattern RPI2C uses (it does not have a single `eventHandler`). */ export function wireRpI2cToBus( master: I2CMaster & { onStart?: () => void; onConnect?: (address: number, mode?: number) => void; onWriteByte?: (value: number) => void; onReadByte?: (ack?: boolean) => void; onStop?: () => void; }, bus: I2CBusManager, ): void { master.onStart = () => bus.start(false); master.onConnect = (addr: number, mode?: number) => bus.connectToSlave(addr, mode === undefined ? true : mode === 0); master.onWriteByte = (v: number) => bus.writeByte(v); master.onReadByte = (ack?: boolean) => bus.readByte(ack ?? true); master.onStop = () => bus.stop(); } // ── Built-in virtual I2C devices ─────────────────────────────────────────── /** * Generic I2C memory / register device. * Emulates a device with 256 byte registers. * First write byte = register address, subsequent bytes = data. * Reads return register contents sequentially. * * Used to test I2C communication without a specific device implementation. */ export class I2CMemoryDevice implements I2CDevice { public registers = new Uint8Array(256); private regPointer = 0; private firstByte = true; /** Callback fired whenever a register is written */ public onRegisterWrite: ((reg: number, value: number) => void) | null = null; constructor(public address: number) {} writeByte(value: number): boolean { if (this.firstByte) { this.regPointer = value; this.firstByte = false; } else { this.registers[this.regPointer] = value; if (this.onRegisterWrite) { this.onRegisterWrite(this.regPointer, value); } this.regPointer = (this.regPointer + 1) & 0xff; } return true; // ACK } readByte(): number { const value = this.registers[this.regPointer]; this.regPointer = (this.regPointer + 1) & 0xff; return value; } stop(): void { this.firstByte = true; } /** Return the full 256-byte register snapshot for cross-board proxying. */ dumpRegisters(): Uint8Array { return new Uint8Array(this.registers); } } /** * Virtual DS1307 RTC — returns system time via I2C (address 0x68). * Supports Wire.requestFrom(0x68, 7) to read seconds..year in BCD. */ export class VirtualDS1307 implements I2CDevice { public address = 0x68; private regPointer = 0; private firstByte = true; private toBCD(n: number): number { return ((Math.floor(n / 10) & 0xf) << 4) | ((n % 10) & 0xf); } /** Snapshot of the 7-byte time + 1-byte control register set. */ dumpRegisters(): Uint8Array { const buf = new Uint8Array(256); const now = new Date(); buf[0] = this.toBCD(now.getSeconds()); buf[1] = this.toBCD(now.getMinutes()); buf[2] = this.toBCD(now.getHours()); buf[3] = this.toBCD(now.getDay() + 1); buf[4] = this.toBCD(now.getDate()); buf[5] = this.toBCD(now.getMonth() + 1); buf[6] = this.toBCD(now.getFullYear() % 100); return buf; } writeByte(value: number): boolean { if (this.firstByte) { this.regPointer = value; this.firstByte = false; } return true; } readByte(): number { const now = new Date(); let val = 0; switch (this.regPointer) { case 0: val = this.toBCD(now.getSeconds()); break; // seconds case 1: val = this.toBCD(now.getMinutes()); break; // minutes case 2: val = this.toBCD(now.getHours()); break; // hours (24h) case 3: val = this.toBCD(now.getDay() + 1); break; // day of week (1=Sun) case 4: val = this.toBCD(now.getDate()); break; // date case 5: val = this.toBCD(now.getMonth() + 1); break; // month case 6: val = this.toBCD(now.getFullYear() % 100); break; // year default: val = 0; } this.regPointer = (this.regPointer + 1) & 0x3f; return val; } stop(): void { this.firstByte = true; } } /** * Virtual temperature / humidity sensor (address 0x48). * Returns fixed temperature (configurable) and humidity. */ export class VirtualTempSensor implements I2CDevice { public address = 0x48; private regPointer = 0; private firstByte = true; /** Temperature in degrees C * 100 (e.g. 2350 = 23.50 C) */ public temperature = 2350; /** Humidity in % * 100 */ public humidity = 5500; writeByte(value: number): boolean { if (this.firstByte) { this.regPointer = value; this.firstByte = false; } return true; } readByte(): number { let val = 0; // Register 0: temp high byte, 1: temp low byte, 2: humidity high, 3: humidity low switch (this.regPointer) { case 0: val = (this.temperature >> 8) & 0xff; break; case 1: val = this.temperature & 0xff; break; case 2: val = (this.humidity >> 8) & 0xff; break; case 3: val = this.humidity & 0xff; break; default: val = 0xff; } this.regPointer = (this.regPointer + 1) & 0xff; return val; } stop(): void { this.firstByte = true; } } /** * Virtual BMP280 barometric pressure / temperature sensor. * * Supports I2C addresses 0x76 (SDO=0) or 0x77 (SDO=1). * * Register map (subset): * 0x88–0x9F Calibration data (trimming parameters) * 0xD0 chip_id = 0x58 (BMP280 production; BME280 = 0x60) * 0xF3 status = 0x00 (measurement complete, no NVM copy) * 0xF4 ctrl_meas (mode, osrs_t, osrs_p) — writable * 0xF5 config — writable * 0xF7–0xF9 press_msb / press_lsb / press_xlsb (20-bit ADC) * 0xFA–0xFC temp_msb / temp_lsb / temp_xlsb (20-bit ADC) * * The calibration parameters are the BMP280 datasheet example values (Section 8.2). * They produce T ≈ 25°C, P ≈ 1006 hPa from the corresponding raw ADC values. * * Setting `temperature` (°C) and `pressure` (hPa) properties recomputes raw ADC * registers using a binary search over the Bosch compensation formulas so that * Arduino sketches using the Adafruit_BMP280 / Bosch driver get realistic values. */ export class VirtualBMP280 implements I2CDevice { public address: number; private readonly registers = new Uint8Array(256); private regPtr = 0; private firstByte = true; // ── BMP280 datasheet Section 8.2 example calibration ─────────────────── private readonly DIG_T1 = 27504; private readonly DIG_T2 = 26435; private readonly DIG_T3 = -1000; private readonly DIG_P1 = 36477; private readonly DIG_P2 = -10685; private readonly DIG_P3 = 3024; private readonly DIG_P4 = 2855; private readonly DIG_P5 = 140; private readonly DIG_P6 = -7; private readonly DIG_P7 = 15500; private readonly DIG_P8 = -14600; private readonly DIG_P9 = 6000; private _temperatureC = 25.0; private _pressureHPa = 1013.25; constructor(address = 0x76) { this.address = address; this.initCalibration(); this.updateMeasurements(); } // ── Public configurable properties ────────────────────────────────────── get temperatureC(): number { return this._temperatureC; } set temperatureC(v: number) { this._temperatureC = v; this.updateMeasurements(); } get pressureHPa(): number { return this._pressureHPa; } set pressureHPa(v: number) { this._pressureHPa = v; this.updateMeasurements(); } // ── I2CDevice interface ───────────────────────────────────────────────── writeByte(value: number): boolean { if (this.firstByte) { this.regPtr = value; this.firstByte = false; } else { // Writable registers (ctrl_meas, config) — store them this.registers[this.regPtr] = value; this.regPtr = (this.regPtr + 1) & 0xff; } return true; } readByte(): number { const val = this.registers[this.regPtr]; this.regPtr = (this.regPtr + 1) & 0xff; return val; } stop(): void { this.firstByte = true; } /** Snapshot the full 256-byte register file (calibration + ADC results). */ dumpRegisters(): Uint8Array { return new Uint8Array(this.registers); } // ── Compensation formulas (Bosch 32-bit integer + double precision) ──── /** Compute t_fine from a 20-bit raw temperature ADC value. */ private tFine(adcT: number): number { const var1 = (((adcT >> 3) - (this.DIG_T1 << 1)) * this.DIG_T2) >> 11; const sub = (adcT >> 4) - this.DIG_T1; const var2 = (((sub * sub) >> 12) * this.DIG_T3) >> 14; return var1 + var2; } /** Compute temperature in 0.01 °C from a 20-bit raw ADC value. */ private compensateT(adcT: number): number { return (this.tFine(adcT) * 5 + 128) >> 8; } /** * Compute pressure in Pa (double precision) from raw ADC values. * Uses the Bosch floating-point compensation formula. */ private compensateP(adcP: number, adcT: number): number { const tf = this.tFine(adcT); let var1 = tf / 2.0 - 64000.0; let var2 = (var1 * var1 * this.DIG_P6) / 32768.0; var2 = var2 + var1 * this.DIG_P5 * 2.0; var2 = var2 / 4.0 + this.DIG_P4 * 65536.0; var1 = ((this.DIG_P3 * var1 * var1) / 524288.0 + this.DIG_P2 * var1) / 524288.0; var1 = (1.0 + var1 / 32768.0) * this.DIG_P1; if (var1 === 0) return 0; let p = 1048576.0 - adcP; p = ((p - var2 / 4096.0) * 6250.0) / var1; const v1b = (this.DIG_P9 * p * p) / 2147483648.0; const v2b = (p * this.DIG_P8) / 32768.0; return p + (v1b + v2b + this.DIG_P7) / 16.0; } /** * Binary-search for the 20-bit raw ADC value that produces the target * temperature (in 0.01 °C units after integer compensation). */ private findAdcT(targetCentidegrees: number): number { let lo = 0, hi = (1 << 20) - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (this.compensateT(mid) < targetCentidegrees) lo = mid + 1; else hi = mid; } return lo; } /** * Binary-search for the 20-bit raw ADC value that produces the target * pressure (in Pa). Pressure is monotonically decreasing in adcP. */ private findAdcP(targetPa: number, adcT: number): number { let lo = 0, hi = (1 << 20) - 1; while (lo < hi) { const mid = (lo + hi) >> 1; if (this.compensateP(mid, adcT) > targetPa) lo = mid + 1; else hi = mid; } return lo; } /** Encode a 20-bit ADC value into three register bytes (msb, lsb, xlsb). */ private static encodeAdc20(val: number): [number, number, number] { return [(val >> 12) & 0xff, (val >> 4) & 0xff, (val & 0xf) << 4]; } // ── Register initialisation ───────────────────────────────────────────── private initCalibration(): void { const r = this.registers; const wu16 = (a: number, v: number) => { r[a] = v & 0xff; r[a + 1] = (v >> 8) & 0xff; }; const ws16 = (a: number, v: number) => wu16(a, v & 0xffff); r[0xd0] = 0x58; // chip_id BMP280 (production silicon; BME280 uses 0x60) r[0xf3] = 0x00; // status (measurement done) r[0xf4] = 0x00; // ctrl_meas default r[0xf5] = 0x00; // config default wu16(0x88, this.DIG_T1); ws16(0x8a, this.DIG_T2); ws16(0x8c, this.DIG_T3); wu16(0x8e, this.DIG_P1); ws16(0x90, this.DIG_P2); ws16(0x92, this.DIG_P3); ws16(0x94, this.DIG_P4); ws16(0x96, this.DIG_P5); ws16(0x98, this.DIG_P6); ws16(0x9a, this.DIG_P7); ws16(0x9c, this.DIG_P8); ws16(0x9e, this.DIG_P9); } /** Recompute raw ADC registers from current temperature / pressure. */ private updateMeasurements(): void { const targetT = Math.round(this._temperatureC * 100); const targetP = this._pressureHPa * 100; // hPa → Pa const adcT = this.findAdcT(targetT); const adcP = this.findAdcP(targetP, adcT); const [pMsb, pLsb, pXlsb] = VirtualBMP280.encodeAdc20(adcP); const [tMsb, tLsb, tXlsb] = VirtualBMP280.encodeAdc20(adcT); this.registers[0xf7] = pMsb; this.registers[0xf8] = pLsb; this.registers[0xf9] = pXlsb; this.registers[0xfa] = tMsb; this.registers[0xfb] = tLsb; this.registers[0xfc] = tXlsb; } } /** * Virtual DS3231 real-time clock with on-chip temperature sensor. * * Address: 0x68 (fixed — same package as DS1307, one or the other per bus). * * Register map (subset): * 0x00 Seconds (BCD, 0–59) * 0x01 Minutes (BCD, 0–59) * 0x02 Hours (BCD, 0–23, 24-hour mode) * 0x03 Day (BCD, 1–7, 1=Sunday) * 0x04 Date (BCD, 1–31) * 0x05 Month (BCD, 1–12) * 0x06 Year (BCD, 0–99) * 0x0E Control (writable) * 0x0F Status = 0x00 (OSF cleared, no alarms) * 0x11 Temp MSB = integer degrees C (signed) * 0x12 Temp LSB = fractional in bits 7:6 (0.25°C steps) * * Time is taken from the host browser clock. * Temperature defaults to 25°C and is configurable via `temperatureC`. */ export class VirtualDS3231 implements I2CDevice { public readonly address = 0x68; public temperatureC = 25.0; private regPtr = 0; private firstByte = true; private toBCD(n: number): number { return ((Math.floor(n / 10) & 0xf) << 4) | ((n % 10) & 0xf); } private readRegister(reg: number): number { const now = new Date(); switch (reg) { case 0x00: return this.toBCD(now.getSeconds()); case 0x01: return this.toBCD(now.getMinutes()); case 0x02: return this.toBCD(now.getHours()); case 0x03: return this.toBCD(now.getDay() + 1); // 1=Sunday case 0x04: return this.toBCD(now.getDate()); case 0x05: return this.toBCD(now.getMonth() + 1); case 0x06: return this.toBCD(now.getFullYear() % 100); case 0x0e: return 0x00; // Control: oscillator enabled, no alarm outputs case 0x0f: return 0x00; // Status: OSF=0 (no oscillator stop), alarms cleared case 0x11: { // Temperature MSB: signed integer degrees C const intTemp = Math.trunc(this.temperatureC); return intTemp & 0xff; } case 0x12: { // Temperature LSB: fractional in bits 7:6, 0.25°C resolution const frac = this.temperatureC - Math.trunc(this.temperatureC); const q = Math.round(frac / 0.25) & 0x03; return (q << 6) & 0xff; } default: return 0x00; } } writeByte(value: number): boolean { if (this.firstByte) { this.regPtr = value; this.firstByte = false; } else { // Accept writes to control registers (0x0E, 0x0F, alarm registers, etc.) // We simply ignore the written value since this is a read-only time source. this.regPtr = (this.regPtr + 1) & 0x1f; } return true; } readByte(): number { const val = this.readRegister(this.regPtr); this.regPtr = (this.regPtr + 1) & 0x1f; return val; } stop(): void { this.firstByte = true; } /** Snapshot the current register state (time + temperature). */ dumpRegisters(): Uint8Array { const buf = new Uint8Array(256); for (let r = 0; r < 0x20; r++) buf[r] = this.readRegister(r); return buf; } } /** * Virtual PCF8574 8-bit I/O expander. * * The PCF8574 exposes a single 8-bit quasi-bidirectional I/O port over I2C. * - Writing one byte sets the output latch (pins driven LOW for 0, HIGH/HiZ for 1). * - Reading one byte returns the current pin state (output latch AND-ed with external input). * * This is the most common I2C interface for 4-bit LCD backpacks. * * Configurable address: 0x20–0x27 (PCF8574) or 0x38–0x3F (PCF8574A). * Default: 0x27 (all address pins HIGH, typical for LCD backpacks). * * `portState` holds the current 8-bit port value read back by the Arduino. * Sketch writes update `outputLatch`; reads return `portState & outputLatch` (open-drain). */ export class VirtualPCF8574 implements I2CDevice { public address: number; /** Current state of the 8 I/O pins as seen from the outside (external input). */ public portState = 0xff; /** Output latch: bits the Arduino last wrote. 1 = released (input/Hi-Z), 0 = driven LOW. */ public outputLatch = 0xff; /** Optional callback when the Arduino writes to the port (e.g. to update an LCD visual). */ public onWrite: ((value: number) => void) | null = null; constructor(address = 0x27) { this.address = address; } writeByte(value: number): boolean { this.outputLatch = value; if (this.onWrite) this.onWrite(value); return true; } readByte(): number { // Open-drain: pin reads HIGH only when both outputLatch and portState are HIGH return this.portState & this.outputLatch & 0xff; } stop(): void { // PCF8574 is stateless (no register pointer) — explicit no-op for interface clarity } }