/** * 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; }, }); // ─── Single NeoPixel (WS2812B) ─────────────────────────────────────────────── /** * Single addressable RGB LED — decodes the WS2812B data stream on DIN * and updates the element's r/g/b properties (0–1 range). */ PartSimulationRegistry.register('neopixel', { 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) => { el.r = r / 255; el.g = g / 255; el.b = b / 255; }, ); return unsub; }, }); // ─── PIR Motion Sensor ─────────────────────────────────────────────────────── /** * PIR motion sensor — click the element to simulate a motion event. * OUT pin goes HIGH for 3 seconds then returns LOW. */ PartSimulationRegistry.register('pir-motion-sensor', { attachEvents: (element, simulator, getArduinoPinHelper) => { const pin = getArduinoPinHelper('OUT'); if (pin === null) return () => {}; simulator.setPinState(pin, false); // idle LOW let timer: ReturnType | null = null; const onClick = () => { if (timer !== null) clearTimeout(timer); simulator.setPinState(pin, true); // motion detected → HIGH console.log('[PIR] Motion detected → OUT HIGH'); timer = setTimeout(() => { simulator.setPinState(pin, false); timer = null; console.log('[PIR] Motion ended → OUT LOW'); }, 3000); }; element.addEventListener('click', onClick); return () => { element.removeEventListener('click', onClick); if (timer !== null) clearTimeout(timer); }; }, }); // ─── KS2E-M-DC5 Relay ──────────────────────────────────────────────────────── /** * Dual-coil relay — listens for COIL1/COIL2 pin state changes. * In a typical Arduino circuit the Arduino drives the coil and the relay * switches a separate load circuit; no electrical feedback is needed. */ PartSimulationRegistry.register('ks2e-m-dc5', { onPinStateChange: (pinName, state, _element) => { if (pinName === 'COIL1' || pinName === 'COIL2') { console.log(`[Relay KS2E] ${pinName} → ${state ? 'ACTIVATED' : 'RELEASED'}`); } }, }); // ─── HC-SR04 Ultrasonic Distance Sensor ────────────────────────────────────── /** * Ultrasonic sensor — monitors the TRIG pin. * When TRIG goes HIGH the sensor responds with an ECHO HIGH pulse * simulating an object at ~10 cm (≈582 µs echo width → 1 ms real-time). */ PartSimulationRegistry.register('hc-sr04', { attachEvents: (element, simulator, getArduinoPinHelper) => { const trigPin = getArduinoPinHelper('TRIG'); const echoPin = getArduinoPinHelper('ECHO'); if (trigPin === null || echoPin === null) return () => {}; simulator.setPinState(echoPin, false); // ECHO LOW initially let echoTimer: ReturnType | null = null; const cleanup = simulator.pinManager.onPinChange(trigPin, (_: number, state: boolean) => { if (state) { // TRIG HIGH — fire ECHO pulse after ~1 ms if (echoTimer !== null) clearTimeout(echoTimer); echoTimer = setTimeout(() => { simulator.setPinState(echoPin, true); // ECHO HIGH console.log('[HC-SR04] ECHO HIGH (10 cm)'); echoTimer = setTimeout(() => { simulator.setPinState(echoPin, false); // ECHO LOW echoTimer = null; }, 1); // 1 ms ≈ 582 µs → ~10 cm }, 1); } }); return () => { cleanup(); if (echoTimer !== null) clearTimeout(echoTimer); }; }, });