velxio/frontend/src/__tests__/protocol-parts.test.ts

1032 lines
40 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<string, unknown> = {}): 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<string, number>) =>
(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<number, (data: number[]) => 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 0127, page address 07 (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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<typeof vi.fn>).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<string, unknown> = {}) {
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);
});
});