From 4de45ecb0adfd06c5a08631c24bcef5b8d0f9c98 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 11 Mar 2026 15:14:18 +0000 Subject: [PATCH 2/2] feat: add VirtualBMP280, VirtualDS3231, VirtualPCF8574 I2C devices with 60 tests Co-authored-by: davidmonterocrespo24 <47928504+davidmonterocrespo24@users.noreply.github.com> --- .../src/__tests__/virtual-i2c-devices.test.ts | 639 ++++++++++++++++++ frontend/src/simulation/I2CBusManager.ts | 327 +++++++++ .../src/simulation/parts/ProtocolParts.ts | 108 ++- 3 files changed, 1073 insertions(+), 1 deletion(-) create mode 100644 frontend/src/__tests__/virtual-i2c-devices.test.ts diff --git a/frontend/src/__tests__/virtual-i2c-devices.test.ts b/frontend/src/__tests__/virtual-i2c-devices.test.ts new file mode 100644 index 0000000..248e6aa --- /dev/null +++ b/frontend/src/__tests__/virtual-i2c-devices.test.ts @@ -0,0 +1,639 @@ +/** + * virtual-i2c-devices.test.ts + * + * Unit tests for the virtual I2C sensor library: + * VirtualBMP280 — barometric pressure / temperature sensor (0x76 / 0x77) + * VirtualDS3231 — real-time clock with on-chip temperature sensor (0x68) + * VirtualPCF8574 — 8-bit I/O expander (0x20–0x27 / 0x38–0x3F) + * + * Also covers the I2CBusManager routing (connectToSlave / writeByte / readByte) + * and the pre-existing VirtualDS1307, VirtualTempSensor, I2CMemoryDevice helpers. + * + * NOTE: these tests import directly from I2CBusManager.ts which only uses + * `import type` from avr8js — so they run in the plain Node / Vitest environment + * without needing the wokwi-libs to be built. + */ + +import { describe, it, expect } from 'vitest'; +import { + I2CBusManager, + I2CMemoryDevice, + VirtualDS1307, + VirtualTempSensor, + VirtualBMP280, + VirtualDS3231, + VirtualPCF8574, +} from '../simulation/I2CBusManager'; + +// ─── Helpers ────────────────────────────────────────────────────────────────── + +/** Minimal mock of AVRTWI used by I2CBusManager */ +function makeTWI() { + const calls: string[] = []; + let readResult = 0xFF; + let writeAck = true; + let connectAck = true; + + return { + calls, + _setReadResult : (v: number) => { readResult = v; }, + _setWriteAck : (v: boolean) => { writeAck = v; }, + _setConnectAck : (v: boolean) => { connectAck = v; }, + + // --- AVRTWI API --- + set eventHandler(_: any) { /* set by I2CBusManager constructor */ }, + completeStart () { calls.push('start'); }, + completeStop () { calls.push('stop'); }, + completeConnect (ack: boolean) { calls.push(`connect:${ack}`); }, + completeWrite (ack: boolean) { calls.push(`write:${ack}`); }, + completeRead (value: number) { calls.push(`read:${value}`); }, + }; +} + +// ─── I2CBusManager routing ──────────────────────────────────────────────────── + +describe('I2CBusManager — routing', () => { + it('completes start unconditionally', () => { + const twi = makeTWI(); + const bus = new I2CBusManager(twi as any); + bus.start(false); + expect(twi.calls).toContain('start'); + }); + + it('NACKs an address with no registered device', () => { + const twi = makeTWI(); + const bus = new I2CBusManager(twi as any); + bus.connectToSlave(0x42, true); + expect(twi.calls).toContain('connect:false'); + }); + + it('ACKs when a device is registered at the address', () => { + const twi = makeTWI(); + const bus = new I2CBusManager(twi as any); + bus.addDevice(new I2CMemoryDevice(0x42)); + bus.connectToSlave(0x42, true); + expect(twi.calls).toContain('connect:true'); + }); + + it('routes writeByte to active device and returns ACK', () => { + const twi = makeTWI(); + const bus = new I2CBusManager(twi as any); + const device = new I2CMemoryDevice(0x50); + bus.addDevice(device); + bus.connectToSlave(0x50, true); + bus.writeByte(0x10); // set register pointer + expect(twi.calls).toContain('write:true'); + }); + + it('routes readByte to active device', () => { + const twi = makeTWI(); + const bus = new I2CBusManager(twi as any); + const device = new I2CMemoryDevice(0x50); + device.registers[0x00] = 0xAB; + bus.addDevice(device); + bus.connectToSlave(0x50, true); + bus.writeByte(0x00); // set register pointer to 0 + bus.connectToSlave(0x50, false); // repeated start, read mode + bus.readByte(true); + const readCall = twi.calls.find(c => c.startsWith('read:')); + expect(readCall).toBe('read:171'); // 0xAB = 171 + }); + + it('returns 0xFF on read when no device at address', () => { + const twi = makeTWI(); + const bus = new I2CBusManager(twi as any); + bus.readByte(true); + expect(twi.calls).toContain('read:255'); + }); + + it('NACKs write when no active device', () => { + const twi = makeTWI(); + const bus = new I2CBusManager(twi as any); + bus.writeByte(0x00); + expect(twi.calls).toContain('write:false'); + }); + + it('removeDevice stops routing to that address', () => { + const twi = makeTWI(); + const bus = new I2CBusManager(twi as any); + bus.addDevice(new I2CMemoryDevice(0x42)); + bus.removeDevice(0x42); + bus.connectToSlave(0x42, true); + expect(twi.calls).toContain('connect:false'); + }); + + it('calls device.stop() on stop condition', () => { + const twi = makeTWI(); + const bus = new I2CBusManager(twi as any); + let stopped = false; + const device: any = { + address : 0x42, + writeByte : () => true, + readByte : () => 0, + stop : () => { stopped = true; }, + }; + bus.addDevice(device); + bus.connectToSlave(0x42, true); + bus.stop(); + expect(stopped).toBe(true); + expect(twi.calls).toContain('stop'); + }); +}); + +// ─── I2CMemoryDevice ───────────────────────────────────────────────────────── + +describe('I2CMemoryDevice', () => { + it('first byte sets register pointer, subsequent bytes write data', () => { + const dev = new I2CMemoryDevice(0x50); + dev.writeByte(0x05); // register pointer + dev.writeByte(0xAB); // write data to register 5 + expect(dev.registers[0x05]).toBe(0xAB); + }); + + it('reads back written data starting at register pointer', () => { + const dev = new I2CMemoryDevice(0x50); + dev.registers[0x02] = 0xCC; + dev.writeByte(0x02); // set pointer + expect(dev.readByte()).toBe(0xCC); + }); + + it('auto-increments register pointer on read', () => { + const dev = new I2CMemoryDevice(0x50); + dev.registers[0x00] = 0x11; + dev.registers[0x01] = 0x22; + dev.writeByte(0x00); + expect(dev.readByte()).toBe(0x11); + expect(dev.readByte()).toBe(0x22); + }); + + it('fires onRegisterWrite callback', () => { + const dev = new I2CMemoryDevice(0x50); + const log: [number, number][] = []; + dev.onRegisterWrite = (r, v) => log.push([r, v]); + dev.writeByte(0x0A); // pointer + dev.writeByte(0xFF); // data → register 0x0A + expect(log).toEqual([[0x0A, 0xFF]]); + }); + + it('resets firstByte on stop()', () => { + const dev = new I2CMemoryDevice(0x50); + dev.writeByte(0x03); // pointer set, firstByte = false + dev.stop(); // reset + dev.writeByte(0x07); // new pointer + dev.writeByte(0x55); // write to register 7 + expect(dev.registers[0x07]).toBe(0x55); + }); +}); + +// ─── VirtualDS1307 ──────────────────────────────────────────────────────────── + +describe('VirtualDS1307', () => { + it('address is 0x68', () => { + expect(new VirtualDS1307().address).toBe(0x68); + }); + + it('readByte for reg 0 returns valid BCD seconds (0–59)', () => { + const dev = new VirtualDS1307(); + dev.writeByte(0x00); // set pointer to seconds + const raw = dev.readByte(); + const tens = (raw >> 4) & 0xF; + const units = raw & 0xF; + expect(tens).toBeLessThanOrEqual(5); + expect(units).toBeLessThanOrEqual(9); + }); + + it('reads 7 consecutive BCD time registers', () => { + const dev = new VirtualDS1307(); + dev.writeByte(0x00); // start at seconds + for (let i = 0; i < 7; i++) { + const byte = dev.readByte(); + expect(byte).toBeGreaterThanOrEqual(0x00); + expect(byte).toBeLessThanOrEqual(0x99); + } + }); + + it('stop() resets firstByte so next write is a new pointer', () => { + const dev = new VirtualDS1307(); + dev.writeByte(0x02); // set pointer to hours + dev.stop(); + dev.writeByte(0x00); // new pointer (seconds) + const raw = dev.readByte(); + // raw should be seconds BCD (tens ≤ 5) + expect((raw >> 4) & 0xF).toBeLessThanOrEqual(5); + }); +}); + +// ─── VirtualTempSensor ──────────────────────────────────────────────────────── + +describe('VirtualTempSensor', () => { + it('address is 0x48', () => { + expect(new VirtualTempSensor().address).toBe(0x48); + }); + + it('reads temperature high byte (register 0) correctly', () => { + const dev = new VirtualTempSensor(); + dev.temperature = 2350; // 23.50°C + dev.writeByte(0x00); + expect(dev.readByte()).toBe((2350 >> 8) & 0xFF); + }); + + it('reads humidity bytes from registers 2–3', () => { + const dev = new VirtualTempSensor(); + dev.humidity = 5500; // 55.00% + dev.writeByte(0x02); + expect(dev.readByte()).toBe((5500 >> 8) & 0xFF); + expect(dev.readByte()).toBe(5500 & 0xFF); + }); +}); + +// ─── VirtualBMP280 ──────────────────────────────────────────────────────────── + +describe('VirtualBMP280 — construction & addresses', () => { + it('default address is 0x76', () => { + expect(new VirtualBMP280().address).toBe(0x76); + }); + + it('accepts 0x77 as alternate address', () => { + expect(new VirtualBMP280(0x77).address).toBe(0x77); + }); + + it('chip_id register (0xD0) reads 0x60', () => { + const dev = new VirtualBMP280(); + dev.writeByte(0xD0); + expect(dev.readByte()).toBe(0x60); + }); + + it('stop() resets firstByte so register pointer can be re-set', () => { + const dev = new VirtualBMP280(); + dev.writeByte(0xD0); + dev.stop(); + dev.writeByte(0xF3); // new pointer → status register + expect(dev.readByte()).toBe(0x00); // status = 0 (ready) + }); +}); + +describe('VirtualBMP280 — calibration registers', () => { + /** + * Reads an unsigned 16-bit little-endian value from two consecutive register + * bytes starting at `regAddr`. + */ + function readU16LE(dev: VirtualBMP280, regAddr: number): number { + dev.writeByte(regAddr); + const lo = dev.readByte(); + const hi = dev.readByte(); + dev.stop(); + return lo | (hi << 8); + } + + function readS16LE(dev: VirtualBMP280, regAddr: number): number { + const u = readU16LE(dev, regAddr); + return u > 0x7FFF ? u - 0x10000 : u; + } + + it('dig_T1 (0x88) equals 27504', () => { + const dev = new VirtualBMP280(); + expect(readU16LE(dev, 0x88)).toBe(27504); + }); + + it('dig_T2 (0x8A) equals 26435', () => { + expect(readS16LE(new VirtualBMP280(), 0x8A)).toBe(26435); + }); + + it('dig_T3 (0x8C) equals -1000', () => { + expect(readS16LE(new VirtualBMP280(), 0x8C)).toBe(-1000); + }); + + it('dig_P1 (0x8E) equals 36477', () => { + expect(readU16LE(new VirtualBMP280(), 0x8E)).toBe(36477); + }); + + it('dig_P2 (0x90) equals -10685', () => { + expect(readS16LE(new VirtualBMP280(), 0x90)).toBe(-10685); + }); +}); + +describe('VirtualBMP280 — temperature compensation', () => { + /** + * Reads the 6-byte pressure+temperature burst (0xF7–0xFC) and reconstructs + * the two 20-bit raw ADC values. Returns { adcP, adcT }. + */ + function readRawAdc(dev: VirtualBMP280): { adcP: number; adcT: number } { + dev.writeByte(0xF7); + const pMsb = dev.readByte(); + const pLsb = dev.readByte(); + const pXlsb = dev.readByte(); + const tMsb = dev.readByte(); + const tLsb = dev.readByte(); + const tXlsb = dev.readByte(); + dev.stop(); + const adcP = (pMsb << 12) | (pLsb << 4) | (pXlsb >> 4); + const adcT = (tMsb << 12) | (tLsb << 4) | (tXlsb >> 4); + return { adcP, adcT }; + } + + /** + * BMP280 Bosch 32-bit integer temperature compensation formula. + * Returns temperature in 0.01°C. + */ + function compensateT(adcT: number, digT1 = 27504, digT2 = 26435, digT3 = -1000): number { + const var1 = (((adcT >> 3) - (digT1 << 1)) * digT2) >> 11; + const sub = (adcT >> 4) - digT1; + const var2 = ((sub * sub >> 12) * digT3) >> 14; + const tFine = var1 + var2; + return (tFine * 5 + 128) >> 8; + } + + /** + * BMP280 floating-point pressure compensation formula. + * Returns pressure in Pa. + */ + function compensateP( + adcP: number, adcT: number, + digT1 = 27504, digT2 = 26435, digT3 = -1000, + digP1 = 36477, digP2 = -10685, digP3 = 3024, + digP4 = 2855, digP5 = 140, digP6 = -7, + digP7 = 15500, digP8 = -14600, digP9 = 6000, + ): number { + const var1 = (((adcT >> 3) - (digT1 << 1)) * digT2) >> 11; + const sub = (adcT >> 4) - digT1; + const var2 = ((sub * sub >> 12) * digT3) >> 14; + const tf = var1 + var2; + + let v1 = tf / 2.0 - 64000.0; + let v2 = v1 * v1 * digP6 / 32768.0; + v2 = v2 + v1 * digP5 * 2.0; + v2 = v2 / 4.0 + digP4 * 65536.0; + v1 = (digP3 * v1 * v1 / 524288.0 + digP2 * v1) / 524288.0; + v1 = (1.0 + v1 / 32768.0) * digP1; + if (v1 === 0) return 0; + let p = 1048576.0 - adcP; + p = (p - v2 / 4096.0) * 6250.0 / v1; + return p + (digP9 * p * p / 2147483648.0 + p * digP8 / 32768.0 + digP7) / 16.0; + } + + it('default 25°C produces compensated temperature within ±0.5°C', () => { + const dev = new VirtualBMP280(); + const { adcT } = readRawAdc(dev); + const centideg = compensateT(adcT); + expect(centideg / 100).toBeCloseTo(25, 0); + }); + + it('setting temperatureC = 20 produces ~20°C compensated output', () => { + const dev = new VirtualBMP280(); + dev.temperatureC = 20; + const { adcT } = readRawAdc(dev); + expect(compensateT(adcT) / 100).toBeCloseTo(20, 0); + }); + + it('setting temperatureC = 0 produces ~0°C compensated output', () => { + const dev = new VirtualBMP280(); + dev.temperatureC = 0; + const { adcT } = readRawAdc(dev); + expect(compensateT(adcT) / 100).toBeCloseTo(0, 0); + }); + + it('setting temperatureC = 85 produces ~85°C compensated output', () => { + const dev = new VirtualBMP280(); + dev.temperatureC = 85; + const { adcT } = readRawAdc(dev); + expect(compensateT(adcT) / 100).toBeCloseTo(85, 0); + }); +}); + +describe('VirtualBMP280 — pressure compensation', () => { + function readRawAdc(dev: VirtualBMP280): { adcP: number; adcT: number } { + dev.writeByte(0xF7); + const pMsb = dev.readByte(), pLsb = dev.readByte(), pXlsb = dev.readByte(); + const tMsb = dev.readByte(), tLsb = dev.readByte(), tXlsb = dev.readByte(); + dev.stop(); + return { + adcP: (pMsb << 12) | (pLsb << 4) | (pXlsb >> 4), + adcT: (tMsb << 12) | (tLsb << 4) | (tXlsb >> 4), + }; + } + + function compensateP(adcP: number, adcT: number): number { + const digT1 = 27504, digT2 = 26435, digT3 = -1000; + const digP1 = 36477, digP2 = -10685, digP3 = 3024; + const digP4 = 2855, digP5 = 140, digP6 = -7; + const digP7 = 15500, digP8 = -14600, digP9 = 6000; + const var1 = (((adcT >> 3) - (digT1 << 1)) * digT2) >> 11; + const sub = (adcT >> 4) - digT1; + const var2 = ((sub * sub >> 12) * digT3) >> 14; + const tf = var1 + var2; + let v1 = tf / 2.0 - 64000.0; + let v2 = v1 * v1 * digP6 / 32768.0; + v2 = v2 + v1 * digP5 * 2.0; + v2 = v2 / 4.0 + digP4 * 65536.0; + v1 = (digP3 * v1 * v1 / 524288.0 + digP2 * v1) / 524288.0; + v1 = (1.0 + v1 / 32768.0) * digP1; + if (v1 === 0) return 0; + let p = 1048576.0 - adcP; + p = (p - v2 / 4096.0) * 6250.0 / v1; + return p + (digP9 * p * p / 2147483648.0 + p * digP8 / 32768.0 + digP7) / 16.0; + } + + it('default 1013.25 hPa produces compensated pressure within ±5 hPa', () => { + const dev = new VirtualBMP280(); + const { adcP, adcT } = readRawAdc(dev); + const pHPa = compensateP(adcP, adcT) / 100; + expect(Math.abs(pHPa - 1013.25)).toBeLessThan(5); + }); + + it('setting pressureHPa = 900 produces ~900 hPa compensated output (±5)', () => { + const dev = new VirtualBMP280(); + dev.pressureHPa = 900; + const { adcP, adcT } = readRawAdc(dev); + expect(Math.abs(compensateP(adcP, adcT) / 100 - 900)).toBeLessThan(5); + }); + + it('setting pressureHPa = 1100 produces ~1100 hPa compensated output (±5)', () => { + const dev = new VirtualBMP280(); + dev.pressureHPa = 1100; + const { adcP, adcT } = readRawAdc(dev); + expect(Math.abs(compensateP(adcP, adcT) / 100 - 1100)).toBeLessThan(5); + }); +}); + +describe('VirtualBMP280 — ctrl_meas register is writable', () => { + it('can write and read back ctrl_meas (0xF4)', () => { + const dev = new VirtualBMP280(); + // Set reg pointer via normal write + dev.writeByte(0xF4); // pointer → 0xF4 + dev.writeByte(0x57); // write 0x57 (forced mode + oversampling) + dev.stop(); + dev.writeByte(0xF4); // read back + expect(dev.readByte()).toBe(0x57); + }); +}); + +// ─── VirtualDS3231 ──────────────────────────────────────────────────────────── + +describe('VirtualDS3231 — time registers', () => { + it('address is 0x68', () => { + expect(new VirtualDS3231().address).toBe(0x68); + }); + + it('register 0x00 returns valid BCD seconds (0–59)', () => { + const dev = new VirtualDS3231(); + dev.writeByte(0x00); + const raw = dev.readByte(); + const tens = (raw >> 4) & 0xF; + const units = raw & 0xF; + expect(tens).toBeLessThanOrEqual(5); + expect(units).toBeLessThanOrEqual(9); + }); + + it('register 0x02 (hours) returns valid BCD 0–23', () => { + const dev = new VirtualDS3231(); + dev.writeByte(0x02); + const raw = dev.readByte(); + const tens = (raw >> 4) & 0xF; + const units = raw & 0xF; + const hours = tens * 10 + units; + expect(hours).toBeGreaterThanOrEqual(0); + expect(hours).toBeLessThanOrEqual(23); + }); + + it('reads 7 consecutive BCD time registers without error', () => { + const dev = new VirtualDS3231(); + dev.writeByte(0x00); + for (let i = 0; i < 7; i++) { + expect(() => dev.readByte()).not.toThrow(); + } + }); + + it('register 0x0E (control) reads 0x00', () => { + const dev = new VirtualDS3231(); + dev.writeByte(0x0E); + expect(dev.readByte()).toBe(0x00); + }); + + it('register 0x0F (status) reads 0x00 (OSF cleared)', () => { + const dev = new VirtualDS3231(); + dev.writeByte(0x0F); + expect(dev.readByte()).toBe(0x00); + }); +}); + +describe('VirtualDS3231 — temperature registers', () => { + it('register 0x11 returns integer part of temperature (25°C = 0x19)', () => { + const dev = new VirtualDS3231(); + dev.temperatureC = 25.0; + dev.writeByte(0x11); + expect(dev.readByte()).toBe(25); // 25 = 0x19 + }); + + it('register 0x12 returns 0x00 for integer temperature (no fractional)', () => { + const dev = new VirtualDS3231(); + dev.temperatureC = 25.0; + dev.writeByte(0x12); + expect(dev.readByte()).toBe(0x00); + }); + + it('register 0x12 returns 0x80 for 0.5°C fractional (bits 7:6 = 0b10)', () => { + const dev = new VirtualDS3231(); + dev.temperatureC = 25.5; + dev.writeByte(0x12); + // 0.5°C / 0.25°C = 2 = 0b10 → stored in bits 7:6 = 0x80 + expect(dev.readByte()).toBe(0x80); + }); + + it('handles negative temperature: -5°C MSB = 0xFB (251 as unsigned)', () => { + const dev = new VirtualDS3231(); + dev.temperatureC = -5.0; + dev.writeByte(0x11); + // trunc(-5) & 0xFF = 251 + expect(dev.readByte()).toBe((-5) & 0xFF); + }); +}); + +describe('VirtualDS3231 — stop/firstByte reset', () => { + it('stop() resets pointer acquisition', () => { + const dev = new VirtualDS3231(); + dev.writeByte(0x11); // set pointer + dev.stop(); + dev.writeByte(0x00); // new pointer → seconds + const raw = dev.readByte(); + expect((raw >> 4) & 0xF).toBeLessThanOrEqual(5); // valid BCD tens digit + }); +}); + +// ─── VirtualPCF8574 ──────────────────────────────────────────────────────────── + +describe('VirtualPCF8574 — construction', () => { + it('default address is 0x27', () => { + expect(new VirtualPCF8574().address).toBe(0x27); + }); + + it('accepts custom address', () => { + expect(new VirtualPCF8574(0x20).address).toBe(0x20); + expect(new VirtualPCF8574(0x3F).address).toBe(0x3F); + }); + + it('default portState and outputLatch are both 0xFF', () => { + const dev = new VirtualPCF8574(); + expect(dev.portState).toBe(0xFF); + expect(dev.outputLatch).toBe(0xFF); + }); +}); + +describe('VirtualPCF8574 — write', () => { + it('writeByte updates outputLatch', () => { + const dev = new VirtualPCF8574(); + dev.writeByte(0b10101010); + expect(dev.outputLatch).toBe(0b10101010); + }); + + it('writeByte fires onWrite callback with the written value', () => { + const dev = new VirtualPCF8574(); + const log: number[] = []; + dev.onWrite = v => log.push(v); + dev.writeByte(0x42); + expect(log).toEqual([0x42]); + }); + + it('multiple writes update outputLatch each time', () => { + const dev = new VirtualPCF8574(); + dev.writeByte(0xAA); + dev.writeByte(0x55); + expect(dev.outputLatch).toBe(0x55); + }); +}); + +describe('VirtualPCF8574 — read (open-drain model)', () => { + it('readByte returns portState & outputLatch (both 0xFF → 0xFF)', () => { + const dev = new VirtualPCF8574(); + expect(dev.readByte()).toBe(0xFF); + }); + + it('readByte: pin driven LOW by Arduino (outputLatch=0) → reads 0 regardless of portState', () => { + const dev = new VirtualPCF8574(); + dev.portState = 0xFF; + dev.writeByte(0x00); // drive all LOW + expect(dev.readByte()).toBe(0x00); + }); + + it('readByte: external input LOW overrides Hi-Z output (open-drain)', () => { + const dev = new VirtualPCF8574(); + dev.portState = 0b00001111; // lower 4 pins pulled low by external device + dev.outputLatch = 0xFF; // Arduino released all pins + expect(dev.readByte()).toBe(0b00001111); + }); + + it('readByte reflects mix of output-low and external-low', () => { + const dev = new VirtualPCF8574(); + dev.outputLatch = 0b11110000; // Arduino drives lower 4 LOW, upper 4 Hi-Z + dev.portState = 0b10101010; // external: alternate HIGH/LOW + // result: upper 4 from portState masked, lower 4 forced LOW by outputLatch + expect(dev.readByte()).toBe(0b10100000); + }); +}); + +describe('VirtualPCF8574 — writeByte returns ACK', () => { + it('always returns true (ACK)', () => { + const dev = new VirtualPCF8574(); + expect(dev.writeByte(0x00)).toBe(true); + expect(dev.writeByte(0xFF)).toBe(true); + }); +}); diff --git a/frontend/src/simulation/I2CBusManager.ts b/frontend/src/simulation/I2CBusManager.ts index 1db3df3..9e72a20 100644 --- a/frontend/src/simulation/I2CBusManager.ts +++ b/frontend/src/simulation/I2CBusManager.ts @@ -213,3 +213,330 @@ export class VirtualTempSensor implements I2CDevice { 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 + } +} diff --git a/frontend/src/simulation/parts/ProtocolParts.ts b/frontend/src/simulation/parts/ProtocolParts.ts index 847c8cc..2eebd89 100644 --- a/frontend/src/simulation/parts/ProtocolParts.ts +++ b/frontend/src/simulation/parts/ProtocolParts.ts @@ -19,7 +19,7 @@ */ import { PartSimulationRegistry } from './PartSimulationRegistry'; -import { VirtualDS1307 } from '../I2CBusManager'; +import { VirtualDS1307, VirtualBMP280, VirtualDS3231, VirtualPCF8574 } from '../I2CBusManager'; import type { I2CDevice } from '../I2CBusManager'; // ─── Helpers ───────────────────────────────────────────────────────────────── @@ -749,3 +749,109 @@ PartSimulationRegistry.register('microsd-card', { }; }, }); + +// ─── BMP280 Barometric Pressure / Temperature Sensor ───────────────────────── + +/** + * BMP280 — I2C barometric pressure + temperature sensor. + * + * Addresses: + * 0x76 (SDO pin pulled LOW, default) + * 0x77 (SDO pin pulled HIGH — set element.address = '0x77') + * + * The element may expose `temperature` (°C) and `pressure` (hPa) properties + * that are read on attach and forwarded to the virtual device. + * + * The virtual device uses the BMP280 datasheet calibration example to compute + * raw ADC values for any desired temperature/pressure combination, so Arduino + * sketches using Adafruit_BMP280 or Bosch's reference driver receive correct + * compensated readings. + */ +PartSimulationRegistry.register('bmp280', { + attachEvents: (element, simulator, _getPin) => { + const sim = simulator as any; + if (typeof sim.addI2CDevice !== 'function') return () => {}; + + const el = element as any; + const addr = (el.address === '0x77' || el.address === 0x77) ? 0x77 : 0x76; + const dev = new VirtualBMP280(addr); + + if (el.temperature !== undefined) dev.temperatureC = parseFloat(el.temperature); + if (el.pressure !== undefined) dev.pressureHPa = parseFloat(el.pressure); + + sim.addI2CDevice(dev); + return () => removeI2CDevice(sim, dev.address); + }, +}); + +// ─── DS3231 Real-Time Clock ─────────────────────────────────────────────────── + +/** + * DS3231 — I2C RTC with on-chip temperature sensor (address 0x68). + * + * Returns the browser's current system time as BCD in registers 0x00–0x06, + * identical to DS1307 for the time registers. Additionally exposes: + * 0x0E Control register + * 0x0F Status register (OSF cleared) + * 0x11 Temperature MSB (integer °C, signed) + * 0x12 Temperature LSB (fractional, 0.25°C per bit in bits 7:6) + * + * Ambient temperature defaults to 25°C; override via `element.temperature`. + */ +PartSimulationRegistry.register('ds3231', { + attachEvents: (element, simulator, _getPin) => { + const sim = simulator as any; + if (typeof sim.addI2CDevice !== 'function') return () => {}; + + const el = element as any; + const dev = new VirtualDS3231(); + if (el.temperature !== undefined) dev.temperatureC = parseFloat(el.temperature); + + sim.addI2CDevice(dev); + return () => removeI2CDevice(sim, dev.address); + }, +}); + +// ─── PCF8574 I/O Expander ──────────────────────────────────────────────────── + +/** + * PCF8574 — I2C 8-bit quasi-bidirectional I/O expander. + * + * Default address: 0x27 (all three address pins HIGH — typical LCD backpack). + * Override with `element.i2cAddress` (e.g. '0x20', '0x3F'). + * + * `element.portState` (0–255) sets the external input state visible to the + * Arduino on a read. Defaults to 0xFF (all pins pulled high / floating input). + * + * Writes from the Arduino update `dev.outputLatch` and fire `dev.onWrite` + * which sets `element.value` so wokwi-LCD-I2C or similar elements can render. + */ +PartSimulationRegistry.register('pcf8574', { + attachEvents: (element, simulator, _getPin) => { + const sim = simulator as any; + if (typeof sim.addI2CDevice !== 'function') return () => {}; + + const el = element as any; + + // Parse address from element property (accepts '0x27', '39', or numeric) + let addr = 0x27; + if (el.i2cAddress !== undefined) { + const raw = String(el.i2cAddress).trim(); + const parsed = raw.startsWith('0x') || raw.startsWith('0X') + ? parseInt(raw, 16) + : parseInt(raw, 10); + if (!isNaN(parsed)) addr = parsed; + } + + const dev = new VirtualPCF8574(addr); + + // Seed port state from element if present + if (el.portState !== undefined) dev.portState = Number(el.portState) & 0xFF; + + // Feed writes back to the element so visual components can re-render + dev.onWrite = (value: number) => { el.value = value; }; + + sim.addI2CDevice(dev); + return () => removeI2CDevice(sim, dev.address); + }, +});