diff --git a/frontend/public/components-metadata.json b/frontend/public/components-metadata.json
index f9c895e..0247c73 100644
--- a/frontend/public/components-metadata.json
+++ b/frontend/public/components-metadata.json
@@ -1729,6 +1729,205 @@
"motion",
"sensor"
]
+ },
+ {
+ "id": "lcd1602",
+ "tagName": "wokwi-lcd1602",
+ "name": "LCD 1602",
+ "category": "displays",
+ "thumbnail": "",
+ "properties": [
+ {
+ "name": "color",
+ "type": "string",
+ "defaultValue": "black",
+ "control": "text"
+ },
+ {
+ "name": "background",
+ "type": "string",
+ "defaultValue": "green",
+ "control": "text"
+ },
+ {
+ "name": "pins",
+ "type": "string",
+ "defaultValue": "full",
+ "control": "text"
+ }
+ ],
+ "defaultValues": {
+ "color": "black",
+ "background": "green",
+ "pins": "full"
+ },
+ "pinCount": 16,
+ "tags": [
+ "lcd1602",
+ "lcd 1602",
+ "lcd",
+ "16x2",
+ "1602",
+ "display",
+ "hd44780"
+ ]
+ },
+ {
+ "id": "stepper-motor",
+ "tagName": "wokwi-stepper-motor",
+ "name": "Stepper Motor",
+ "category": "output",
+ "thumbnail": "",
+ "properties": [
+ {
+ "name": "angle",
+ "type": "number",
+ "defaultValue": 0,
+ "control": "text"
+ },
+ {
+ "name": "size",
+ "type": "string",
+ "defaultValue": "23",
+ "control": "text"
+ },
+ {
+ "name": "value",
+ "type": "string",
+ "defaultValue": "",
+ "control": "text"
+ },
+ {
+ "name": "units",
+ "type": "string",
+ "defaultValue": "",
+ "control": "text"
+ }
+ ],
+ "defaultValues": {
+ "angle": 0,
+ "size": "23",
+ "value": "",
+ "units": ""
+ },
+ "pinCount": 4,
+ "tags": [
+ "stepper-motor",
+ "stepper motor",
+ "stepper",
+ "motor",
+ "nema"
+ ]
+ },
+ {
+ "id": "tilt-switch",
+ "tagName": "wokwi-tilt-switch",
+ "name": "Tilt Switch",
+ "category": "sensors",
+ "thumbnail": "",
+ "properties": [],
+ "defaultValues": {},
+ "pinCount": 3,
+ "tags": [
+ "tilt-switch",
+ "tilt switch",
+ "tilt",
+ "ball switch"
+ ]
+ },
+ {
+ "id": "ntc-temperature-sensor",
+ "tagName": "wokwi-ntc-temperature-sensor",
+ "name": "NTC Temperature Sensor",
+ "category": "sensors",
+ "thumbnail": "",
+ "properties": [],
+ "defaultValues": {},
+ "pinCount": 3,
+ "tags": [
+ "ntc-temperature-sensor",
+ "ntc temperature sensor",
+ "ntc",
+ "temperature",
+ "sensor",
+ "thermistor"
+ ]
+ },
+ {
+ "id": "heart-beat-sensor",
+ "tagName": "wokwi-heart-beat-sensor",
+ "name": "Heart Beat Sensor",
+ "category": "sensors",
+ "thumbnail": "",
+ "properties": [],
+ "defaultValues": {},
+ "pinCount": 3,
+ "tags": [
+ "heart-beat-sensor",
+ "heart beat sensor",
+ "heart",
+ "pulse",
+ "sensor"
+ ]
+ },
+ {
+ "id": "neopixel-matrix",
+ "tagName": "wokwi-neopixel-matrix",
+ "name": "NeoPixel Matrix",
+ "category": "displays",
+ "thumbnail": "",
+ "properties": [
+ {
+ "name": "rows",
+ "type": "number",
+ "defaultValue": 8,
+ "control": "text"
+ },
+ {
+ "name": "cols",
+ "type": "number",
+ "defaultValue": 8,
+ "control": "text"
+ }
+ ],
+ "defaultValues": {
+ "rows": 8,
+ "cols": 8
+ },
+ "pinCount": 4,
+ "tags": [
+ "neopixel-matrix",
+ "neopixel matrix",
+ "ws2812b",
+ "led matrix",
+ "neopixel"
+ ]
+ },
+ {
+ "id": "led-ring",
+ "tagName": "wokwi-led-ring",
+ "name": "WS2812B LED Ring",
+ "category": "displays",
+ "thumbnail": "",
+ "properties": [
+ {
+ "name": "pixels",
+ "type": "number",
+ "defaultValue": 16,
+ "control": "text"
+ }
+ ],
+ "defaultValues": {
+ "pixels": 16
+ },
+ "pinCount": 4,
+ "tags": [
+ "led-ring",
+ "led ring",
+ "ws2812b",
+ "neopixel ring",
+ "ring"
+ ]
}
]
}
\ No newline at end of file
diff --git a/frontend/public/og-image.png b/frontend/public/og-image.png
index 7fcba51..660a7fb 100644
Binary files a/frontend/public/og-image.png and b/frontend/public/og-image.png differ
diff --git a/frontend/src/__tests__/motor-parts.test.ts b/frontend/src/__tests__/motor-parts.test.ts
new file mode 100644
index 0000000..912da8f
--- /dev/null
+++ b/frontend/src/__tests__/motor-parts.test.ts
@@ -0,0 +1,291 @@
+/**
+ * motor-parts.test.ts
+ *
+ * Tests simulation logic for interactive motor/encoder components:
+ * - ky-040 rotary encoder (BasicParts.ts)
+ * - biaxial-stepper motor (BasicParts.ts)
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry';
+
+// Side-effect imports — register all parts
+import '../simulation/parts/BasicParts';
+import '../simulation/parts/ComplexParts';
+import '../simulation/parts/ChipParts';
+import '../simulation/parts/SensorParts';
+
+// ─── Mocks ────────────────────────────────────────────────────────────────────
+
+beforeEach(() => {
+ let counter = 0;
+ vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => ++counter);
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
+ // Allow setTimeout to be called but track it (not immediately invoked)
+ vi.stubGlobal('setTimeout', vi.fn().mockReturnValue(1));
+ vi.stubGlobal('clearTimeout', vi.fn());
+});
+afterEach(() => vi.unstubAllGlobals());
+
+// ─── Mock factories ───────────────────────────────────────────────────────────
+
+function makeElement(props: Record = {}): HTMLElement {
+ return {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ ...props,
+ } as unknown as HTMLElement;
+}
+
+function makeSimulator() {
+ const pinManager = {
+ onPinChange: vi.fn().mockReturnValue(() => {}),
+ onPwmChange: vi.fn().mockReturnValue(() => {}),
+ triggerPinChange: vi.fn(),
+ };
+ return {
+ pinManager,
+ setPinState: 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;
+
+// ─── KY-040 Rotary Encoder ────────────────────────────────────────────────────
+
+describe('ky-040 — registration', () => {
+ it('is registered in PartSimulationRegistry', () => {
+ expect(PartSimulationRegistry.get('ky-040')).toBeDefined();
+ });
+});
+
+describe('ky-040 — attachEvents', () => {
+ it('initialises CLK, DT HIGH and SW HIGH (not pressed) on attach', () => {
+ const logic = PartSimulationRegistry.get('ky-040')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ logic.attachEvents!(element, sim as any, pinMap({ CLK: 2, DT: 3, SW: 4 }));
+
+ // All three pins should have been set HIGH on init
+ expect(sim.setPinState).toHaveBeenCalledWith(4, true); // SW HIGH
+ expect(sim.setPinState).toHaveBeenCalledWith(2, true); // CLK HIGH (idle)
+ expect(sim.setPinState).toHaveBeenCalledWith(3, true); // DT HIGH (idle)
+ });
+
+ it('registers event listeners for rotate-cw, rotate-ccw, button-press, button-release', () => {
+ const logic = PartSimulationRegistry.get('ky-040')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ logic.attachEvents!(element, sim as any, pinMap({ CLK: 2, DT: 3, SW: 4 }));
+
+ const events = (element.addEventListener as ReturnType).mock.calls.map(
+ ([event]: [string]) => event
+ );
+ expect(events).toContain('rotate-cw');
+ expect(events).toContain('rotate-ccw');
+ expect(events).toContain('button-press');
+ expect(events).toContain('button-release');
+ });
+
+ it('pulses CLK with DT LOW on rotate-cw (clockwise)', () => {
+ const logic = PartSimulationRegistry.get('ky-040')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ const listeners: Record void> = {};
+ (element.addEventListener as ReturnType).mockImplementation(
+ (ev: string, handler: (...args: any[]) => void) => { listeners[ev] = handler; }
+ );
+
+ logic.attachEvents!(element, sim as any, pinMap({ CLK: 2, DT: 3, SW: 4 }));
+ sim.setPinState.mockClear();
+
+ listeners['rotate-cw']?.();
+
+ // DT should be set LOW for CW direction
+ expect(sim.setPinState).toHaveBeenCalledWith(3, false); // DT LOW
+ // CLK should be set LOW first then HIGH (via setTimeout)
+ expect(sim.setPinState).toHaveBeenCalledWith(2, false); // CLK pulse starts LOW
+ });
+
+ it('pulses CLK with DT HIGH on rotate-ccw (counter-clockwise)', () => {
+ const logic = PartSimulationRegistry.get('ky-040')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ const listeners: Record void> = {};
+ (element.addEventListener as ReturnType).mockImplementation(
+ (ev: string, handler: (...args: any[]) => void) => { listeners[ev] = handler; }
+ );
+
+ logic.attachEvents!(element, sim as any, pinMap({ CLK: 2, DT: 3, SW: 4 }));
+ sim.setPinState.mockClear();
+
+ listeners['rotate-ccw']?.();
+
+ // DT should be set HIGH for CCW direction
+ expect(sim.setPinState).toHaveBeenCalledWith(3, true); // DT HIGH
+ expect(sim.setPinState).toHaveBeenCalledWith(2, false); // CLK LOW
+ });
+
+ it('drives SW pin LOW on button-press and HIGH on button-release', () => {
+ const logic = PartSimulationRegistry.get('ky-040')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ const listeners: Record void> = {};
+ (element.addEventListener as ReturnType).mockImplementation(
+ (ev: string, handler: (...args: any[]) => void) => { listeners[ev] = handler; }
+ );
+
+ logic.attachEvents!(element, sim as any, pinMap({ CLK: 2, DT: 3, SW: 4 }));
+ sim.setPinState.mockClear();
+
+ listeners['button-press']?.();
+ expect(sim.setPinState).toHaveBeenCalledWith(4, false); // SW active LOW
+
+ sim.setPinState.mockClear();
+ listeners['button-release']?.();
+ expect(sim.setPinState).toHaveBeenCalledWith(4, true); // SW release HIGH
+ });
+
+ it('removes all event listeners on cleanup', () => {
+ const logic = PartSimulationRegistry.get('ky-040')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ const cleanup = logic.attachEvents!(element, sim as any, pinMap({ CLK: 2, DT: 3, SW: 4 }));
+ cleanup();
+
+ expect(element.removeEventListener).toHaveBeenCalledWith('rotate-cw', expect.any(Function));
+ expect(element.removeEventListener).toHaveBeenCalledWith('rotate-ccw', expect.any(Function));
+ expect(element.removeEventListener).toHaveBeenCalledWith('button-press', expect.any(Function));
+ expect(element.removeEventListener).toHaveBeenCalledWith('button-release', expect.any(Function));
+ });
+});
+
+// ─── Biaxial Stepper Motor ────────────────────────────────────────────────────
+
+describe('biaxial-stepper — registration', () => {
+ it('is registered in PartSimulationRegistry', () => {
+ expect(PartSimulationRegistry.get('biaxial-stepper')).toBeDefined();
+ });
+});
+
+describe('biaxial-stepper — attachEvents', () => {
+ it('registers 8 coil pin-change listeners (4 per motor)', () => {
+ const logic = PartSimulationRegistry.get('biaxial-stepper')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+ el.outerHandAngle = 0;
+ el.innerHandAngle = 0;
+
+ logic.attachEvents!(el, sim as any, pinMap({
+ 'A1-': 2, 'A1+': 3, 'B1+': 4, 'B1-': 5,
+ 'A2-': 6, 'A2+': 7, 'B2+': 8, 'B2-': 9,
+ }));
+
+ expect(sim.pinManager.onPinChange).toHaveBeenCalledTimes(8);
+ });
+
+ it('advances outerHandAngle by 1.8° per forward step on motor 1', () => {
+ const logic = PartSimulationRegistry.get('biaxial-stepper')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+ el.outerHandAngle = 0;
+ el.innerHandAngle = 0;
+
+ logic.attachEvents!(el, sim as any, pinMap({
+ 'A1-': 2, 'A1+': 3, 'B1+': 4, 'B1-': 5,
+ 'A2-': 6, 'A2+': 7, 'B2+': 8, 'B2-': 9,
+ }));
+
+ // Collect handlers indexed by pin number
+ const handlers: Record void> = {};
+ for (const [pin, handler] of sim.pinManager.onPinChange.mock.calls) {
+ handlers[pin as number] = handler;
+ }
+
+ // Full-step sequence motor 1:
+ // Step 0: A1+ = HIGH
+ handlers[3]?.(3, true); // A1+ HIGH → step 0
+
+ // Step 1: A1+ = LOW, B1+ = HIGH → forward step
+ handlers[3]?.(3, false); // A1+ LOW
+ handlers[4]?.(4, true); // B1+ HIGH → step 1
+
+ expect(el.outerHandAngle).toBeCloseTo(1.8, 1);
+ });
+
+ it('advances innerHandAngle by 1.8° per forward step on motor 2', () => {
+ const logic = PartSimulationRegistry.get('biaxial-stepper')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+ el.outerHandAngle = 0;
+ el.innerHandAngle = 0;
+
+ logic.attachEvents!(el, sim as any, pinMap({
+ 'A1-': 2, 'A1+': 3, 'B1+': 4, 'B1-': 5,
+ 'A2-': 6, 'A2+': 7, 'B2+': 8, 'B2-': 9,
+ }));
+
+ const handlers: Record void> = {};
+ for (const [pin, handler] of sim.pinManager.onPinChange.mock.calls) {
+ handlers[pin as number] = handler;
+ }
+
+ // Step 0 for motor 2: A2+ = HIGH
+ handlers[7]?.(7, true);
+
+ // Step 1 for motor 2: A2+ = LOW, B2+ = HIGH
+ handlers[7]?.(7, false);
+ handlers[8]?.(8, true);
+
+ expect(el.innerHandAngle).toBeCloseTo(1.8, 1);
+ });
+
+ it('reverses outerHandAngle when motor 1 steps backwards', () => {
+ const logic = PartSimulationRegistry.get('biaxial-stepper')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+ el.outerHandAngle = 0;
+ el.innerHandAngle = 0;
+
+ logic.attachEvents!(el, sim as any, pinMap({
+ 'A1-': 2, 'A1+': 3, 'B1+': 4, 'B1-': 5,
+ 'A2-': 6, 'A2+': 7, 'B2+': 8, 'B2-': 9,
+ }));
+
+ const handlers: Record void> = {};
+ for (const [pin, handler] of sim.pinManager.onPinChange.mock.calls) {
+ handlers[pin as number] = handler;
+ }
+
+ // Start at step 0: A1+ HIGH
+ handlers[3]?.(3, true);
+
+ // Step backwards to step 3: B1- HIGH, A1+ LOW → diff = -1 (= +3 mod 4)
+ handlers[3]?.(3, false);
+ handlers[5]?.(5, true); // B1- HIGH → step 3 (backwards from step 0)
+
+ expect(el.outerHandAngle).toBeCloseTo(360 - 1.8, 1);
+ });
+
+ it('does nothing when no coil pins are connected', () => {
+ const logic = PartSimulationRegistry.get('biaxial-stepper')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+ el.outerHandAngle = 0;
+ el.innerHandAngle = 0;
+
+ logic.attachEvents!(el, sim as any, noPins);
+
+ expect(sim.pinManager.onPinChange).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/__tests__/sensor-parts.test.ts b/frontend/src/__tests__/sensor-parts.test.ts
new file mode 100644
index 0000000..1f72395
--- /dev/null
+++ b/frontend/src/__tests__/sensor-parts.test.ts
@@ -0,0 +1,413 @@
+/**
+ * sensor-parts.test.ts
+ *
+ * Tests simulation logic for sensor and stepper-motor components registered
+ * in SensorParts.ts (and stepper-motor in particular).
+ */
+
+import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
+import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegistry';
+
+// Side-effect imports — register all parts (including SensorParts)
+import '../simulation/parts/BasicParts';
+import '../simulation/parts/ComplexParts';
+import '../simulation/parts/ChipParts';
+import '../simulation/parts/SensorParts';
+
+// ─── RAF mock (no-op to prevent infinite loops) ───────────────────────────────
+beforeEach(() => {
+ let counter = 0;
+ vi.stubGlobal('requestAnimationFrame', (_cb: FrameRequestCallback) => ++counter);
+ vi.stubGlobal('cancelAnimationFrame', vi.fn());
+ vi.stubGlobal('setInterval', vi.fn().mockReturnValue(42));
+ vi.stubGlobal('clearInterval', vi.fn());
+ vi.stubGlobal('setTimeout', vi.fn().mockReturnValue(1));
+ vi.stubGlobal('clearTimeout', vi.fn());
+});
+afterEach(() => vi.unstubAllGlobals());
+
+// ─── Mock factories ───────────────────────────────────────────────────────────
+
+function makeElement(props: Record = {}): HTMLElement {
+ return {
+ addEventListener: vi.fn(),
+ removeEventListener: vi.fn(),
+ ...props,
+ } as unknown as HTMLElement;
+}
+
+function makeADC() {
+ return { channelValues: new Array(8).fill(0) };
+}
+
+function makeSimulator(adc?: ReturnType | null) {
+ const pinManager = {
+ onPinChange: vi.fn().mockReturnValue(() => {}),
+ onPwmChange: vi.fn().mockReturnValue(() => {}),
+ triggerPinChange: vi.fn(),
+ };
+ return {
+ pinManager,
+ getADC: vi.fn().mockReturnValue(adc ?? null),
+ setPinState: 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;
+
+// ─── SensorParts registration check ──────────────────────────────────────────
+
+describe('SensorParts — registration', () => {
+ const SENSOR_IDS = [
+ 'tilt-switch',
+ 'ntc-temperature-sensor',
+ 'gas-sensor',
+ 'flame-sensor',
+ 'heart-beat-sensor',
+ 'big-sound-sensor',
+ 'small-sound-sensor',
+ 'stepper-motor',
+ 'led-ring',
+ 'neopixel-matrix',
+ ];
+
+ it('registers all sensor and stepper component types', () => {
+ for (const id of SENSOR_IDS) {
+ expect(PartSimulationRegistry.get(id), `missing: ${id}`).toBeDefined();
+ }
+ });
+});
+
+// ─── Tilt Switch ─────────────────────────────────────────────────────────────
+
+describe('tilt-switch — attachEvents', () => {
+ it('sets OUT pin LOW on attach (upright), then HIGH after click, then LOW again', () => {
+ const logic = PartSimulationRegistry.get('tilt-switch')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ // Capture addEventListener calls
+ const listeners: Record void> = {};
+ (element.addEventListener as ReturnType).mockImplementation(
+ (event: string, handler: (...args: any[]) => void) => {
+ listeners[event] = handler;
+ }
+ );
+
+ logic.attachEvents!(element, sim as any, pinMap({ OUT: 14 }));
+
+ // Should have started LOW (upright)
+ expect(sim.setPinState).toHaveBeenCalledWith(14, false);
+
+ // First click — tilts HIGH
+ sim.setPinState.mockClear();
+ listeners['click']?.();
+ expect(sim.setPinState).toHaveBeenCalledWith(14, true);
+
+ // Second click — returns LOW
+ sim.setPinState.mockClear();
+ listeners['click']?.();
+ expect(sim.setPinState).toHaveBeenCalledWith(14, false);
+ });
+
+ it('does nothing when OUT pin is not connected', () => {
+ const logic = PartSimulationRegistry.get('tilt-switch')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ const cleanup = logic.attachEvents!(element, sim as any, noPins);
+ expect(cleanup).toBeDefined();
+ expect(sim.setPinState).not.toHaveBeenCalled();
+ });
+});
+
+// ─── NTC Temperature Sensor ──────────────────────────────────────────────────
+
+describe('ntc-temperature-sensor — attachEvents', () => {
+ it('injects 2.5V (mid-range) on OUT pin at room temperature', () => {
+ const logic = PartSimulationRegistry.get('ntc-temperature-sensor')!;
+ const adc = makeADC();
+ const sim = makeSimulator(adc);
+ const element = makeElement();
+
+ logic.attachEvents!(element, sim as any, pinMap({ OUT: 14 }));
+
+ // Pin 14 = ADC channel 0. 2.5V should be stored in channelValues[0]
+ expect(adc.channelValues[0]).toBeCloseTo(2.5, 2);
+ });
+
+ it('does nothing when OUT pin is not connected', () => {
+ const logic = PartSimulationRegistry.get('ntc-temperature-sensor')!;
+ const adc = makeADC();
+ const sim = makeSimulator(adc);
+ const element = makeElement();
+
+ logic.attachEvents!(element, sim as any, noPins);
+ // ADC should remain zeroed
+ expect(adc.channelValues[0]).toBe(0);
+ });
+});
+
+// ─── Gas Sensor ──────────────────────────────────────────────────────────────
+
+describe('gas-sensor — attachEvents', () => {
+ it('injects baseline analog voltage on AOUT and sets ledPower=true', () => {
+ const logic = PartSimulationRegistry.get('gas-sensor')!;
+ const adc = makeADC();
+ const sim = makeSimulator(adc);
+ const el = makeElement() as any;
+
+ logic.attachEvents!(el, sim as any, pinMap({ AOUT: 14, DOUT: 7 }));
+
+ // AOUT → ADC channel 0, baseline 1.5V
+ expect(adc.channelValues[0]).toBeCloseTo(1.5, 2);
+ expect(el.ledPower).toBe(true);
+ });
+
+ it('registers pin-change listener for DOUT to update ledD0', () => {
+ const logic = PartSimulationRegistry.get('gas-sensor')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+
+ logic.attachEvents!(el, sim as any, pinMap({ DOUT: 7 }));
+
+ // Should have registered a onPinChange listener for DOUT (pin 7)
+ expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(7, expect.any(Function));
+
+ // Simulate DOUT going HIGH → ledD0 should update
+ const handler = sim.pinManager.onPinChange.mock.calls[0][1];
+ handler(7, true);
+ expect(el.ledD0).toBe(true);
+
+ handler(7, false);
+ expect(el.ledD0).toBe(false);
+ });
+});
+
+// ─── Flame Sensor ────────────────────────────────────────────────────────────
+
+describe('flame-sensor — attachEvents', () => {
+ it('injects baseline analog voltage on AOUT and sets ledPower=true', () => {
+ const logic = PartSimulationRegistry.get('flame-sensor')!;
+ const adc = makeADC();
+ const sim = makeSimulator(adc);
+ const el = makeElement() as any;
+
+ logic.attachEvents!(el, sim as any, pinMap({ AOUT: 14 }));
+
+ expect(adc.channelValues[0]).toBeCloseTo(1.5, 2);
+ expect(el.ledPower).toBe(true);
+ });
+
+ it('updates ledSignal when DOUT pin state changes', () => {
+ const logic = PartSimulationRegistry.get('flame-sensor')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+
+ logic.attachEvents!(el, sim as any, pinMap({ DOUT: 8 }));
+
+ expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(8, expect.any(Function));
+
+ const handler = sim.pinManager.onPinChange.mock.calls[0][1];
+ handler(8, true);
+ expect(el.ledSignal).toBe(true);
+ handler(8, false);
+ expect(el.ledSignal).toBe(false);
+ });
+});
+
+// ─── Heart Beat Sensor ───────────────────────────────────────────────────────
+
+describe('heart-beat-sensor — attachEvents', () => {
+ it('starts OUT pin LOW and sets up an interval for pulse generation', () => {
+ const logic = PartSimulationRegistry.get('heart-beat-sensor')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ logic.attachEvents!(element, sim as any, pinMap({ OUT: 14 }));
+
+ // Should start LOW
+ expect(sim.setPinState).toHaveBeenCalledWith(14, false);
+ // Should have called setInterval
+ expect(setInterval).toHaveBeenCalled();
+ });
+
+ it('clears the interval on cleanup', () => {
+ const logic = PartSimulationRegistry.get('heart-beat-sensor')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ const cleanup = logic.attachEvents!(element, sim as any, pinMap({ OUT: 14 }));
+ cleanup();
+
+ expect(clearInterval).toHaveBeenCalledWith(42); // 42 is the mock return from setInterval
+ });
+
+ it('does nothing when OUT pin is not connected', () => {
+ const logic = PartSimulationRegistry.get('heart-beat-sensor')!;
+ const sim = makeSimulator();
+ const element = makeElement();
+
+ logic.attachEvents!(element, sim as any, noPins);
+ expect(setInterval).not.toHaveBeenCalled();
+ });
+});
+
+// ─── Big Sound Sensor ────────────────────────────────────────────────────────
+
+describe('big-sound-sensor — attachEvents', () => {
+ it('injects 2.5V on AOUT and sets led2=true (power LED)', () => {
+ const logic = PartSimulationRegistry.get('big-sound-sensor')!;
+ const adc = makeADC();
+ const sim = makeSimulator(adc);
+ const el = makeElement() as any;
+
+ logic.attachEvents!(el, sim as any, pinMap({ AOUT: 14 }));
+
+ expect(adc.channelValues[0]).toBeCloseTo(2.5, 2);
+ expect(el.led2).toBe(true);
+ });
+
+ it('updates led1 when DOUT pin changes', () => {
+ const logic = PartSimulationRegistry.get('big-sound-sensor')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+
+ logic.attachEvents!(el, sim as any, pinMap({ DOUT: 9 }));
+
+ expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(9, expect.any(Function));
+ const handler = sim.pinManager.onPinChange.mock.calls[0][1];
+ handler(9, true); expect(el.led1).toBe(true);
+ handler(9, false); expect(el.led1).toBe(false);
+ });
+});
+
+// ─── Small Sound Sensor ──────────────────────────────────────────────────────
+
+describe('small-sound-sensor — attachEvents', () => {
+ it('injects 2.5V on AOUT and sets ledPower=true', () => {
+ const logic = PartSimulationRegistry.get('small-sound-sensor')!;
+ const adc = makeADC();
+ const sim = makeSimulator(adc);
+ const el = makeElement() as any;
+
+ logic.attachEvents!(el, sim as any, pinMap({ AOUT: 14 }));
+
+ expect(adc.channelValues[0]).toBeCloseTo(2.5, 2);
+ expect(el.ledPower).toBe(true);
+ });
+
+ it('updates ledSignal when DOUT pin changes', () => {
+ const logic = PartSimulationRegistry.get('small-sound-sensor')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+
+ logic.attachEvents!(el, sim as any, pinMap({ DOUT: 10 }));
+
+ const handler = sim.pinManager.onPinChange.mock.calls[0][1];
+ handler(10, true); expect(el.ledSignal).toBe(true);
+ handler(10, false); expect(el.ledSignal).toBe(false);
+ });
+});
+
+// ─── Stepper Motor ───────────────────────────────────────────────────────────
+
+describe('stepper-motor — attachEvents', () => {
+ it('registers pin-change listeners for all 4 coil pins', () => {
+ const logic = PartSimulationRegistry.get('stepper-motor')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+ el.angle = 0;
+
+ const pins = { 'A-': 4, 'A+': 5, 'B+': 6, 'B-': 7 };
+ logic.attachEvents!(el, sim as any, pinMap(pins));
+
+ expect(sim.pinManager.onPinChange).toHaveBeenCalledTimes(4);
+ const registeredPins = sim.pinManager.onPinChange.mock.calls.map(([p]: [number]) => p);
+ expect(registeredPins).toEqual(expect.arrayContaining([4, 5, 6, 7]));
+ });
+
+ it('advances angle by 1.8° per forward step (full-step sequence)', () => {
+ const logic = PartSimulationRegistry.get('stepper-motor')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+ el.angle = 0;
+
+ const pins = { 'A-': 4, 'A+': 5, 'B+': 6, 'B-': 7 };
+ logic.attachEvents!(el, sim as any, pinMap(pins));
+
+ // Collect handlers indexed by pin number
+ const handlers: Record void> = {};
+ for (const [pin, handler] of sim.pinManager.onPinChange.mock.calls) {
+ handlers[pin as number] = handler;
+ }
+
+ // Step 0: A+ = HIGH (others LOW)
+ handlers[5]?.(5, true); // A+
+
+ // Step 1: B+ = HIGH, A+ = LOW → should advance angle
+ handlers[5]?.(5, false); // A+ LOW
+ handlers[6]?.(6, true); // B+ HIGH
+
+ expect(el.angle).toBeCloseTo(1.8, 1);
+ });
+
+ it('does nothing with zero coil pins connected', () => {
+ const logic = PartSimulationRegistry.get('stepper-motor')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+ el.angle = 0;
+
+ logic.attachEvents!(el, sim as any, noPins);
+
+ expect(sim.pinManager.onPinChange).not.toHaveBeenCalled();
+ expect(el.angle).toBe(0);
+ });
+});
+
+// ─── LED Ring (NeoPixel) ─────────────────────────────────────────────────────
+
+describe('led-ring — attachEvents', () => {
+ it('registers a pin-change listener on the DIN pin', () => {
+ const logic = PartSimulationRegistry.get('led-ring')!;
+ const sim = makeSimulator();
+ sim.cpu = { data: new Uint8Array(512), cycles: 0 } as any;
+ const el = makeElement() as any;
+ el.setPixel = vi.fn();
+
+ logic.attachEvents!(el, sim as any, pinMap({ DIN: 6 }));
+
+ expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(6, expect.any(Function));
+ });
+
+ it('does nothing when DIN pin is not connected', () => {
+ const logic = PartSimulationRegistry.get('led-ring')!;
+ const sim = makeSimulator();
+ const el = makeElement() as any;
+
+ const cleanup = logic.attachEvents!(el, sim as any, noPins);
+ expect(cleanup).toBeDefined();
+ expect(sim.pinManager.onPinChange).not.toHaveBeenCalled();
+ });
+});
+
+// ─── NeoPixel Matrix ─────────────────────────────────────────────────────────
+
+describe('neopixel-matrix — attachEvents', () => {
+ it('registers a pin-change listener on the DIN pin', () => {
+ const logic = PartSimulationRegistry.get('neopixel-matrix')!;
+ const sim = makeSimulator();
+ sim.cpu = { data: new Uint8Array(512), cycles: 0 } as any;
+ const el = makeElement() as any;
+ el.setPixel = vi.fn();
+ el.cols = 8;
+
+ logic.attachEvents!(el, sim as any, pinMap({ DIN: 6 }));
+
+ expect(sim.pinManager.onPinChange).toHaveBeenCalledWith(6, expect.any(Function));
+ });
+});
diff --git a/frontend/src/__tests__/simulation-parts.test.ts b/frontend/src/__tests__/simulation-parts.test.ts
index 979ca7a..6dd6d2b 100644
--- a/frontend/src/__tests__/simulation-parts.test.ts
+++ b/frontend/src/__tests__/simulation-parts.test.ts
@@ -21,6 +21,8 @@ import { PartSimulationRegistry } from '../simulation/parts/PartSimulationRegist
// Side-effect imports — register all parts
import '../simulation/parts/BasicParts';
import '../simulation/parts/ComplexParts';
+import '../simulation/parts/ChipParts';
+import '../simulation/parts/SensorParts';
// ─── RAF depth-limited mock ───────────────────────────────────────────────────
// Calls the callback once synchronously but prevents re-entrancy so that
diff --git a/frontend/src/simulation/parts/BasicParts.ts b/frontend/src/simulation/parts/BasicParts.ts
index 2c4cf1e..bbcb955 100644
--- a/frontend/src/simulation/parts/BasicParts.ts
+++ b/frontend/src/simulation/parts/BasicParts.ts
@@ -164,33 +164,148 @@ PartSimulationRegistry.register('led-bar-graph', {
},
});
+// NOTE: '7segment' is registered in ChipParts.ts which supports both direct-drive
+// and 74HC595-driven modes. Do not re-register it here.
+
+// ─── KY-040 Rotary Encoder ───────────────────────────────────────────────────
+
/**
- * 7-Segment Display
- * Pins: A, B, C, D, E, F, G, DP (common cathode — segments light when HIGH)
- * The wokwi-7segment 'values' property is an array of 8 values (A B C D E F G DP)
+ * KY-040 rotary encoder — maps element events to Arduino CLK/DT/SW pins.
+ *
+ * The element emits:
+ * - 'rotate-cw' → clockwise step
+ * - 'rotate-ccw' → counter-clockwise step
+ * - 'button-press' → push-button pressed
+ * - 'button-release' → push-button released
+ *
+ * Most Arduino encoder libraries sample CLK and read DT on a CLK rising edge:
+ * DT LOW on CLK rising → clockwise
+ * DT HIGH on CLK rising → counter-clockwise
+ *
+ * The SW pin is active LOW (HIGH when not pressed).
*/
-PartSimulationRegistry.register('7segment', {
- attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
- const pinManager = (avrSimulator as any).pinManager;
- if (!pinManager) return () => { };
+PartSimulationRegistry.register('ky-040', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pinCLK = getArduinoPinHelper('CLK');
+ const pinDT = getArduinoPinHelper('DT');
+ const pinSW = getArduinoPinHelper('SW');
- // Order matches wokwi-elements values array: [A, B, C, D, E, F, G, DP]
- const segmentPinNames = ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'DP'];
- const values = new Array(8).fill(0);
- const unsubscribers: (() => void)[] = [];
+ // SW starts HIGH (not pressed, active LOW)
+ if (pinSW !== null) simulator.setPinState(pinSW, true);
+ // CLK and DT start HIGH (idle)
+ if (pinCLK !== null) simulator.setPinState(pinCLK, true);
+ if (pinDT !== null) simulator.setPinState(pinDT, true);
- segmentPinNames.forEach((segName, idx) => {
- const pin = getArduinoPinHelper(segName);
- if (pin !== null) {
- unsubscribers.push(
- pinManager.onPinChange(pin, (_p: number, state: boolean) => {
- values[idx] = state ? 1 : 0;
- (element as any).values = [...values];
- })
- );
+ /** Emit one encoder pulse: set DT to dtLevel, pulse CLK HIGH→LOW. */
+ function emitPulse(dtLevel: boolean) {
+ if (pinDT !== null) simulator.setPinState(pinDT, dtLevel);
+ if (pinCLK !== null) {
+ simulator.setPinState(pinCLK, false); // CLK LOW first
+ // Small delay then CLK rising edge (encoder sampled on rising edge)
+ setTimeout(() => {
+ if (pinCLK !== null) simulator.setPinState(pinCLK, true);
+ setTimeout(() => {
+ if (pinCLK !== null) simulator.setPinState(pinCLK, false);
+ if (pinDT !== null) simulator.setPinState(pinDT, true); // restore DT
+ }, 1);
+ }, 1);
}
- });
+ }
- return () => unsubscribers.forEach(u => u());
+ const onCW = () => emitPulse(false); // DT LOW = CW
+ const onCCW = () => emitPulse(true); // DT HIGH = CCW
+ const onPress = () => { if (pinSW !== null) simulator.setPinState(pinSW, false); };
+ const onRelease = () => { if (pinSW !== null) simulator.setPinState(pinSW, true); };
+
+ element.addEventListener('rotate-cw', onCW);
+ element.addEventListener('rotate-ccw', onCCW);
+ element.addEventListener('button-press', onPress);
+ element.addEventListener('button-release', onRelease);
+
+ return () => {
+ element.removeEventListener('rotate-cw', onCW);
+ element.removeEventListener('rotate-ccw', onCCW);
+ element.removeEventListener('button-press', onPress);
+ element.removeEventListener('button-release', onRelease);
+ };
+ },
+});
+
+// ─── Biaxial Stepper Motor ────────────────────────────────────────────────────
+
+/**
+ * Biaxial stepper motor — monitors 8 coil pins for two independent motors.
+ *
+ * Motor 1 pins: A1-, A1+, B1+, B1- → outerHandAngle
+ * Motor 2 pins: A2-, A2+, B2+, B2- → innerHandAngle
+ *
+ * Full-step decode: each motor uses the same 4-step lookup table as
+ * the single stepper-motor. 1.8° per step.
+ */
+PartSimulationRegistry.register('biaxial-stepper', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pinManager = (simulator as any).pinManager;
+ if (!pinManager) return () => {};
+
+ const el = element as any;
+ const STEP_ANGLE = 1.8;
+
+ // Full-step table: [A+, B+, A-, B-]
+ const stepTable: [boolean, boolean, boolean, boolean][] = [
+ [true, false, false, false],
+ [false, true, false, false],
+ [false, false, true, false],
+ [false, false, false, true],
+ ];
+
+ function stepIndexFromCoils(ap: boolean, bp: boolean, am: boolean, bm: boolean): number {
+ for (let i = 0; i < stepTable.length; i++) {
+ const [tap, tbp, tam, tbm] = stepTable[i];
+ if (ap === tap && bp === tbp && am === tam && bm === tbm) return i;
+ }
+ return -1;
+ }
+
+ function makeMotorTracker(
+ pinAminus: number | null, pinAplus: number | null,
+ pinBplus: number | null, pinBminus: number | null,
+ setAngle: (deg: number) => void,
+ ) {
+ let aMinus = false, aPlus = false, bPlus = false, bMinus = false;
+ let cumAngle = 0;
+ let prevIdx = -1;
+ const unsubs: (() => void)[] = [];
+
+ function onCoilChange() {
+ const idx = stepIndexFromCoils(aPlus, bPlus, aMinus, bMinus);
+ if (idx < 0) return;
+ if (prevIdx < 0) { prevIdx = idx; return; }
+ const diff = (idx - prevIdx + 4) % 4;
+ if (diff === 1) cumAngle += STEP_ANGLE;
+ else if (diff === 3) cumAngle -= STEP_ANGLE;
+ prevIdx = idx;
+ setAngle(((cumAngle % 360) + 360) % 360);
+ }
+
+ if (pinAminus !== null) unsubs.push(pinManager.onPinChange(pinAminus, (_: number, s: boolean) => { aMinus = s; onCoilChange(); }));
+ if (pinAplus !== null) unsubs.push(pinManager.onPinChange(pinAplus, (_: number, s: boolean) => { aPlus = s; onCoilChange(); }));
+ if (pinBplus !== null) unsubs.push(pinManager.onPinChange(pinBplus, (_: number, s: boolean) => { bPlus = s; onCoilChange(); }));
+ if (pinBminus !== null) unsubs.push(pinManager.onPinChange(pinBminus, (_: number, s: boolean) => { bMinus = s; onCoilChange(); }));
+
+ return () => unsubs.forEach(u => u());
+ }
+
+ const cleanup1 = makeMotorTracker(
+ getArduinoPinHelper('A1-'), getArduinoPinHelper('A1+'),
+ getArduinoPinHelper('B1+'), getArduinoPinHelper('B1-'),
+ (deg) => { el.outerHandAngle = deg; },
+ );
+ const cleanup2 = makeMotorTracker(
+ getArduinoPinHelper('A2-'), getArduinoPinHelper('A2+'),
+ getArduinoPinHelper('B2+'), getArduinoPinHelper('B2-'),
+ (deg) => { el.innerHandAngle = deg; },
+ );
+
+ return () => { cleanup1(); cleanup2(); };
},
});
diff --git a/frontend/src/simulation/parts/ComplexParts.ts b/frontend/src/simulation/parts/ComplexParts.ts
index db23b30..9500188 100644
--- a/frontend/src/simulation/parts/ComplexParts.ts
+++ b/frontend/src/simulation/parts/ComplexParts.ts
@@ -1,43 +1,10 @@
import { PartSimulationRegistry } from './PartSimulationRegistry';
import type { AnySimulator } from './PartSimulationRegistry';
import { RP2040Simulator } from '../RP2040Simulator';
+import { getADC, setAdcVoltage } from './partUtils';
// ─── Helpers ────────────────────────────────────────────────────────────────
-/** Read the ADC instance from the simulator (returns null if not initialized) */
-function getADC(avrSimulator: AnySimulator): any | null {
- return (avrSimulator as any).getADC?.() ?? null;
-}
-
-/**
- * Write an analog voltage to an ADC channel, supporting both AVR and RP2040.
- *
- * AVR: pins 14-19 → ADC channels 0-5, voltage stored directly (0-5V)
- * RP2040: GPIO 26-29 → ADC channels 0-3, converted to 12-bit value (0-4095)
- */
-function setAdcVoltage(simulator: AnySimulator, pin: number, voltage: number): boolean {
- // RP2040: GPIO26-29 → ADC channels 0-3
- if (simulator instanceof RP2040Simulator) {
- if (pin >= 26 && pin <= 29) {
- const channel = pin - 26;
- // RP2040 ADC: 12-bit, 3.3V reference
- const adcValue = Math.round((voltage / 3.3) * 4095);
- console.log(`[setAdcVoltage] RP2040 ch${channel} = ${adcValue} (${voltage.toFixed(3)}V)`);
- simulator.setADCValue(channel, adcValue);
- return true;
- }
- console.warn(`[setAdcVoltage] RP2040 pin ${pin} is not an ADC pin (26-29)`);
- return false;
- }
- // AVR: pins 14-19 → ADC channels 0-5
- if (pin < 14 || pin > 19) return false;
- const channel = pin - 14;
- const adc = getADC(simulator);
- if (!adc) return false;
- adc.channelValues[channel] = voltage;
- return true;
-}
-
// ─── RGB LED (PWM-aware) ─────────────────────────────────────────────────────
/**
diff --git a/frontend/src/simulation/parts/SensorParts.ts b/frontend/src/simulation/parts/SensorParts.ts
new file mode 100644
index 0000000..2698115
--- /dev/null
+++ b/frontend/src/simulation/parts/SensorParts.ts
@@ -0,0 +1,491 @@
+/**
+ * SensorParts.ts — Simulation logic for sensors, stepper motor, and NeoPixel devices.
+ *
+ * Implements:
+ * - tilt-switch
+ * - ntc-temperature-sensor
+ * - gas-sensor (MQ-series)
+ * - flame-sensor
+ * - heart-beat-sensor
+ * - big-sound-sensor
+ * - small-sound-sensor
+ * - stepper-motor (NEMA full-step decode)
+ * - led-ring (WS2812B NeoPixel ring)
+ * - neopixel-matrix (WS2812B NeoPixel matrix)
+ */
+
+import { PartSimulationRegistry } from './PartSimulationRegistry';
+import { setAdcVoltage } from './partUtils';
+
+// ─── Tilt Switch ─────────────────────────────────────────────────────────────
+
+/**
+ * Tilt switch — click the element to toggle between tilted (OUT HIGH) and
+ * upright (OUT LOW). Starts upright (LOW).
+ */
+PartSimulationRegistry.register('tilt-switch', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pin = getArduinoPinHelper('OUT');
+ if (pin === null) return () => {};
+
+ let tilted = false;
+
+ const onClick = () => {
+ tilted = !tilted;
+ simulator.setPinState(pin, tilted);
+ console.log(`[TiltSwitch] pin ${pin} → ${tilted ? 'HIGH' : 'LOW'}`);
+ };
+
+ // Start LOW (upright)
+ simulator.setPinState(pin, false);
+ element.addEventListener('click', onClick);
+ return () => element.removeEventListener('click', onClick);
+ },
+});
+
+// ─── NTC Temperature Sensor ──────────────────────────────────────────────────
+
+/**
+ * NTC thermistor sensor — injects a mid-range analog voltage on the OUT pin
+ * representing room temperature (~25°C, ~2.5V on a 5V divider).
+ * Listens to `input` events in case the element ever gains a drag slider.
+ */
+PartSimulationRegistry.register('ntc-temperature-sensor', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pin = getArduinoPinHelper('OUT');
+ if (pin === null) return () => {};
+
+ // Room temperature default (2.5V = mid-range)
+ setAdcVoltage(simulator, pin, 2.5);
+
+ const onInput = () => {
+ const val = (element as any).value;
+ if (val !== undefined) {
+ setAdcVoltage(simulator, pin, (val / 1023.0) * 5.0);
+ }
+ };
+ element.addEventListener('input', onInput);
+ return () => element.removeEventListener('input', onInput);
+ },
+});
+
+// ─── Gas Sensor (MQ-series) ──────────────────────────────────────────────────
+
+/**
+ * Gas sensor — injects a low baseline voltage on AOUT (clean air),
+ * shows power LED. When Arduino drives DOUT → updates threshold LED D0.
+ */
+PartSimulationRegistry.register('gas-sensor', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pinAOUT = getArduinoPinHelper('AOUT');
+ const pinDOUT = getArduinoPinHelper('DOUT');
+ const pinManager = (simulator as any).pinManager;
+
+ const el = element as any;
+ el.ledPower = true;
+
+ const unsubscribers: (() => void)[] = [];
+
+ // Inject baseline analog voltage (1.5V ≈ clean air / low gas)
+ if (pinAOUT !== null) {
+ setAdcVoltage(simulator, pinAOUT, 1.5);
+ }
+
+ // DOUT from Arduino → threshold LED indicator
+ if (pinDOUT !== null && pinManager) {
+ unsubscribers.push(
+ pinManager.onPinChange(pinDOUT, (_: number, state: boolean) => {
+ el.ledD0 = state;
+ })
+ );
+ }
+
+ // Allow element to update analog value if it fires input events
+ const onInput = () => {
+ const val = (el as any).value;
+ if (val !== undefined && pinAOUT !== null) {
+ setAdcVoltage(simulator, pinAOUT, (val / 1023.0) * 5.0);
+ }
+ };
+ element.addEventListener('input', onInput);
+ unsubscribers.push(() => element.removeEventListener('input', onInput));
+
+ return () => unsubscribers.forEach(u => u());
+ },
+});
+
+// ─── Flame Sensor ────────────────────────────────────────────────────────────
+
+/**
+ * Flame sensor — injects a low baseline voltage on AOUT (no flame),
+ * shows power LED. Arduino driving DOUT → updates signal LED.
+ */
+PartSimulationRegistry.register('flame-sensor', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pinAOUT = getArduinoPinHelper('AOUT');
+ const pinDOUT = getArduinoPinHelper('DOUT');
+ const pinManager = (simulator as any).pinManager;
+
+ const el = element as any;
+ el.ledPower = true;
+
+ const unsubscribers: (() => void)[] = [];
+
+ if (pinAOUT !== null) {
+ setAdcVoltage(simulator, pinAOUT, 1.5);
+ }
+
+ if (pinDOUT !== null && pinManager) {
+ unsubscribers.push(
+ pinManager.onPinChange(pinDOUT, (_: number, state: boolean) => {
+ el.ledSignal = state;
+ })
+ );
+ }
+
+ const onInput = () => {
+ const val = (el as any).value;
+ if (val !== undefined && pinAOUT !== null) {
+ setAdcVoltage(simulator, pinAOUT, (val / 1023.0) * 5.0);
+ }
+ };
+ element.addEventListener('input', onInput);
+ unsubscribers.push(() => element.removeEventListener('input', onInput));
+
+ return () => unsubscribers.forEach(u => u());
+ },
+});
+
+// ─── Heart Beat Sensor ───────────────────────────────────────────────────────
+
+/**
+ * Heart beat sensor — simulates a 60 BPM signal on OUT pin.
+ * Every 1000ms: briefly pulls OUT HIGH for 100ms, then LOW again.
+ */
+PartSimulationRegistry.register('heart-beat-sensor', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pin = getArduinoPinHelper('OUT');
+ if (pin === null) return () => {};
+
+ simulator.setPinState(pin, false);
+
+ const intervalId = setInterval(() => {
+ simulator.setPinState(pin, true); // pulse HIGH
+ setTimeout(() => simulator.setPinState(pin, false), 100);
+ }, 1000);
+
+ return () => clearInterval(intervalId);
+ },
+});
+
+// ─── Big Sound Sensor ────────────────────────────────────────────────────────
+
+/**
+ * Big sound sensor (FC-04) — injects mid-range analog on AOUT,
+ * shows power LED (led2). Arduino driving DOUT → signal LED (led1).
+ */
+PartSimulationRegistry.register('big-sound-sensor', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pinAOUT = getArduinoPinHelper('AOUT');
+ const pinDOUT = getArduinoPinHelper('DOUT');
+ const pinManager = (simulator as any).pinManager;
+
+ const el = element as any;
+ el.led2 = true; // Power LED
+
+ const unsubscribers: (() => void)[] = [];
+
+ if (pinAOUT !== null) {
+ setAdcVoltage(simulator, pinAOUT, 2.5);
+ }
+
+ if (pinDOUT !== null && pinManager) {
+ unsubscribers.push(
+ pinManager.onPinChange(pinDOUT, (_: number, state: boolean) => {
+ el.led1 = state;
+ })
+ );
+ }
+
+ const onInput = () => {
+ const val = (el as any).value;
+ if (val !== undefined && pinAOUT !== null) {
+ setAdcVoltage(simulator, pinAOUT, (val / 1023.0) * 5.0);
+ }
+ };
+ element.addEventListener('input', onInput);
+ unsubscribers.push(() => element.removeEventListener('input', onInput));
+
+ return () => unsubscribers.forEach(u => u());
+ },
+});
+
+// ─── Small Sound Sensor ──────────────────────────────────────────────────────
+
+/**
+ * Small sound sensor (KY-038) — injects mid-range analog on AOUT,
+ * shows power LED. Arduino driving DOUT → signal LED.
+ */
+PartSimulationRegistry.register('small-sound-sensor', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pinAOUT = getArduinoPinHelper('AOUT');
+ const pinDOUT = getArduinoPinHelper('DOUT');
+ const pinManager = (simulator as any).pinManager;
+
+ const el = element as any;
+ el.ledPower = true;
+
+ const unsubscribers: (() => void)[] = [];
+
+ if (pinAOUT !== null) {
+ setAdcVoltage(simulator, pinAOUT, 2.5);
+ }
+
+ if (pinDOUT !== null && pinManager) {
+ unsubscribers.push(
+ pinManager.onPinChange(pinDOUT, (_: number, state: boolean) => {
+ el.ledSignal = state;
+ })
+ );
+ }
+
+ const onInput = () => {
+ const val = (el as any).value;
+ if (val !== undefined && pinAOUT !== null) {
+ setAdcVoltage(simulator, pinAOUT, (val / 1023.0) * 5.0);
+ }
+ };
+ element.addEventListener('input', onInput);
+ unsubscribers.push(() => element.removeEventListener('input', onInput));
+
+ return () => unsubscribers.forEach(u => u());
+ },
+});
+
+// ─── Stepper Motor (NEMA full-step decode) ───────────────────────────────────
+
+/**
+ * Stepper motor — monitors the 4 coil pins (A-, A+, B+, B-).
+ * Uses a full-step lookup table to detect direction of rotation and
+ * accumulates the shaft angle (1.8° per step = 200 steps per revolution).
+ *
+ * Full-step sequence (active-HIGH per coil):
+ * Step 0: A+ = 1, B+ = 0, A- = 0, B- = 0
+ * Step 1: A+ = 0, B+ = 1, A- = 0, B- = 0
+ * Step 2: A+ = 0, B+ = 0, A- = 1, B- = 0
+ * Step 3: A+ = 0, B+ = 0, A- = 0, B- = 1
+ */
+PartSimulationRegistry.register('stepper-motor', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pinManager = (simulator as any).pinManager;
+ if (!pinManager) return () => {};
+
+ const el = element as any;
+ const STEP_ANGLE = 1.8; // degrees per step
+
+ const pinAMinus = getArduinoPinHelper('A-');
+ const pinAPlus = getArduinoPinHelper('A+');
+ const pinBPlus = getArduinoPinHelper('B+');
+ const pinBMinus = getArduinoPinHelper('B-');
+
+ const coils = { aMinus: false, aPlus: false, bPlus: false, bMinus: false };
+ let cumAngle = el.angle ?? 0;
+ let prevStepIndex = -1;
+
+ // Full-step table: index → [A+, B+, A-, B-]
+ const stepTable: [boolean, boolean, boolean, boolean][] = [
+ [true, false, false, false], // step 0
+ [false, true, false, false], // step 1
+ [false, false, true, false], // step 2
+ [false, false, false, true], // step 3
+ ];
+
+ function coilToStepIndex(): number {
+ for (let i = 0; i < stepTable.length; i++) {
+ const [ap, bp, am, bm] = stepTable[i];
+ if (coils.aPlus === ap && coils.bPlus === bp &&
+ coils.aMinus === am && coils.bMinus === bm) {
+ return i;
+ }
+ }
+ return -1; // energized coil pattern not in full-step table
+ }
+
+ function onCoilChange() {
+ const idx = coilToStepIndex();
+ if (idx < 0) return; // half-step or off state — ignore
+ if (prevStepIndex < 0) { prevStepIndex = idx; return; }
+
+ const diff = (idx - prevStepIndex + 4) % 4;
+ if (diff === 1) {
+ cumAngle += STEP_ANGLE;
+ } else if (diff === 3) {
+ cumAngle -= STEP_ANGLE;
+ }
+ prevStepIndex = idx;
+ el.angle = ((cumAngle % 360) + 360) % 360;
+ }
+
+ const unsubscribers: (() => void)[] = [];
+
+ if (pinAMinus !== null) {
+ unsubscribers.push(pinManager.onPinChange(pinAMinus, (_: number, s: boolean) => {
+ coils.aMinus = s; onCoilChange();
+ }));
+ }
+ if (pinAPlus !== null) {
+ unsubscribers.push(pinManager.onPinChange(pinAPlus, (_: number, s: boolean) => {
+ coils.aPlus = s; onCoilChange();
+ }));
+ }
+ if (pinBPlus !== null) {
+ unsubscribers.push(pinManager.onPinChange(pinBPlus, (_: number, s: boolean) => {
+ coils.bPlus = s; onCoilChange();
+ }));
+ }
+ if (pinBMinus !== null) {
+ unsubscribers.push(pinManager.onPinChange(pinBMinus, (_: number, s: boolean) => {
+ coils.bMinus = s; onCoilChange();
+ }));
+ }
+
+ return () => unsubscribers.forEach(u => u());
+ },
+});
+
+// ─── WS2812B NeoPixel decode helper ──────────────────────────────────────────
+
+/**
+ * Decode WS2812B bit-stream from DIN pin changes for NeoPixel devices.
+ *
+ * Protocol (800 kHz, 16 MHz AVR: 1 tick = 62.5 ns):
+ * - bit 0: HIGH for ~0.35µs (≤8 cycles); LOW for ~0.80µs
+ * - bit 1: HIGH for ~0.70µs (>8 cycles); LOW for ~0.40µs
+ * - RESET: LOW for >50µs (≥800 cycles)
+ *
+ * We measure HIGH pulse_width via cpu.cycles difference.
+ * 8 bits (GRB order from WS2812B) → 1 byte; 3 bytes → 1 pixel.
+ */
+function createNeopixelDecoder(
+ simulator: any,
+ pinDIN: number,
+ onPixel: (index: number, r: number, g: number, b: number) => void,
+): () => void {
+ const pinManager = simulator.pinManager;
+ if (!pinManager) return () => {};
+
+ const CPU_CYCLES_PER_US = 16; // 16 MHz
+ const RESET_CYCLES = 800; // 50µs × 16 cycles/µs
+ const BIT1_THRESHOLD = 8; // ~0.5µs threshold between bit-0 and bit-1
+
+ let lastRisingCycle = 0;
+ let lastFallingCycle = 0;
+ let lastHigh = false;
+
+ let bitBuf = 0;
+ let bitsCollected = 0;
+ let byteBuf: number[] = [];
+ let pixelIndex = 0;
+
+ const unsub = pinManager.onPinChange(pinDIN, (_: number, high: boolean) => {
+ const cpu = simulator.cpu ?? (simulator as any).cpu;
+ const now: number = cpu?.cycles ?? 0;
+
+ if (high) {
+ // Rising edge — check if preceding LOW was a RESET
+ const lowDur = now - lastFallingCycle;
+ if (lowDur > RESET_CYCLES) {
+ // RESET pulse received — flush and restart
+ pixelIndex = 0;
+ byteBuf = [];
+ bitBuf = 0;
+ bitsCollected = 0;
+ }
+ lastRisingCycle = now;
+ lastHigh = true;
+ } else {
+ // Falling edge — measure HIGH pulse width
+ if (lastHigh) {
+ const highDur = now - lastRisingCycle;
+ const bit = highDur > BIT1_THRESHOLD ? 1 : 0;
+
+ // WS2812B transmits MSB first
+ bitBuf = (bitBuf << 1) | bit;
+ bitsCollected++;
+
+ if (bitsCollected === 8) {
+ byteBuf.push(bitBuf & 0xFF);
+ bitBuf = 0;
+ bitsCollected = 0;
+
+ if (byteBuf.length === 3) {
+ // WS2812B byte order is GRB
+ const g = byteBuf[0];
+ const r = byteBuf[1];
+ const b = byteBuf[2];
+ onPixel(pixelIndex++, r, g, b);
+ byteBuf = [];
+ }
+ }
+ }
+ lastFallingCycle = now;
+ lastHigh = false;
+ }
+ });
+
+ return unsub;
+}
+
+// ─── LED Ring (WS2812B NeoPixel ring) ────────────────────────────────────────
+
+PartSimulationRegistry.register('led-ring', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pinDIN = getArduinoPinHelper('DIN');
+ if (pinDIN === null) return () => {};
+
+ const el = element as any;
+
+ const unsub = createNeopixelDecoder(
+ (simulator as any),
+ pinDIN,
+ (index, r, g, b) => {
+ try {
+ el.setPixel(index, { r, g, b });
+ } catch (_) {
+ // setPixel not yet available (element not upgraded) — ignore
+ }
+ },
+ );
+
+ return unsub;
+ },
+});
+
+// ─── NeoPixel Matrix (WS2812B matrix grid) ────────────────────────────────────
+
+PartSimulationRegistry.register('neopixel-matrix', {
+ attachEvents: (element, simulator, getArduinoPinHelper) => {
+ const pinDIN = getArduinoPinHelper('DIN');
+ if (pinDIN === null) return () => {};
+
+ const el = element as any;
+
+ const unsub = createNeopixelDecoder(
+ (simulator as any),
+ pinDIN,
+ (index, r, g, b) => {
+ // cols is set by the element property (default 8)
+ const cols: number = el.cols ?? 8;
+ const row = Math.floor(index / cols);
+ const col = index % cols;
+ try {
+ el.setPixel(row, col, { r, g, b });
+ } catch (_) {
+ // ignore
+ }
+ },
+ );
+
+ return unsub;
+ },
+});
diff --git a/frontend/src/simulation/parts/index.ts b/frontend/src/simulation/parts/index.ts
index c7f2b58..df05b33 100644
--- a/frontend/src/simulation/parts/index.ts
+++ b/frontend/src/simulation/parts/index.ts
@@ -2,3 +2,4 @@ export * from './PartSimulationRegistry';
import './BasicParts';
import './ComplexParts';
import './ChipParts';
+import './SensorParts';
diff --git a/frontend/src/simulation/parts/partUtils.ts b/frontend/src/simulation/parts/partUtils.ts
new file mode 100644
index 0000000..1e0295b
--- /dev/null
+++ b/frontend/src/simulation/parts/partUtils.ts
@@ -0,0 +1,45 @@
+/**
+ * partUtils.ts — Shared simulation helpers
+ *
+ * Provides ADC voltage injection utilities used by both ComplexParts and
+ * SensorParts, supporting both AVR (ATmega328p) and RP2040 boards.
+ */
+
+import type { AnySimulator } from './PartSimulationRegistry';
+import { RP2040Simulator } from '../RP2040Simulator';
+
+/** Read the ADC instance from the simulator (returns null if not initialized) */
+export function getADC(avrSimulator: AnySimulator): any | null {
+ return (avrSimulator as any).getADC?.() ?? null;
+}
+
+/**
+ * Write an analog voltage to an ADC channel, supporting both AVR and RP2040.
+ *
+ * AVR: pins 14-19 → ADC channels 0-5, voltage stored directly (0-5V)
+ * RP2040: GPIO 26-29 → ADC channels 0-3, converted to 12-bit value (0-4095)
+ *
+ * Returns true if the voltage was successfully injected.
+ */
+export function setAdcVoltage(simulator: AnySimulator, pin: number, voltage: number): boolean {
+ // RP2040: GPIO26-29 → ADC channels 0-3
+ if (simulator instanceof RP2040Simulator) {
+ if (pin >= 26 && pin <= 29) {
+ const channel = pin - 26;
+ // RP2040 ADC: 12-bit, 3.3V reference
+ const adcValue = Math.round((voltage / 3.3) * 4095);
+ console.log(`[setAdcVoltage] RP2040 ch${channel} = ${adcValue} (${voltage.toFixed(3)}V)`);
+ simulator.setADCValue(channel, adcValue);
+ return true;
+ }
+ console.warn(`[setAdcVoltage] RP2040 pin ${pin} is not an ADC pin (26-29)`);
+ return false;
+ }
+ // AVR: pins 14-19 → ADC channels 0-5
+ if (pin < 14 || pin > 19) return false;
+ const channel = pin - 14;
+ const adc = getADC(simulator);
+ if (!adc) return false;
+ adc.channelValues[channel] = voltage;
+ return true;
+}
diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts
index 553d84a..aea0ea6 100644
--- a/frontend/src/vite-env.d.ts
+++ b/frontend/src/vite-env.d.ts
@@ -24,5 +24,18 @@ declare namespace JSX {
'wokwi-pir-motion-sensor': any;
'wokwi-photoresistor-sensor': any;
'wokwi-74hc595': any;
+ 'wokwi-stepper-motor': any;
+ 'wokwi-biaxial-stepper': any;
+ 'wokwi-tilt-switch': any;
+ 'wokwi-ntc-temperature-sensor': any;
+ 'wokwi-gas-sensor': any;
+ 'wokwi-flame-sensor': any;
+ 'wokwi-heart-beat-sensor': any;
+ 'wokwi-big-sound-sensor': any;
+ 'wokwi-small-sound-sensor': any;
+ 'wokwi-led-ring': any;
+ 'wokwi-neopixel-matrix': any;
+ 'wokwi-lcd1602': any;
+ 'wokwi-ky-040': any;
}
}