/** * protocol-parts.test.ts * * Tests for the 8 "protocol" components that use I2C, SPI, single-wire, or * custom serial communication: * * ssd1306 — I2C OLED (VirtualSSD1306) * ds1307 — I2C RTC (VirtualDS1307) * mpu6050 — I2C IMU (VirtualMPU6050) * dht22 — single-wire temp/humidity * hx711 — 2-wire load-cell ADC * ir-receiver — click → NEC pulse on OUT pin * ir-remote — button-press → ir-signal event * microsd-card — SPI SD init handshake */ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry'; import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry'; import '../simulation/parts/ProtocolParts'; // ─── Globals ────────────────────────────────────────────────────────────────── beforeEach(() => { vi.useFakeTimers(); vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => 1); vi.stubGlobal('cancelAnimationFrame', vi.fn()); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); // ─── Mock factories ─────────────────────────────────────────────────────────── function makeElement(props: Record = {}): HTMLElement { return { addEventListener: vi.fn(), removeEventListener: vi.fn(), dispatchEvent: vi.fn(), ...props, } as unknown as HTMLElement; } function makeI2CSim() { return { addI2CDevice: vi.fn(), i2cBus: { removeDevice: vi.fn() }, removeI2CDevice: vi.fn(), setPinState: vi.fn(), pinManager: { onPinChange: vi.fn().mockReturnValue(() => {}), }, spi: null, cpu: { data: new Uint8Array(512).fill(0), cycles: 0 }, }; } function makePinSim() { return { pinManager: { onPinChange: vi.fn().mockReturnValue(() => {}), }, setPinState: vi.fn(), addI2CDevice: vi.fn(), i2cBus: { removeDevice: vi.fn() }, spi: null, cpu: { data: new Uint8Array(512).fill(0), cycles: 0 }, }; } function makeSPISim() { const spi = { onByte: null as ((b: number) => void) | null, completeTransfer: vi.fn(), }; return { spi, pinManager: { onPinChange: vi.fn().mockReturnValue(() => {}) }, setPinState: vi.fn(), addI2CDevice: vi.fn(), i2cBus: { removeDevice: vi.fn() }, cpu: { data: new Uint8Array(512).fill(0), cycles: 0 }, }; } const pinMap = (map: Record) => (name: string): number | null => name in map ? map[name] : null; const noPins = (_name: string): number | null => null; /** * Simulator mock that triggers the ESP32 dual-path branch in ProtocolParts. * * Deliberately has no `addI2CDevice` so the `else if (registerSensor)` branch * is taken. Captures I2C transaction listeners so tests can fire them. */ function makeEsp32Sim() { const listeners = new Map void>(); return { registerSensor: vi.fn(), updateSensor: vi.fn(), unregisterSensor: vi.fn(), addI2CTransactionListener: vi.fn((addr: number, fn: (d: number[]) => void) => { listeners.set(addr, fn); }), removeI2CTransactionListener: vi.fn((addr: number) => { listeners.delete(addr); }), /** Simulate backend emitting an i2c_transaction for a given address. */ _fireTransaction(addr: number, data: number[]) { listeners.get(addr)?.(data); }, i2cBus: { removeDevice: vi.fn() }, pinManager: { onPinChange: vi.fn().mockReturnValue(() => {}) }, setPinState: vi.fn(), spi: null, cpu: { data: new Uint8Array(512) }, }; } // ─── Registration ───────────────────────────────────────────────────────────── describe('Protocol parts — registration', () => { const IDS = [ 'ssd1306', 'ds1307', 'mpu6050', 'bmp280', 'ds3231', 'pcf8574', 'dht22', 'hx711', 'ir-receiver', 'ir-remote', 'microsd-card', ]; it('registers all 11 protocol components', () => { for (const id of IDS) { expect(PartSimulationRegistry.get(id), `missing: ${id}`).toBeDefined(); } }); }); // ─── ssd1306 ────────────────────────────────────────────────────────────────── describe('ssd1306 — I2C device', () => { it('calls addI2CDevice with address 0x3C', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ssd1306')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.addI2CDevice).toHaveBeenCalledOnce(); const device = sim.addI2CDevice.mock.calls[0][0]; expect(device.address).toBe(0x3c); }); it('cleanup calls removeDevice on i2cBus', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ssd1306')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); cleanup(); expect(sim.i2cBus.removeDevice).toHaveBeenCalledWith(0x3c); }); it('decodes horizontal addressing: write data bytes into buffer correctly', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ssd1306')!; logic.attachEvents!(makeElement(), sim as any, noPins); const device = sim.addI2CDevice.mock.calls[0][0]; // Set column address 0–127, page address 0–7 (via commands) device.writeByte(0x00); // control: command stream device.writeByte(0x21); // cmd: set column address device.writeByte(0x00); // col start = 0 device.writeByte(0x7f); // col end = 127 device.writeByte(0x22); // cmd: set page address device.writeByte(0x00); // page start = 0 device.writeByte(0x07); // page end = 7 device.stop(); // flushes command state device.writeByte(0x40); // control: data stream device.writeByte(0xab); // column 0 of page 0 = 0xAB device.stop(); expect(device.buffer[0]).toBe(0xab); }); it('readByte returns 0xFF (read not supported)', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ssd1306')!; logic.attachEvents!(makeElement(), sim as any, noPins); const device = sim.addI2CDevice.mock.calls[0][0]; expect(device.readByte()).toBe(0xff); }); it('no-op when simulator has no addI2CDevice', () => { const sim = { ...makeI2CSim(), addI2CDevice: undefined }; const logic = PartSimulationRegistry.get('ssd1306')!; expect(() => { const c = logic.attachEvents!(makeElement(), sim as any, noPins); c(); }).not.toThrow(); }); }); // ─── ssd1306 — protocol auto-detect (one component, wired like real life) ───── describe('ssd1306 — protocol auto-detect', () => { it('runs I2C when neither CS nor DC is wired', () => { const sim = makeI2CSim(); PartSimulationRegistry.get('ssd1306')!.attachEvents!(makeElement(), sim as any, noPins); expect(sim.addI2CDevice).toHaveBeenCalledOnce(); expect(sim.spi).toBeNull(); }); it('runs SPI when CS is wired to a GPIO', () => { const sim = makeSPISim(); PartSimulationRegistry.get('ssd1306')!.attachEvents!( makeElement(), sim as any, pinMap({ CS: 5 }), ); // SPI decoder hooks spi.onByte; the I2C path would call addI2CDevice instead. expect(typeof sim.spi.onByte).toBe('function'); expect(sim.addI2CDevice).not.toHaveBeenCalled(); }); it('runs SPI when DC is wired to a GPIO', () => { const sim = makeSPISim(); PartSimulationRegistry.get('ssd1306')!.attachEvents!( makeElement(), sim as any, pinMap({ DC: 4 }), ); expect(typeof sim.spi.onByte).toBe('function'); expect(sim.addI2CDevice).not.toHaveBeenCalled(); }); it('legacy ssd1306-i2c alias forces I2C even with CS wired', () => { const sim = makeI2CSim(); PartSimulationRegistry.get('ssd1306-i2c')!.attachEvents!( makeElement(), sim as any, pinMap({ CS: 5 }), ); expect(sim.addI2CDevice).toHaveBeenCalledOnce(); }); it('legacy ssd1306-spi alias forces SPI even with nothing wired', () => { const sim = makeSPISim(); PartSimulationRegistry.get('ssd1306-spi')!.attachEvents!(makeElement(), sim as any, noPins); expect(typeof sim.spi.onByte).toBe('function'); }); }); // ─── ds1307 ─────────────────────────────────────────────────────────────────── describe('ds1307 — I2C RTC', () => { it('calls addI2CDevice with address 0x68', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ds1307')!; logic.attachEvents!(makeElement(), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; expect(dev.address).toBe(0x68); }); it('readByte returns valid BCD for seconds (register 0)', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ds1307')!; logic.attachEvents!(makeElement(), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; dev.writeByte(0x00); // set register pointer to 0 (seconds) const seconds = dev.readByte(); // BCD: upper nibble = tens digit, lower nibble = units digit const tens = (seconds >> 4) & 0xf; const units = seconds & 0xf; expect(tens).toBeLessThanOrEqual(5); expect(units).toBeLessThanOrEqual(9); }); it('cleanup removes device from i2cBus', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('ds1307')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); cleanup(); expect(sim.i2cBus.removeDevice).toHaveBeenCalledWith(0x68); }); }); // ─── mpu6050 ────────────────────────────────────────────────────────────────── describe('mpu6050 — I2C IMU', () => { it('calls addI2CDevice with address 0x68 (AD0=0)', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('mpu6050')!; logic.attachEvents!(makeElement(), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; expect(dev.address).toBe(0x68); }); it('uses address 0x69 when element.ad0 is true', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('mpu6050')!; logic.attachEvents!(makeElement({ ad0: true }), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; expect(dev.address).toBe(0x69); }); it('WHO_AM_I register (0x75) returns 0x68', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('mpu6050')!; logic.attachEvents!(makeElement(), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; dev.writeByte(0x75); // set register pointer expect(dev.readByte()).toBe(0x68); }); it('ACCEL_ZOUT reports +1g (0x40, 0x00)', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('mpu6050')!; logic.attachEvents!(makeElement(), sim as any, noPins); const dev = sim.addI2CDevice.mock.calls[0][0]; dev.writeByte(0x3f); // ACCEL_ZOUT_H expect(dev.readByte()).toBe(0x40); expect(dev.readByte()).toBe(0x00); }); it('cleanup removes device', () => { const sim = makeI2CSim(); const logic = PartSimulationRegistry.get('mpu6050')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); cleanup(); expect(sim.i2cBus.removeDevice).toHaveBeenCalledWith(0x68); }); }); // ─── dht22 ─────────────────────────────────────────────────────────────────── describe('dht22 — single-wire sensor', () => { it('sets DATA pin HIGH (idle) on attach', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('dht22')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ DATA: 7 })); expect(sim.setPinState).toHaveBeenCalledWith(7, true); }); it('registers onPinChange for DATA pin', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('dht22')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ DATA: 7 })); expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(7, expect.any(Function)); }); it('drives DATA in response after LOW → HIGH start sequence', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('dht22')!; logic.attachEvents!( makeElement({ temperature: 25.0, humidity: 50.0 }), sim as any, pinMap({ DATA: 7 }), ); const cb = sim.pinManager.onPinChange.mock.calls[0][1] as (pin: number, state: boolean) => void; sim.setPinState.mockClear(); cb(7, false); // MCU pulls DATA LOW (start signal) cb(7, true); // MCU releases DATA HIGH → DHT22 should begin response // Response should include at least one LOW pulse const calls = (sim.setPinState as ReturnType).mock.calls; expect(calls.some(([, s]) => s === false)).toBe(true); }); it('no-op if DATA pin not found', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('dht22')!; expect(() => { const c = logic.attachEvents!(makeElement(), sim as any, noPins); c(); }).not.toThrow(); expect(sim.pinManager.onPinChange).not.toHaveBeenCalled(); }); it('cleanup drives DATA HIGH', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('dht22')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, pinMap({ DATA: 7 })); sim.setPinState.mockClear(); cleanup(); expect(sim.setPinState).toHaveBeenCalledWith(7, true); }); }); // ─── hx711 ─────────────────────────────────────────────────────────────────── describe('hx711 — load cell amplifier', () => { it('drives DOUT LOW (ready) on attach', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ SCK: 2, DOUT: 3 })); expect(sim.setPinState).toHaveBeenCalledWith(3, false); }); it('registers onPinChange for SCK pin', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ SCK: 2, DOUT: 3 })); expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(2, expect.any(Function)); }); it('outputs 24 bits on 24 rising SCK edges (MSB first)', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; // weight=100 → raw = 100000 = 0x0186A0 logic.attachEvents!(makeElement({ weight: 100 }), sim as any, pinMap({ SCK: 2, DOUT: 3 })); const cb = sim.pinManager.onPinChange.mock.calls[0][1] as (p: number, s: boolean) => void; sim.setPinState.mockClear(); // 24 rising edges const doutValues: boolean[] = []; for (let i = 0; i < 24; i++) { cb(2, true); // rising const last = (sim.setPinState as ReturnType).mock.lastCall; if (last && last[0] === 3) doutValues.push(last[1]); cb(2, false); // falling } expect(doutValues).toHaveLength(24); // Reconstruct 24-bit value const reconstructed = doutValues.reduce((acc, bit, i) => acc | ((bit ? 1 : 0) << (23 - i)), 0); const expected = (100 * 1000) & 0xff_ffff; // = 100000 = 0x0186A0 expect(reconstructed).toBe(expected); }); it('drives DOUT HIGH after 25 rising edges (gain select done)', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ SCK: 2, DOUT: 3 })); const cb = sim.pinManager.onPinChange.mock.calls[0][1] as (p: number, s: boolean) => void; // 24 data bits + 1 gain pulse for (let i = 0; i < 25; i++) { cb(2, true); cb(2, false); } // After 25th rising, DOUT goes HIGH const highCalls = (sim.setPinState as ReturnType).mock.calls.filter( ([pin, state]) => pin === 3 && state === true, ); expect(highCalls.length).toBeGreaterThanOrEqual(1); }); it('no-op if SCK or DOUT not connected', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; expect(() => { const c = logic.attachEvents!(makeElement(), sim as any, noPins); c(); }).not.toThrow(); expect(sim.pinManager.onPinChange).not.toHaveBeenCalled(); }); it('cleanup drives DOUT HIGH', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('hx711')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, pinMap({ SCK: 2, DOUT: 3 })); sim.setPinState.mockClear(); cleanup(); expect(sim.setPinState).toHaveBeenCalledWith(3, true); }); }); // ─── ir-receiver ───────────────────────────────────────────────────────────── describe('ir-receiver — NEC click simulation', () => { it('sets OUT pin HIGH (idle) on attach', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('ir-receiver')!; logic.attachEvents!(makeElement(), sim as any, pinMap({ OUT: 5 })); expect(sim.setPinState).toHaveBeenCalledWith(5, true); }); it('registers a click listener on the element', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-receiver')!; logic.attachEvents!(el, sim as any, pinMap({ OUT: 5 })); expect(el.addEventListener).toHaveBeenCalledWith('click', expect.any(Function)); }); it('click drives OUT LOW (IR burst start) and later HIGH via setTimeout', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-receiver')!; logic.attachEvents!(el, sim as any, pinMap({ OUT: 5 })); const clickCb = (el.addEventListener as ReturnType).mock.calls.find( ([event]) => event === 'click', )?.[1] as (() => void) | undefined; expect(clickCb).toBeDefined(); sim.setPinState.mockClear(); clickCb!(); // First call should drive pin LOW (beginning of 9 ms preamble burst) const firstLow = (sim.setPinState as ReturnType).mock.calls[0]; expect(firstLow).toEqual([5, false]); }); it('NEC sequence produces more than 60 setPinState calls (35+ transitions)', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-receiver')!; logic.attachEvents!(el, sim as any, pinMap({ OUT: 5 })); const clickCb = (el.addEventListener as ReturnType).mock.calls.find( ([ev]) => ev === 'click', )?.[1] as () => void; sim.setPinState.mockClear(); clickCb(); // Run all queued timeouts to exhaust the chain vi.runAllTimers(); expect((sim.setPinState as ReturnType).mock.calls.length).toBeGreaterThan(60); }); it('cleanup removes click listener and sets pin HIGH', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-receiver')!; const cleanup = logic.attachEvents!(el, sim as any, pinMap({ OUT: 5 })); sim.setPinState.mockClear(); cleanup(); expect(el.removeEventListener).toHaveBeenCalledWith('click', expect.any(Function)); expect(sim.setPinState).toHaveBeenCalledWith(5, true); }); it('no-op when no pin connected (no throw)', () => { const sim = makePinSim(); const logic = PartSimulationRegistry.get('ir-receiver')!; expect(() => { const c = logic.attachEvents!(makeElement(), sim as any, noPins); c(); }).not.toThrow(); }); }); // ─── ir-remote ─────────────────────────────────────────────────────────────── describe('ir-remote — button dispatch', () => { it('registers button-press listener on element', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-remote')!; logic.attachEvents!(el, sim as any, noPins); const events = (el.addEventListener as ReturnType).mock.calls.map(([e]) => e); expect(events).toContain('button-press'); }); it('button-press fires ir-signal CustomEvent with address and command', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-remote')!; logic.attachEvents!(el, sim as any, noPins); const onButtonPress = (el.addEventListener as ReturnType).mock.calls.find( ([ev]) => ev === 'button-press', )?.[1] as ((e: Event) => void) | undefined; expect(onButtonPress).toBeDefined(); const fakeEvent = new CustomEvent('button-press', { detail: { key: 'power' } }); onButtonPress!(fakeEvent); expect(el.dispatchEvent).toHaveBeenCalledWith(expect.objectContaining({ type: 'ir-signal' })); const dispatched = (el.dispatchEvent as ReturnType).mock .calls[0][0] as CustomEvent; expect(dispatched.detail.command).toBe(0x45); // POWER key }); it('drives IR pin if connected', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-remote')!; logic.attachEvents!(el, sim as any, pinMap({ IR: 4 })); const onButtonPress = (el.addEventListener as ReturnType).mock.calls.find( ([ev]) => ev === 'button-press', )?.[1] as (e: Event) => void; sim.setPinState.mockClear(); onButtonPress(new CustomEvent('button-press', { detail: { key: 'power' } })); // Should start the NEC pulse sequence (first edge LOW) expect(sim.setPinState).toHaveBeenCalledWith(4, false); }); it('cleanup removes all listeners', () => { const sim = makePinSim(); const el = makeElement(); const logic = PartSimulationRegistry.get('ir-remote')!; const cleanup = logic.attachEvents!(el, sim as any, noPins); cleanup(); expect(el.removeEventListener).toHaveBeenCalledTimes(2); }); }); // ─── microsd-card ───────────────────────────────────────────────────────────── describe('microsd-card — SPI init handshake', () => { it('hooks into simulator.spi.onByte', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.spi.onByte).toBeTypeOf('function'); }); it('CMD0 (0x40 + 4 zeroes + CRC) returns R1=0x01 (idle)', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; logic.attachEvents!(makeElement(), sim as any, noPins); const tx = sim.spi.onByte as (b: number) => void; // Send CMD0: [0x40, 0x00, 0x00, 0x00, 0x00, 0x95] [0x40, 0x00, 0x00, 0x00, 0x00, 0x95].forEach((b) => tx(b)); // Poll with 0xFF to receive response tx(0xff); const replies = (sim.spi.completeTransfer as ReturnType).mock.calls.map( ([v]) => v, ); expect(replies).toContain(0x01); }); it('CMD8 returns R7 with echo-back 0x1AA', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; logic.attachEvents!(makeElement(), sim as any, noPins); const tx = sim.spi.onByte as (b: number) => void; // CMD8: [0x48, 0x00, 0x00, 0x01, 0xAA, 0x87] [0x48, 0x00, 0x00, 0x01, 0xaa, 0x87].forEach((b) => tx(b)); // Read 5 bytes of R7 response for (let i = 0; i < 5; i++) tx(0xff); const replies = (sim.spi.completeTransfer as ReturnType).mock.calls.map( ([v]) => v, ); // R7 = 0x01, 0x00, 0x00, 0x01, 0xAA expect(replies).toContain(0x01); expect(replies).toContain(0xaa); }); it('ACMD41 (CMD55 + CMD41) returns R1=0x00 (ready)', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; logic.attachEvents!(makeElement(), sim as any, noPins); const tx = sim.spi.onByte as (b: number) => void; [0x77, 0x00, 0x00, 0x00, 0x00, 0x65].forEach((b) => tx(b)); // CMD55 tx(0xff); // poll sim.spi.completeTransfer.mockClear(); [0x69, 0x40, 0x00, 0x00, 0x00, 0x77].forEach((b) => tx(b)); // ACMD41 tx(0xff); const replies = (sim.spi.completeTransfer as ReturnType).mock.calls.map( ([v]) => v, ); expect(replies).toContain(0x00); }); it('0xFF clock bytes return 0xFF (idle) when no pending response', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; logic.attachEvents!(makeElement(), sim as any, noPins); const tx = sim.spi.onByte as (b: number) => void; tx(0xff); expect(sim.spi.completeTransfer).toHaveBeenLastCalledWith(0xff); }); it('cleanup restores previous onByte and is callable without SPI', () => { const sim = makeSPISim(); const logic = PartSimulationRegistry.get('microsd-card')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); expect(() => cleanup()).not.toThrow(); expect(sim.spi.onByte).toBeNull(); }); it('no-op when simulator has no spi', () => { const sim = { ...makeSPISim(), spi: null }; const logic = PartSimulationRegistry.get('microsd-card')!; expect(() => { const c = logic.attachEvents!(makeElement(), sim as any, noPins); c(); }).not.toThrow(); }); }); // ─── ESP32 paths ────────────────────────────────────────────────────────────── // The following tests verify the `else if (typeof sim.registerSensor)` branch // that was added to each component for ESP32 QEMU simulation. // ─── ssd1306 — ESP32 relay path ─────────────────────────────────────────────── // NOTE: 0x3C = 60 decimal → virtual pin = 200 + 60 = 260 describe('ssd1306 — ESP32 relay path', () => { it('registers sensor with type ssd1306 and virtual pin 260 (200+0x3C)', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('ssd1306')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.registerSensor).toHaveBeenCalledWith( 'ssd1306', 260, expect.objectContaining({ addr: 0x3c }), ); }); it('adds I2C transaction listener for addr 0x3C', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('ssd1306')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.addI2CTransactionListener).toHaveBeenCalledWith(0x3c, expect.any(Function)); }); it('transaction data is forwarded to VirtualSSD1306 device', () => { const el = makeElement(); const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('ssd1306')!; logic.attachEvents!(el, sim as any, noPins); // Send a command-mode control byte + set-page command — should not throw expect(() => { sim._fireTransaction(0x3c, [0x00, 0xb0]); }).not.toThrow(); }); it('cleanup calls unregisterSensor(260) and removeI2CTransactionListener(0x3C)', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('ssd1306')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); cleanup(); expect(sim.unregisterSensor).toHaveBeenCalledWith(260); expect(sim.removeI2CTransactionListener).toHaveBeenCalledWith(0x3c); }); }); // ─── ds1307 — ESP32 path ────────────────────────────────────────────────────── describe('ds1307 — ESP32 path', () => { it('registers sensor with type ds1307 and virtual pin 304 (200+0x68)', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('ds1307')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.registerSensor).toHaveBeenCalledWith( 'ds1307', 304, expect.objectContaining({ addr: 0x68 }), ); }); it('does NOT add I2C transaction listener (read-only: backend handles reads)', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('ds1307')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.addI2CTransactionListener).not.toHaveBeenCalled(); }); it('cleanup calls unregisterSensor(304)', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('ds1307')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); cleanup(); expect(sim.unregisterSensor).toHaveBeenCalledWith(304); }); }); // ─── bmp280 — ESP32 path ────────────────────────────────────────────────────── describe('bmp280 — ESP32 path', () => { it('registers sensor with type bmp280 and virtual pin 318 (200+0x76)', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('bmp280')!; logic.attachEvents!(makeElement(), sim as any, noPins, 'bmp-esp-1'); expect(sim.registerSensor).toHaveBeenCalledWith( 'bmp280', 318, expect.objectContaining({ addr: 0x76 }), ); }); it('uses virtual pin 319 (200+0x77) when address is 0x77', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('bmp280')!; logic.attachEvents!(makeElement({ address: '0x77' }), sim as any, noPins, 'bmp-esp-2'); expect(sim.registerSensor).toHaveBeenCalledWith( 'bmp280', 319, expect.objectContaining({ addr: 0x77 }), ); }); it('forwards initial temperature from element.temperature', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('bmp280')!; logic.attachEvents!(makeElement({ temperature: '35.5' }), sim as any, noPins, 'bmp-esp-3'); const [, , props] = sim.registerSensor.mock.calls[0]; expect(props.temperature).toBeCloseTo(35.5); }); it('forwards initial pressure from element.pressure', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('bmp280')!; logic.attachEvents!(makeElement({ pressure: '980.5' }), sim as any, noPins, 'bmp-esp-4'); const [, , props] = sim.registerSensor.mock.calls[0]; expect(props.pressure).toBeCloseTo(980.5); }); it('registerSensorUpdate callback calls updateSensor with new values', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('bmp280')!; logic.attachEvents!(makeElement(), sim as any, noPins, 'bmp-esp-5'); dispatchSensorUpdate('bmp-esp-5', { temperature: 40, pressure: 950 }); expect(sim.updateSensor).toHaveBeenCalledWith( 318, expect.objectContaining({ temperature: 40 }), ); }); it('cleanup calls unregisterSensor and unregisters sensor update', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('bmp280')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins, 'bmp-esp-6'); cleanup(); expect(sim.unregisterSensor).toHaveBeenCalledWith(318); // After cleanup, dispatching an update must NOT call updateSensor again sim.updateSensor.mockClear(); dispatchSensorUpdate('bmp-esp-6', { temperature: 99 }); expect(sim.updateSensor).not.toHaveBeenCalled(); }); }); // ─── ds3231 — ESP32 path ────────────────────────────────────────────────────── describe('ds3231 — ESP32 path', () => { it('registers sensor with type ds3231 and virtual pin 304 (200+0x68)', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('ds3231')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.registerSensor).toHaveBeenCalledWith( 'ds3231', 304, expect.objectContaining({ addr: 0x68 }), ); }); it('forwards initial temperature from element.temperature', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('ds3231')!; logic.attachEvents!(makeElement({ temperature: '28.5' }), sim as any, noPins); const [, , props] = sim.registerSensor.mock.calls[0]; expect(props.temperature).toBeCloseTo(28.5); }); it('cleanup calls unregisterSensor(304)', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('ds3231')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); cleanup(); expect(sim.unregisterSensor).toHaveBeenCalledWith(304); }); }); // ─── pcf8574 — ESP32 relay path ─────────────────────────────────────────────── describe('pcf8574 — ESP32 relay path', () => { it('registers sensor with type pcf8574 and virtual pin 239 (200+0x27)', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('pcf8574')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.registerSensor).toHaveBeenCalledWith( 'pcf8574', 239, expect.objectContaining({ addr: 0x27 }), ); }); it('adds I2C transaction listener for addr 0x27', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('pcf8574')!; logic.attachEvents!(makeElement(), sim as any, noPins); expect(sim.addI2CTransactionListener).toHaveBeenCalledWith(0x27, expect.any(Function)); }); it('transaction byte is forwarded to VirtualPCF8574 — onWrite fires', () => { const el = makeElement(); const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('pcf8574')!; logic.attachEvents!(el, sim as any, noPins); // Fire a transaction: MCU wrote byte 0xAB to I2C address 0x27 sim._fireTransaction(0x27, [0xab]); expect((el as any).value).toBe(0xab); }); it('transaction at different address does NOT update element', () => { const el = makeElement(); const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('pcf8574')!; logic.attachEvents!(el, sim as any, noPins); sim._fireTransaction(0x20, [0xff]); // wrong address expect((el as any).value).toBeUndefined(); }); it('cleanup calls unregisterSensor(239) and removeI2CTransactionListener(0x27)', () => { const sim = makeEsp32Sim(); const logic = PartSimulationRegistry.get('pcf8574')!; const cleanup = logic.attachEvents!(makeElement(), sim as any, noPins); cleanup(); expect(sim.unregisterSensor).toHaveBeenCalledWith(239); expect(sim.removeI2CTransactionListener).toHaveBeenCalledWith(0x27); }); }); // ─── microSD card — SD-over-SPI storage (Phase 1) ─────────────────────────────── describe('microsd-card — SD-over-SPI storage', () => { function setupSD(props: Record = {}) { const sim = makeSPISim(); const replies: number[] = []; sim.spi.completeTransfer = vi.fn((r: number) => replies.push(r)); const logic = PartSimulationRegistry.get('microsd-card')!; const cleanup = logic.attachEvents!(makeElement(props), sim as any, noPins); const send = (bytes: number[]) => { for (const b of bytes) sim.spi.onByte!(b); }; return { sim, replies, send, cleanup }; } const cmd = (index: number, arg = 0): number[] => [ 0x40 | index, (arg >>> 24) & 0xff, (arg >>> 16) & 0xff, (arg >>> 8) & 0xff, arg & 0xff, 0x95, ]; const FF = (n: number): number[] => new Array(n).fill(0xff); // SDSC byte addressing: block N is byte offset N*512. const at = (block: number): number => block * 512; /** Read a 512-byte block via CMD17 and return its data bytes. */ function readSdBlock( send: (b: number[]) => void, replies: number[], block: number, ): number[] { replies.length = 0; send(cmd(17, at(block))); send(FF(520)); const t = replies.indexOf(0xfe); // data-start token (latency-robust) return replies.slice(t + 1, t + 1 + 512); } it('init handshake: CMD0/CMD8/ACMD41/CMD58 give the expected R1/R7/OCR', () => { const { send, replies } = setupSD(); // 1-byte Ncr latency: the response is shifted out AFTER the 6 command bytes, // so R1 lands on the first 0xFF clock (index 6), not the last command byte. const after = (c: number[], extra: number) => { replies.length = 0; send(c); send(FF(extra)); }; after(cmd(0), 1); expect(replies[6]).toBe(0x01); // idle after(cmd(8, 0x1aa), 5); expect(replies.slice(6, 11)).toEqual([0x01, 0x00, 0x00, 0x01, 0xaa]); // R7 after(cmd(55), 1); expect(replies[6]).toBe(0x01); after(cmd(41), 1); expect(replies[6]).toBe(0x00); // ACMD41 ready after(cmd(58), 5); expect(replies.slice(6, 11)).toEqual([0x00, 0x80, 0xff, 0x80, 0x00]); // OCR (SDSC) }); it('writes a block (CMD24 + data) and reads it back identically (CMD17)', () => { const { send, replies } = setupSD(); const data = Array.from({ length: 512 }, (_, i) => (i * 7 + 3) & 0xff); // CMD24 write block 5, then: gap, start token, 512 data, 2 CRC, + a clock send(cmd(24, at(5))); send([0xff, 0xfe, ...data, 0xff, 0xff, 0xff]); expect(replies).toContain(0x05); // data-response: accepted // Read it back expect(readSdBlock(send, replies, 5)).toEqual(data); }); it('unwritten blocks read back as zeros', () => { const { send, replies } = setupSD(); expect(readSdBlock(send, replies, 999)).toEqual(new Array(512).fill(0)); }); it('CMD9 returns a 16-byte CSD v2 reflecting the configured capacity', () => { const { send, replies } = setupSD(); send(cmd(9)); send(FF(20)); const t = replies.indexOf(0xfe); const csd = replies.slice(t + 1, t + 1 + 16); expect(csd.length).toBe(16); expect(csd[0] & 0xc0).toBe(0x40); // CSD structure v2 // 64 MB -> C_SIZE = 64MB/512KB - 1 = 127 -> low byte 0x7F expect(csd[9]).toBe(0x7f); }); it('reads a pre-injected FAT image via element.sdImageData', () => { const block0 = Array.from({ length: 512 }, (_, i) => (i ^ 0x5a) & 0xff); const block1 = Array.from({ length: 512 }, (_, i) => (i + 200) & 0xff); const image = Uint8Array.from([...block0, ...block1]); const { send, replies } = setupSD({ sdImageData: image }); expect(readSdBlock(send, replies, 0)).toEqual(block0); expect(readSdBlock(send, replies, 1)).toEqual(block1); }); it('multi-block write (CMD25) stores consecutive blocks until the stop token', () => { const { send, replies } = setupSD(); const a = Array.from({ length: 512 }, (_, i) => (i + 1) & 0xff); const b = Array.from({ length: 512 }, (_, i) => (i + 2) & 0xff); send(cmd(25, at(10))); // write starting at block 10 send([0xfc, ...a, 0xff, 0xff]); // block 10 (multi data token 0xFC) send([0xfc, ...b, 0xff, 0xff]); // block 11 send([0xfd]); // stop-transmission token expect(readSdBlock(send, replies, 10)).toEqual(a); expect(readSdBlock(send, replies, 11)).toEqual(b); }); });