/** * I2C Bus Manager — virtual I2C devices that attach to avr8js AVRTWI * * Each device registers at a 7-bit I2C address. When the Arduino sketch * does Wire.beginTransmission(addr) / Wire.requestFrom(addr, ...), the * TWI event handler routes events to the matching virtual device. */ 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; } // ── I2C Bus Manager (TWIEventHandler for avr8js) ─────────────────────────── export class I2CBusManager implements TWIEventHandler { private devices: Map = new Map(); private activeDevice: I2CDevice | null = null; private writeMode = true; constructor(private twi: AVRTWI) { twi.eventHandler = this; } /** 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); } // ── TWIEventHandler implementation ────────────────────────────────────── start(_repeated: boolean): void { this.twi.completeStart(); } stop(): void { if (this.activeDevice?.stop) this.activeDevice.stop(); this.activeDevice = null; this.twi.completeStop(); } connectToSlave(addr: number, write: boolean): void { const device = this.devices.get(addr); if (device) { this.activeDevice = device; this.writeMode = write; this.twi.completeConnect(true); // ACK } else { this.activeDevice = null; this.twi.completeConnect(false); // NACK — no such address } } writeByte(value: number): void { if (this.activeDevice) { const ack = this.activeDevice.writeByte(value); this.twi.completeWrite(ack); } else { this.twi.completeWrite(false); } } readByte(_ack: boolean): void { if (this.activeDevice) { const value = this.activeDevice.readByte(); this.twi.completeRead(value); } else { this.twi.completeRead(0xff); } } } // ── 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; } } /** * 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); } 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 = 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; } // ── 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] = 0x60; // chip_id (BMP280) 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; } } /** * 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 } }