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": "LCD 1602", + "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": "STEPPER", + "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": "TILT", + "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": "NTC TEMP", + "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": "HEARTBEAT", + "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; } }