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
parent
318305bac4
commit
2aa7607428
|
|
@ -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 |
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
});
|
||||
});
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(); };
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
@ -2,3 +2,4 @@ export * from './PartSimulationRegistry';
|
|||
import './BasicParts';
|
||||
import './ComplexParts';
|
||||
import './ChipParts';
|
||||
import './SensorParts';
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue