feat: add sensor parts and utilities for simulation

- Introduced new SensorParts.ts to handle various sensors including tilt switch, temperature sensor, gas sensor, flame sensor, heart beat sensor, and sound sensors.
- Implemented stepper motor simulation with full-step decoding.
- Added utility functions for ADC voltage injection in partUtils.ts, supporting both AVR and RP2040.
- Updated BasicParts.ts to avoid re-registering the 7-segment display.
- Enhanced ComplexParts.ts by removing unused ADC helper functions.
- Updated index.ts to include the new SensorParts module.
- Updated Vite environment definitions to include new custom elements for sensors and stepper motors.
pull/10/head
David Montero Crespo 2026-03-08 02:20:21 -03:00
parent 318305bac4
commit 2aa7607428
11 changed files with 1593 additions and 56 deletions

View File

@ -1729,6 +1729,205 @@
"motion",
"sensor"
]
},
{
"id": "lcd1602",
"tagName": "wokwi-lcd1602",
"name": "LCD 1602",
"category": "displays",
"thumbnail": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"64\" height=\"64\" fill=\"#1a5e1a\" rx=\"4\"/><rect x=\"4\" y=\"14\" width=\"56\" height=\"36\" rx=\"2\" fill=\"#2e7d32\"/><rect x=\"7\" y=\"18\" width=\"10\" height=\"5\" rx=\"1\" fill=\"#4caf50\" opacity=\"0.7\"/><rect x=\"19\" y=\"18\" width=\"10\" height=\"5\" rx=\"1\" fill=\"#4caf50\" opacity=\"0.7\"/><rect x=\"31\" y=\"18\" width=\"10\" height=\"5\" rx=\"1\" fill=\"#4caf50\" opacity=\"0.7\"/><rect x=\"43\" y=\"18\" width=\"10\" height=\"5\" rx=\"1\" fill=\"#4caf50\" opacity=\"0.7\"/><rect x=\"7\" y=\"26\" width=\"10\" height=\"5\" rx=\"1\" fill=\"#4caf50\" opacity=\"0.7\"/><text x=\"32\" y=\"50\" text-anchor=\"middle\" font-size=\"7\" fill=\"#81c784\" font-family=\"monospace\">LCD 1602</text></svg>",
"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": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"64\" height=\"64\" fill=\"#37474f\" rx=\"4\"/><circle cx=\"32\" cy=\"32\" r=\"20\" fill=\"#546e7a\" stroke=\"#90a4ae\" stroke-width=\"2\"/><circle cx=\"32\" cy=\"32\" r=\"6\" fill=\"#b0bec5\"/><line x1=\"32\" y1=\"12\" x2=\"32\" y2=\"32\" stroke=\"#ffd54f\" stroke-width=\"2\" stroke-linecap=\"round\"/><text x=\"32\" y=\"56\" text-anchor=\"middle\" font-size=\"7\" fill=\"#b0bec5\" font-family=\"monospace\">STEPPER</text></svg>",
"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": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"64\" height=\"64\" fill=\"#e0e0e0\" rx=\"4\"/><text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\".3em\" font-size=\"9\" fill=\"#666\">TILT</text></svg>",
"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": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"64\" height=\"64\" fill=\"#e0e0e0\" rx=\"4\"/><text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\".3em\" font-size=\"8\" fill=\"#666\">NTC TEMP</text></svg>",
"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": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"64\" height=\"64\" fill=\"#ffebee\" rx=\"4\"/><text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\"-.2em\" font-size=\"9\" fill=\"#e53935\">HEART</text><text x=\"50%\" y=\"50%\" text-anchor=\"middle\" dy=\"1em\" font-size=\"9\" fill=\"#e53935\">BEAT</text></svg>",
"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": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"64\" height=\"64\" fill=\"#222\" rx=\"4\"/><circle cx=\"16\" cy=\"16\" r=\"5\" fill=\"#f44336\"/><circle cx=\"32\" cy=\"16\" r=\"5\" fill=\"#4caf50\"/><circle cx=\"48\" cy=\"16\" r=\"5\" fill=\"#2196f3\"/><circle cx=\"16\" cy=\"32\" r=\"5\" fill=\"#ffeb3b\"/><circle cx=\"32\" cy=\"32\" r=\"5\" fill=\"#9c27b0\"/><circle cx=\"48\" cy=\"32\" r=\"5\" fill=\"#00bcd4\"/><circle cx=\"16\" cy=\"48\" r=\"5\" fill=\"#ff9800\"/><circle cx=\"32\" cy=\"48\" r=\"5\" fill=\"#f44336\"/><circle cx=\"48\" cy=\"48\" r=\"5\" fill=\"#4caf50\"/></svg>",
"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": "<svg width=\"64\" height=\"64\" xmlns=\"http://www.w3.org/2000/svg\"><rect width=\"64\" height=\"64\" fill=\"#222\" rx=\"4\"/><circle cx=\"32\" cy=\"32\" r=\"26\" fill=\"none\" stroke=\"#363\" stroke-width=\"6\"/><circle cx=\"32\" cy=\"6\" r=\"4\" fill=\"#f44336\"/><circle cx=\"57\" cy=\"32\" r=\"4\" fill=\"#4caf50\"/><circle cx=\"32\" cy=\"58\" r=\"4\" fill=\"#2196f3\"/><circle cx=\"7\" cy=\"32\" r=\"4\" fill=\"#ffeb3b\"/></svg>",
"properties": [
{
"name": "pixels",
"type": "number",
"defaultValue": 16,
"control": "text"
}
],
"defaultValues": {
"pixels": 16
},
"pinCount": 4,
"tags": [
"led-ring",
"led ring",
"ws2812b",
"neopixel ring",
"ring"
]
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 72 KiB

After

Width:  |  Height:  |  Size: 83 KiB

View File

@ -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<string, unknown> = {}): 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<string, number>) => (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<typeof vi.fn>).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<string, (...args: any[]) => void> = {};
(element.addEventListener as ReturnType<typeof vi.fn>).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<string, (...args: any[]) => void> = {};
(element.addEventListener as ReturnType<typeof vi.fn>).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<string, (...args: any[]) => void> = {};
(element.addEventListener as ReturnType<typeof vi.fn>).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<number, (pin: number, s: boolean) => 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<number, (pin: number, s: boolean) => 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<number, (pin: number, s: boolean) => 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();
});
});

View File

@ -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<string, unknown> = {}): 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<typeof makeADC> | 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<string, number>) => (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<string, (...args: any[]) => void> = {};
(element.addEventListener as ReturnType<typeof vi.fn>).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<number, (pin: number, s: boolean) => 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));
});
});

View File

@ -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

View File

@ -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(); };
},
});

View File

@ -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) ─────────────────────────────────────────────────────
/**

View File

@ -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;
},
});

View File

@ -2,3 +2,4 @@ export * from './PartSimulationRegistry';
import './BasicParts';
import './ComplexParts';
import './ChipParts';
import './SensorParts';

View File

@ -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;
}

View File

@ -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;
}
}