feat: add SensorControlPanel for interactive sensor controls

- Implemented SensorControlPanel component to allow real-time adjustments of sensor values during simulation.
- Introduced SensorUpdateRegistry for communication between UI and simulation.
- Added configuration for various sensors including sliders and buttons for user interaction.
- Enhanced existing sensor parts to support updates from SensorControlPanel.
- Created CSS styles for the SensorControlPanel layout and controls.
pull/47/head
David Montero Crespo 2026-03-19 01:52:46 -03:00
parent d170b4f7ea
commit 8ba11800e1
11 changed files with 800 additions and 95 deletions

View File

@ -197,9 +197,10 @@ describe('flame-sensor — attachEvents', () => {
const sim = makeSimulator(adc);
const el = makeElement() as any;
logic.attachEvents!(el, sim as any, pinMap({ AOUT: 14 }));
logic.attachEvents!(el, sim as any, pinMap({ AOUT: 14 }), 'flame-sensor-test');
expect(adc.channelValues[0]).toBeCloseTo(1.5, 2);
// No-flame baseline = 4.5V (inverse: no flame → high V, flame → low V)
expect(adc.channelValues[0]).toBeCloseTo(4.5, 2);
expect(el.ledPower).toBe(true);
});

View File

@ -214,7 +214,7 @@ export const DynamicComponent: React.FC<DynamicComponentProps> = ({
return null;
};
cleanupSimulationEvents = logic.attachEvents(el, simulator, getArduinoPin);
cleanupSimulationEvents = logic.attachEvents(el, simulator, getArduinoPin, id);
}
return () => {

View File

@ -0,0 +1,178 @@
/**
* Sensor Control Panel wokwi-style top-of-canvas panel for adjusting
* sensor values (temperature, distance, humidity, etc.) during simulation.
*/
.sensor-control-panel {
position: absolute;
top: 8px;
left: 8px;
background-color: #2d2d2d;
border: 1px solid #555;
border-radius: 8px;
padding: 12px 16px 14px;
min-width: 320px;
max-width: 420px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5);
z-index: 1002;
pointer-events: all;
color: #fff;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', sans-serif;
user-select: none;
}
/* ── Header ─────────────────────────────────────────────────────────────── */
.sensor-panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #444;
}
.sensor-panel-title {
font-size: 13px;
font-weight: 600;
color: #fff;
letter-spacing: 0.01em;
}
.sensor-panel-close {
background: transparent;
border: none;
color: #aaa;
cursor: pointer;
font-size: 20px;
padding: 0;
width: 24px;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 4px;
transition: background-color 0.15s, color 0.15s;
line-height: 1;
}
.sensor-panel-close:hover {
background-color: #444;
color: #fff;
}
/* ── Section labels (ACCELERATION / ROTATION / TEMPERATURE etc.) ──────── */
.sensor-section-label {
display: flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 700;
color: #aaa;
letter-spacing: 0.08em;
text-transform: uppercase;
margin-top: 10px;
margin-bottom: 4px;
}
.sensor-section-label:first-of-type {
margin-top: 0;
}
.sensor-section-icon {
font-size: 14px;
}
/* ── Slider row ──────────────────────────────────────────────────────────── */
.sensor-control-row {
display: flex;
align-items: center;
gap: 8px;
margin: 4px 0;
}
.sensor-control-label {
font-size: 12px;
color: #ccc;
width: 20px;
flex-shrink: 0;
text-align: right;
}
.sensor-control-label-wide {
font-size: 12px;
color: #ccc;
min-width: 90px;
flex-shrink: 0;
}
.sensor-slider {
flex: 1;
-webkit-appearance: none;
appearance: none;
height: 4px;
background: #555;
border-radius: 2px;
outline: none;
cursor: pointer;
}
.sensor-slider::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
border-radius: 50%;
background: #007acc;
cursor: pointer;
transition: background 0.15s;
}
.sensor-slider::-webkit-slider-thumb:hover {
background: #0095e8;
}
.sensor-slider::-moz-range-thumb {
width: 14px;
height: 14px;
border-radius: 50%;
background: #007acc;
cursor: pointer;
border: none;
}
.sensor-value-display {
font-size: 12px;
color: #fff;
min-width: 70px;
text-align: right;
flex-shrink: 0;
}
/* ── Button control ──────────────────────────────────────────────────────── */
.sensor-trigger-button {
margin-top: 8px;
padding: 7px 16px;
background-color: #3d3d3d;
border: 1px solid #666;
border-radius: 4px;
color: #fff;
font-size: 13px;
font-weight: 500;
cursor: pointer;
transition: background-color 0.15s, border-color 0.15s;
width: 100%;
}
.sensor-trigger-button:hover {
background-color: #505050;
border-color: #888;
}
.sensor-trigger-button:active {
background-color: #007acc;
border-color: #007acc;
}

View File

@ -0,0 +1,156 @@
/**
* SensorControlPanel wokwi-style interactive sensor controls.
*
* Appears at the top-left of the simulation canvas when a sensor component is
* clicked during simulation. Provides sliders and buttons that feed values
* directly into the running simulation via SensorUpdateRegistry.
*/
import React, { useEffect, useState } from 'react';
import {
SENSOR_CONTROLS,
type SensorControl,
type SliderControl,
} from '../../simulation/sensorControlConfig';
import { dispatchSensorUpdate } from '../../simulation/SensorUpdateRegistry';
import './SensorControlPanel.css';
interface SensorControlPanelProps {
componentId: string;
metadataId: string;
sensorName: string;
onClose: () => void;
}
// ── Section grouping for MPU6050 ────────────────────────────────────────────
interface SensorSection {
label: string;
icon: string;
keys: string[];
}
const MPU6050_SECTIONS: SensorSection[] = [
{ label: 'Acceleration', icon: '↗', keys: ['accelX', 'accelY', 'accelZ'] },
{ label: 'Rotation', icon: '↻', keys: ['gyroX', 'gyroY', 'gyroZ'] },
{ label: 'Temperature', icon: '🌡', keys: ['temp'] },
];
// Keys that use single-char axis labels (X / Y / Z) rather than the full key name
const AXIS_KEYS = new Set(['accelX', 'accelY', 'accelZ', 'gyroX', 'gyroY', 'gyroZ']);
// ── Component ───────────────────────────────────────────────────────────────
export const SensorControlPanel: React.FC<SensorControlPanelProps> = ({
componentId,
metadataId,
sensorName,
onClose,
}) => {
const def = SENSOR_CONTROLS[metadataId];
// Local slider/button state — initialised from config defaults
const [values, setValues] = useState<Record<string, number | boolean>>(
def ? { ...def.defaultValues } : {},
);
// Push initial defaults into simulation on mount
useEffect(() => {
if (def && Object.keys(def.defaultValues).length > 0) {
dispatchSensorUpdate(componentId, def.defaultValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [componentId]);
// Close on Escape
useEffect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); };
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
if (!def) return null;
// ── Handlers ──────────────────────────────────────────────────────────────
const handleSlider = (key: string, raw: string) => {
const v = parseFloat(raw);
setValues(prev => ({ ...prev, [key]: v }));
dispatchSensorUpdate(componentId, { [key]: v });
};
const handleButton = (key: string) => {
dispatchSensorUpdate(componentId, { [key]: true });
};
// ── Render helpers ────────────────────────────────────────────────────────
const renderControl = (ctrl: SensorControl) => {
if (ctrl.type === 'button') {
return (
<button
key={ctrl.key}
className="sensor-trigger-button"
onClick={() => handleButton(ctrl.key)}
>
{ctrl.label}
</button>
);
}
// Slider
const sc = ctrl as SliderControl;
const val = values[sc.key] as number ?? sc.defaultValue;
const displayVal = sc.formatValue ? sc.formatValue(val) : String(val);
const isAxisKey = AXIS_KEYS.has(sc.key);
return (
<div key={sc.key} className="sensor-control-row">
<span className={isAxisKey ? 'sensor-control-label' : 'sensor-control-label-wide'}>
{isAxisKey ? sc.label : sc.label}
</span>
<input
type="range"
className="sensor-slider"
min={sc.min}
max={sc.max}
step={sc.step}
value={val}
onChange={e => handleSlider(sc.key, e.target.value)}
/>
<span className="sensor-value-display">
{displayVal}{sc.unit ? ` ${sc.unit}` : ''}
</span>
</div>
);
};
// For MPU6050 render sections; for everything else render controls flat
const renderControls = () => {
if (metadataId === 'mpu6050') {
return MPU6050_SECTIONS.map(section => {
const sectionControls = def.controls.filter(c => section.keys.includes(c.key));
return (
<React.Fragment key={section.label}>
<div className="sensor-section-label">
<span className="sensor-section-icon">{section.icon}</span>
{section.label}
</div>
{sectionControls.map(renderControl)}
</React.Fragment>
);
});
}
return def.controls.map(renderControl);
};
return (
<div className="sensor-control-panel" onClick={e => e.stopPropagation()}>
<div className="sensor-panel-header">
<span className="sensor-panel-title">{sensorName || def.title}</span>
<button className="sensor-panel-close" onClick={onClose} title="Close">×</button>
</div>
{renderControls()}
</div>
);
};

View File

@ -3,6 +3,8 @@ import React, { useEffect, useState, useRef, useCallback } from 'react';
import { ESP32_ADC_PIN_MAP } from '../components-wokwi/Esp32Element';
import { ComponentPickerModal } from '../ComponentPickerModal';
import { ComponentPropertyDialog } from './ComponentPropertyDialog';
import { SensorControlPanel } from './SensorControlPanel';
import { SENSOR_CONTROLS } from '../../simulation/sensorControlConfig';
import { DynamicComponent, createComponentFromMetadata } from '../DynamicComponent';
import { ComponentRegistry } from '../../services/ComponentRegistry';
import { PinSelector } from './PinSelector';
@ -94,6 +96,10 @@ export const SimulatorCanvas = () => {
const [propertyDialogComponentId, setPropertyDialogComponentId] = useState<string | null>(null);
const [propertyDialogPosition, setPropertyDialogPosition] = useState({ x: 0, y: 0 });
// Sensor control panel (shown instead of property dialog for sensor components during simulation)
const [sensorControlComponentId, setSensorControlComponentId] = useState<string | null>(null);
const [sensorControlMetadataId, setSensorControlMetadataId] = useState<string | null>(null);
// Click vs drag detection
const [clickStartTime, setClickStartTime] = useState<number>(0);
const [clickStartPos, setClickStartPos] = useState({ x: 0, y: 0 });
@ -389,15 +395,20 @@ export const SimulatorCanvas = () => {
const dy = changed ? changed.clientY - touchClickStartPosRef.current.y : 0;
const dist = Math.sqrt(dx * dx + dy * dy);
// Short tap with minimal movement → open property dialog
// Short tap with minimal movement → open property dialog or sensor panel
if (dist < 5 && elapsed < 300 && touchDraggedComponentIdRef.current !== '__board__') {
const component = componentsRef.current.find(
(c) => c.id === touchDraggedComponentIdRef.current
);
if (component) {
setPropertyDialogComponentId(touchDraggedComponentIdRef.current);
setPropertyDialogPosition({ x: component.x, y: component.y });
setShowPropertyDialog(true);
if (runningRef.current && SENSOR_CONTROLS[component.metadataId] !== undefined) {
setSensorControlComponentId(touchDraggedComponentIdRef.current);
setSensorControlMetadataId(component.metadataId);
} else {
setPropertyDialogComponentId(touchDraggedComponentIdRef.current);
setPropertyDialogPosition({ x: component.x, y: component.y });
setShowPropertyDialog(true);
}
}
}
@ -764,9 +775,15 @@ export const SimulatorCanvas = () => {
} else if (draggedComponentId !== '__board__') {
const component = components.find((c) => c.id === draggedComponentId);
if (component) {
setPropertyDialogComponentId(draggedComponentId);
setPropertyDialogPosition({ x: component.x, y: component.y });
setShowPropertyDialog(true);
// During simulation: sensor components show the SensorControlPanel
if (running && SENSOR_CONTROLS[component.metadataId] !== undefined) {
setSensorControlComponentId(draggedComponentId);
setSensorControlMetadataId(component.metadataId);
} else {
setPropertyDialogComponentId(draggedComponentId);
setPropertyDialogPosition({ x: component.x, y: component.y });
setShowPropertyDialog(true);
}
}
}
}
@ -1152,6 +1169,22 @@ export const SimulatorCanvas = () => {
: 'default',
}}
>
{/* Sensor Control Panel — shown when a sensor component is clicked during simulation */}
{sensorControlComponentId && sensorControlMetadataId && (() => {
const meta = registry.getById(sensorControlMetadataId);
return (
<SensorControlPanel
componentId={sensorControlComponentId}
metadataId={sensorControlMetadataId}
sensorName={meta?.name ?? sensorControlMetadataId}
onClose={() => {
setSensorControlComponentId(null);
setSensorControlMetadataId(null);
}}
/>
);
})()}
{/* Infinite world — pan+zoom applied here */}
<div
className="canvas-world"

View File

@ -0,0 +1,38 @@
/**
* SensorUpdateRegistry module-level singleton for React UI simulation communication.
*
* When a sensor's attachEvents() runs it registers a callback keyed by componentId.
* The SensorControlPanel calls dispatchSensorUpdate() to push new values into the
* running simulation without any React/Zustand dependency in the simulation layer.
*/
type SensorUpdateCallback = (values: Record<string, number | boolean>) => void;
const registry = new Map<string, SensorUpdateCallback>();
/**
* Register a callback for a component. Called from inside attachEvents().
* The callback receives a partial values object (only changed keys).
*/
export function registerSensorUpdate(componentId: string, cb: SensorUpdateCallback): void {
registry.set(componentId, cb);
}
/**
* Dispatch new sensor values for a component. Called from SensorControlPanel.
* No-ops silently if the component has no registered callback.
*/
export function dispatchSensorUpdate(
componentId: string,
values: Record<string, number | boolean>,
): void {
registry.get(componentId)?.(values);
}
/**
* Unregister a component's callback. Called in the cleanup function returned
* by attachEvents() so stale callbacks don't persist after simulation stops.
*/
export function unregisterSensorUpdate(componentId: string): void {
registry.delete(componentId);
}

View File

@ -2,6 +2,7 @@ import { PartSimulationRegistry } from './PartSimulationRegistry';
import type { AnySimulator } from './PartSimulationRegistry';
import { RP2040Simulator } from '../RP2040Simulator';
import { getADC, setAdcVoltage } from './partUtils';
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
// ─── Helpers ────────────────────────────────────────────────────────────────
@ -131,14 +132,14 @@ PartSimulationRegistry.register('slide-potentiometer', {
* returns a valid value. Users can modify the element's `value` attribute.
*/
PartSimulationRegistry.register('photoresistor-sensor', {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
attachEvents: (element, avrSimulator, getArduinoPinHelper, componentId) => {
const pinAO = getArduinoPinHelper('AO') ?? getArduinoPinHelper('A0');
const pinDO = getArduinoPinHelper('DO') ?? getArduinoPinHelper('D0');
const pinManager = (avrSimulator as any).pinManager;
const unsubscribers: (() => void)[] = [];
// Inject initial mid-range voltage (simulate moderate light)
// Inject initial mid-range voltage (simulate moderate light, ~500 lux)
if (pinAO !== null) {
setAdcVoltage(avrSimulator, pinAO, 2.5);
}
@ -161,7 +162,17 @@ PartSimulationRegistry.register('photoresistor-sensor', {
}));
}
return () => unsubscribers.forEach(u => u());
// SensorControlPanel: lux 01000 → volts 05
registerSensorUpdate(componentId, (values) => {
if ('lux' in values && pinAO !== null) {
setAdcVoltage(avrSimulator, pinAO, ((values.lux as number) / 1000) * 5.0);
}
});
return () => {
unsubscribers.forEach(u => u());
unregisterSensorUpdate(componentId);
};
},
});
@ -172,7 +183,7 @@ PartSimulationRegistry.register('photoresistor-sensor', {
* Wokwi pins: VRX (X axis), VRY (Y axis), SW (button)
*/
PartSimulationRegistry.register('analog-joystick', {
attachEvents: (element, avrSimulator, getArduinoPinHelper) => {
attachEvents: (element, avrSimulator, getArduinoPinHelper, componentId) => {
const pinX = getArduinoPinHelper('VRX') ?? getArduinoPinHelper('XOUT');
const pinY = getArduinoPinHelper('VRY') ?? getArduinoPinHelper('YOUT');
const pinSW = getArduinoPinHelper('SW');
@ -208,11 +219,22 @@ PartSimulationRegistry.register('analog-joystick', {
element.addEventListener('button-press', onPress);
element.addEventListener('button-release', onRelease);
// SensorControlPanel: xAxis/yAxis -512..512 → voltage 05V (center = 2.5V)
registerSensorUpdate(componentId, (values) => {
if ('xAxis' in values && pinX !== null) {
setAdcVoltage(avrSimulator, pinX, ((values.xAxis as number + 512) / 1023) * 5.0);
}
if ('yAxis' in values && pinY !== null) {
setAdcVoltage(avrSimulator, pinY, ((values.yAxis as number + 512) / 1023) * 5.0);
}
});
return () => {
element.removeEventListener('input', onMove);
element.removeEventListener('joystick-move', onMove);
element.removeEventListener('button-press', onPress);
element.removeEventListener('button-release', onRelease);
unregisterSensorUpdate(componentId);
};
},
});

View File

@ -20,16 +20,18 @@ export interface PartSimulationLogic {
/**
* Called when the simulation starts to attach events or setup periodic tasks.
* Useful for input components (buttons, potentiometers) or complex components (servos).
*
*
* @param element The DOM element of the wokwi component
* @param avrSimulator The running simulator instance
* @param getArduinoPinHelper Function to find what Arduino pin is connected to a specific component pin
* @param componentId The unique ID of this component instance (used by SensorUpdateRegistry)
* @returns A cleanup function to remove event listeners when simulation stops
*/
attachEvents?: (
element: HTMLElement,
simulator: AnySimulator,
getArduinoPinHelper: (componentPinName: string) => number | null
getArduinoPinHelper: (componentPinName: string) => number | null,
componentId: string
) => () => void;
}

View File

@ -21,6 +21,7 @@
import { PartSimulationRegistry } from './PartSimulationRegistry';
import { VirtualDS1307, VirtualBMP280, VirtualDS3231, VirtualPCF8574 } from '../I2CBusManager';
import type { I2CDevice } from '../I2CBusManager';
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
// ─── Helpers ─────────────────────────────────────────────────────────────────
@ -310,7 +311,7 @@ class VirtualMPU6050 implements I2CDevice {
}
PartSimulationRegistry.register('mpu6050', {
attachEvents: (element, simulator, _getPin) => {
attachEvents: (element, simulator, _getPin, componentId) => {
const sim = simulator as any;
if (typeof sim.addI2CDevice !== 'function') return () => {};
const el = element as any;
@ -318,7 +319,28 @@ PartSimulationRegistry.register('mpu6050', {
const addr = (el.ad0 === true || el.ad0 === 'true') ? 0x69 : 0x68;
const device = new VirtualMPU6050(addr);
sim.addI2CDevice(device);
return () => removeI2CDevice(sim, device.address);
// Helper: write a signed 16-bit value to two consecutive registers (H, L)
const writeI16 = (regH: number, raw: number) => {
const v = Math.max(-32768, Math.min(32767, Math.round(raw))) & 0xFFFF;
device.registers[regH] = (v >> 8) & 0xFF;
device.registers[regH + 1] = v & 0xFF;
};
registerSensorUpdate(componentId, (values) => {
if ('accelX' in values) writeI16(0x3B, (values.accelX as number) * 16384);
if ('accelY' in values) writeI16(0x3D, (values.accelY as number) * 16384);
if ('accelZ' in values) writeI16(0x3F, (values.accelZ as number) * 16384);
if ('gyroX' in values) writeI16(0x43, (values.gyroX as number) * 131);
if ('gyroY' in values) writeI16(0x45, (values.gyroY as number) * 131);
if ('gyroZ' in values) writeI16(0x47, (values.gyroZ as number) * 131);
if ('temp' in values) writeI16(0x41, ((values.temp as number) - 36.53) * 340);
});
return () => {
removeI2CDevice(sim, device.address);
unregisterSensorUpdate(componentId);
};
},
});
@ -385,7 +407,7 @@ function driveDHT22Response(simulator: any, pin: number, element: HTMLElement):
}
PartSimulationRegistry.register('dht22', {
attachEvents: (element, simulator, getPin) => {
attachEvents: (element, simulator, getPin, componentId) => {
const pin = getPin('DATA');
if (pin === null) return () => {};
@ -410,9 +432,17 @@ PartSimulationRegistry.register('dht22', {
// Idle state: DATA HIGH (pulled up)
simulator.setPinState(pin, true);
// SensorControlPanel: update temperature / humidity on the element
registerSensorUpdate(componentId, (values) => {
const el = element as any;
if ('temperature' in values) el.temperature = values.temperature as number;
if ('humidity' in values) el.humidity = values.humidity as number;
});
return () => {
unsub();
simulator.setPinState(pin, true);
unregisterSensorUpdate(componentId);
};
},
});
@ -768,7 +798,7 @@ PartSimulationRegistry.register('microsd-card', {
* compensated readings.
*/
PartSimulationRegistry.register('bmp280', {
attachEvents: (element, simulator, _getPin) => {
attachEvents: (element, simulator, _getPin, componentId) => {
const sim = simulator as any;
if (typeof sim.addI2CDevice !== 'function') return () => {};
@ -780,7 +810,17 @@ PartSimulationRegistry.register('bmp280', {
if (el.pressure !== undefined) dev.pressureHPa = parseFloat(el.pressure);
sim.addI2CDevice(dev);
return () => removeI2CDevice(sim, dev.address);
// SensorControlPanel: update temperature / pressure in real-time
registerSensorUpdate(componentId, (values) => {
if ('temperature' in values) dev.temperatureC = values.temperature as number;
if ('pressure' in values) dev.pressureHPa = values.pressure as number;
});
return () => {
removeI2CDevice(sim, dev.address);
unregisterSensorUpdate(componentId);
};
},
});

View File

@ -12,51 +12,68 @@
* - stepper-motor (NEMA full-step decode)
* - led-ring (WS2812B NeoPixel ring)
* - neopixel-matrix (WS2812B NeoPixel matrix)
* - pir-motion-sensor
* - hc-sr04
*/
import { PartSimulationRegistry } from './PartSimulationRegistry';
import { setAdcVoltage } from './partUtils';
import { registerSensorUpdate, unregisterSensorUpdate } from '../SensorUpdateRegistry';
// ─── Tilt Switch ─────────────────────────────────────────────────────────────
/**
* Tilt switch click the element to toggle between tilted (OUT HIGH) and
* upright (OUT LOW). Starts upright (LOW).
* upright (OUT LOW). Also controllable via SensorControlPanel "Toggle tilt" button.
*/
PartSimulationRegistry.register('tilt-switch', {
attachEvents: (element, simulator, getArduinoPinHelper) => {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const pin = getArduinoPinHelper('OUT');
if (pin === null) return () => {};
let tilted = false;
const onClick = () => {
const triggerToggle = () => {
tilted = !tilted;
simulator.setPinState(pin, tilted);
console.log(`[TiltSwitch] pin ${pin}${tilted ? 'HIGH' : 'LOW'}`);
console.log(`[TiltSwitch] pin ${pin}${tilted ? 'HIGH (tilted)' : 'LOW (upright)'}`);
};
// Start LOW (upright)
simulator.setPinState(pin, false);
element.addEventListener('click', onClick);
return () => element.removeEventListener('click', onClick);
element.addEventListener('click', triggerToggle);
// SensorControlPanel callback
registerSensorUpdate(componentId, (values) => {
if (values.toggle === true) triggerToggle();
});
return () => {
element.removeEventListener('click', triggerToggle);
unregisterSensorUpdate(componentId);
};
},
});
// ─── 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.
* NTC thermistor sensor injects analog voltage representing temperature.
* Default 25°C 2.5V. SensorControlPanel slider adjusts temperature.
*
* Linear approximation: volts = clamp(2.5 - (temp - 25) * 0.02, 0, 5)
* (25°C = 2.5V; lower temp = higher voltage, higher temp = lower voltage)
*/
PartSimulationRegistry.register('ntc-temperature-sensor', {
attachEvents: (element, simulator, getArduinoPinHelper) => {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const pin = getArduinoPinHelper('OUT');
if (pin === null) return () => {};
// Room temperature default (2.5V = mid-range)
setAdcVoltage(simulator, pin, 2.5);
const tempToVolts = (temp: number) =>
Math.max(0, Math.min(5, 2.5 - (temp - 25) * 0.02));
// Room temperature default
setAdcVoltage(simulator, pin, tempToVolts(25));
const onInput = () => {
const val = (element as any).value;
@ -65,18 +82,29 @@ PartSimulationRegistry.register('ntc-temperature-sensor', {
}
};
element.addEventListener('input', onInput);
return () => element.removeEventListener('input', onInput);
registerSensorUpdate(componentId, (values) => {
if ('temperature' in values) {
setAdcVoltage(simulator, pin, tempToVolts(values.temperature as number));
}
});
return () => {
element.removeEventListener('input', onInput);
unregisterSensorUpdate(componentId);
};
},
});
// ─── 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.
* Gas sensor injects analog voltage on AOUT.
* Default 1.5V (clean air / low gas). SensorControlPanel slider adjusts level (01023).
* Higher value higher voltage (more gas detected).
*/
PartSimulationRegistry.register('gas-sensor', {
attachEvents: (element, simulator, getArduinoPinHelper) => {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const pinAOUT = getArduinoPinHelper('AOUT');
const pinDOUT = getArduinoPinHelper('DOUT');
const pinManager = (simulator as any).pinManager;
@ -110,18 +138,28 @@ PartSimulationRegistry.register('gas-sensor', {
element.addEventListener('input', onInput);
unsubscribers.push(() => element.removeEventListener('input', onInput));
return () => unsubscribers.forEach(u => u());
registerSensorUpdate(componentId, (values) => {
if ('gasLevel' in values && pinAOUT !== null) {
setAdcVoltage(simulator, pinAOUT, ((values.gasLevel as number) / 1023) * 5.0);
}
});
return () => {
unsubscribers.forEach(u => u());
unregisterSensorUpdate(componentId);
};
},
});
// ─── Flame Sensor ────────────────────────────────────────────────────────────
/**
* Flame sensor injects a low baseline voltage on AOUT (no flame),
* shows power LED. Arduino driving DOUT updates signal LED.
* Flame sensor injects analog voltage on AOUT.
* Default 4.5V (no flame). SensorControlPanel slider: 0 = no flame (high V),
* 1023 = intense flame (low V).
*/
PartSimulationRegistry.register('flame-sensor', {
attachEvents: (element, simulator, getArduinoPinHelper) => {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const pinAOUT = getArduinoPinHelper('AOUT');
const pinDOUT = getArduinoPinHelper('DOUT');
const pinManager = (simulator as any).pinManager;
@ -132,7 +170,7 @@ PartSimulationRegistry.register('flame-sensor', {
const unsubscribers: (() => void)[] = [];
if (pinAOUT !== null) {
setAdcVoltage(simulator, pinAOUT, 1.5);
setAdcVoltage(simulator, pinAOUT, 4.5); // no flame = high voltage
}
if (pinDOUT !== null && pinManager) {
@ -152,7 +190,18 @@ PartSimulationRegistry.register('flame-sensor', {
element.addEventListener('input', onInput);
unsubscribers.push(() => element.removeEventListener('input', onInput));
return () => unsubscribers.forEach(u => u());
registerSensorUpdate(componentId, (values) => {
if ('intensity' in values && pinAOUT !== null) {
// 0 = no flame → high voltage (4.5V); 1023 = flame → low voltage (0.2V)
const volts = 5.0 - ((values.intensity as number) / 1023) * 5.0;
setAdcVoltage(simulator, pinAOUT, volts);
}
});
return () => {
unsubscribers.forEach(u => u());
unregisterSensorUpdate(componentId);
};
},
});
@ -181,11 +230,11 @@ PartSimulationRegistry.register('heart-beat-sensor', {
// ─── Big Sound Sensor ────────────────────────────────────────────────────────
/**
* Big sound sensor (FC-04) injects mid-range analog on AOUT,
* shows power LED (led2). Arduino driving DOUT signal LED (led1).
* Big sound sensor (FC-04) injects mid-range analog on AOUT.
* SensorControlPanel slider adjusts sound level (01023).
*/
PartSimulationRegistry.register('big-sound-sensor', {
attachEvents: (element, simulator, getArduinoPinHelper) => {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const pinAOUT = getArduinoPinHelper('AOUT');
const pinDOUT = getArduinoPinHelper('DOUT');
const pinManager = (simulator as any).pinManager;
@ -216,18 +265,27 @@ PartSimulationRegistry.register('big-sound-sensor', {
element.addEventListener('input', onInput);
unsubscribers.push(() => element.removeEventListener('input', onInput));
return () => unsubscribers.forEach(u => u());
registerSensorUpdate(componentId, (values) => {
if ('soundLevel' in values && pinAOUT !== null) {
setAdcVoltage(simulator, pinAOUT, ((values.soundLevel as number) / 1023) * 5.0);
}
});
return () => {
unsubscribers.forEach(u => u());
unregisterSensorUpdate(componentId);
};
},
});
// ─── Small Sound Sensor ──────────────────────────────────────────────────────
/**
* Small sound sensor (KY-038) injects mid-range analog on AOUT,
* shows power LED. Arduino driving DOUT signal LED.
* Small sound sensor (KY-038) injects mid-range analog on AOUT.
* SensorControlPanel slider adjusts sound level (01023).
*/
PartSimulationRegistry.register('small-sound-sensor', {
attachEvents: (element, simulator, getArduinoPinHelper) => {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const pinAOUT = getArduinoPinHelper('AOUT');
const pinDOUT = getArduinoPinHelper('DOUT');
const pinManager = (simulator as any).pinManager;
@ -258,7 +316,16 @@ PartSimulationRegistry.register('small-sound-sensor', {
element.addEventListener('input', onInput);
unsubscribers.push(() => element.removeEventListener('input', onInput));
return () => unsubscribers.forEach(u => u());
registerSensorUpdate(componentId, (values) => {
if ('soundLevel' in values && pinAOUT !== null) {
setAdcVoltage(simulator, pinAOUT, ((values.soundLevel as number) / 1023) * 5.0);
}
});
return () => {
unsubscribers.forEach(u => u());
unregisterSensorUpdate(componentId);
};
},
});
@ -268,12 +335,6 @@ PartSimulationRegistry.register('small-sound-sensor', {
* 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) => {
@ -308,12 +369,12 @@ PartSimulationRegistry.register('stepper-motor', {
return i;
}
}
return -1; // energized coil pattern not in full-step table
return -1;
}
function onCoilChange() {
const idx = coilToStepIndex();
if (idx < 0) return; // half-step or off state — ignore
if (idx < 0) return;
if (prevStepIndex < 0) { prevStepIndex = idx; return; }
const diff = (idx - prevStepIndex + 4) % 4;
@ -357,14 +418,6 @@ PartSimulationRegistry.register('stepper-motor', {
/**
* 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,
@ -374,9 +427,8 @@ function createNeopixelDecoder(
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
const RESET_CYCLES = 800;
const BIT1_THRESHOLD = 8;
let lastRisingCycle = 0;
let lastFallingCycle = 0;
@ -392,10 +444,8 @@ function createNeopixelDecoder(
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;
@ -404,12 +454,10 @@ function createNeopixelDecoder(
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++;
@ -419,7 +467,6 @@ function createNeopixelDecoder(
bitsCollected = 0;
if (byteBuf.length === 3) {
// WS2812B byte order is GRB
const g = byteBuf[0];
const r = byteBuf[1];
const b = byteBuf[2];
@ -474,7 +521,6 @@ PartSimulationRegistry.register('neopixel-matrix', {
(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;
@ -493,8 +539,7 @@ PartSimulationRegistry.register('neopixel-matrix', {
// ─── Single NeoPixel (WS2812B) ───────────────────────────────────────────────
/**
* Single addressable RGB LED decodes the WS2812B data stream on DIN
* and updates the element's r/g/b properties (01 range).
* Single addressable RGB LED decodes the WS2812B data stream on DIN.
*/
PartSimulationRegistry.register('neopixel', {
attachEvents: (element, simulator, getArduinoPinHelper) => {
@ -520,11 +565,11 @@ PartSimulationRegistry.register('neopixel', {
// ─── PIR Motion Sensor ───────────────────────────────────────────────────────
/**
* PIR motion sensor click the element to simulate a motion event.
* OUT pin goes HIGH for 3 seconds then returns LOW.
* PIR motion sensor click the element OR press "Simulate motion" in the
* SensorControlPanel to trigger a 3-second HIGH pulse on OUT.
*/
PartSimulationRegistry.register('pir-motion-sensor', {
attachEvents: (element, simulator, getArduinoPinHelper) => {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const pin = getArduinoPinHelper('OUT');
if (pin === null) return () => {};
@ -532,9 +577,9 @@ PartSimulationRegistry.register('pir-motion-sensor', {
let timer: ReturnType<typeof setTimeout> | null = null;
const onClick = () => {
const triggerMotion = () => {
if (timer !== null) clearTimeout(timer);
simulator.setPinState(pin, true); // motion detected → HIGH
simulator.setPinState(pin, true);
console.log('[PIR] Motion detected → OUT HIGH');
timer = setTimeout(() => {
simulator.setPinState(pin, false);
@ -543,10 +588,16 @@ PartSimulationRegistry.register('pir-motion-sensor', {
}, 3000);
};
element.addEventListener('click', onClick);
element.addEventListener('click', triggerMotion);
registerSensorUpdate(componentId, (values) => {
if (values.trigger === true) triggerMotion();
});
return () => {
element.removeEventListener('click', onClick);
element.removeEventListener('click', triggerMotion);
if (timer !== null) clearTimeout(timer);
unregisterSensorUpdate(componentId);
};
},
});
@ -555,8 +606,6 @@ PartSimulationRegistry.register('pir-motion-sensor', {
/**
* 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) => {
@ -570,37 +619,49 @@ PartSimulationRegistry.register('ks2e-m-dc5', {
/**
* 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).
* When TRIG goes HIGH, responds with an ECHO HIGH pulse whose duration
* encodes the configured distance (default 10 cm).
*
* Echo timing: echoMs = distanceCm / 17.15
* (speed of sound ~343 m/s; round-trip halves: 17150 cm/s)
*/
PartSimulationRegistry.register('hc-sr04', {
attachEvents: (element, simulator, getArduinoPinHelper) => {
attachEvents: (element, simulator, getArduinoPinHelper, componentId) => {
const trigPin = getArduinoPinHelper('TRIG');
const echoPin = getArduinoPinHelper('ECHO');
if (trigPin === null || echoPin === null) return () => {};
simulator.setPinState(echoPin, false); // ECHO LOW initially
let distanceCm = 10; // default distance in cm
let echoTimer: ReturnType<typeof setTimeout> | 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);
// echoMs = distanceCm / 17.15 (min 1 ms for setTimeout reliability)
const echoMs = Math.max(1, distanceCm / 17.15);
echoTimer = setTimeout(() => {
simulator.setPinState(echoPin, true); // ECHO HIGH
console.log('[HC-SR04] ECHO HIGH (10 cm)');
simulator.setPinState(echoPin, true);
console.log(`[HC-SR04] ECHO HIGH (${distanceCm} cm)`);
echoTimer = setTimeout(() => {
simulator.setPinState(echoPin, false); // ECHO LOW
simulator.setPinState(echoPin, false);
echoTimer = null;
}, 1); // 1 ms ≈ 582 µs → ~10 cm
}, echoMs);
}, 1);
}
});
registerSensorUpdate(componentId, (values) => {
if ('distance' in values) {
distanceCm = Math.max(2, Math.min(400, values.distance as number));
}
});
return () => {
cleanup();
if (echoTimer !== null) clearTimeout(echoTimer);
unregisterSensorUpdate(componentId);
};
},
});

View File

@ -0,0 +1,174 @@
/**
* sensorControlConfig.ts defines the interactive controls shown in the
* SensorControlPanel for each sensor component type.
*
* Used by:
* - SensorControlPanel.tsx (renders the controls)
* - SimulatorCanvas.tsx (decides whether to show the panel on click)
*/
export interface SliderControl {
type: 'slider';
key: string;
label: string;
min: number;
max: number;
step: number;
unit: string;
defaultValue: number;
/** Optional custom formatter — e.g. to show "24.0°C" instead of "24" */
formatValue?: (v: number) => string;
}
export interface ButtonControl {
type: 'button';
key: string;
label: string;
}
export type SensorControl = SliderControl | ButtonControl;
export interface SensorControlDef {
title: string;
controls: SensorControl[];
defaultValues: Record<string, number | boolean>;
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
const oneDecimal = (v: number) => v.toFixed(1);
const twoDecimal = (v: number) => v.toFixed(2);
// ─── Sensor Control Definitions ──────────────────────────────────────────────
export const SENSOR_CONTROLS: Record<string, SensorControlDef> = {
// ── MPU-6050 6-axis IMU ────────────────────────────────────────────────────
mpu6050: {
title: 'MPU6050 Accelerometer + Gyroscope',
controls: [
// Acceleration
{ type: 'slider', key: 'accelX', label: 'X', min: -2, max: 2, step: 0.01, unit: 'g', defaultValue: 0, formatValue: oneDecimal },
{ type: 'slider', key: 'accelY', label: 'Y', min: -2, max: 2, step: 0.01, unit: 'g', defaultValue: 0, formatValue: oneDecimal },
{ type: 'slider', key: 'accelZ', label: 'Z', min: -2, max: 2, step: 0.01, unit: 'g', defaultValue: 1, formatValue: oneDecimal },
// Rotation (gyro)
{ type: 'slider', key: 'gyroX', label: 'X', min: -250, max: 250, step: 1, unit: '°/sec', defaultValue: 0, formatValue: oneDecimal },
{ type: 'slider', key: 'gyroY', label: 'Y', min: -250, max: 250, step: 1, unit: '°/sec', defaultValue: 0, formatValue: oneDecimal },
{ type: 'slider', key: 'gyroZ', label: 'Z', min: -250, max: 250, step: 1, unit: '°/sec', defaultValue: 0, formatValue: oneDecimal },
// Temperature
{ type: 'slider', key: 'temp', label: 'Temperature', min: -40, max: 85, step: 1, unit: '°C', defaultValue: 24, formatValue: oneDecimal },
],
defaultValues: { accelX: 0, accelY: 0, accelZ: 1, gyroX: 0, gyroY: 0, gyroZ: 0, temp: 24 },
},
// ── DHT22 Temperature / Humidity ──────────────────────────────────────────
dht22: {
title: 'DHT22 Temperature & Humidity',
controls: [
{ type: 'slider', key: 'temperature', label: 'Temperature', min: -40, max: 80, step: 0.5, unit: '°C', defaultValue: 25, formatValue: oneDecimal },
{ type: 'slider', key: 'humidity', label: 'Humidity', min: 0, max: 100, step: 0.5, unit: '%', defaultValue: 50, formatValue: oneDecimal },
],
defaultValues: { temperature: 25, humidity: 50 },
},
// ── BMP280 Barometric Pressure + Temperature ───────────────────────────────
bmp280: {
title: 'BMP280 Barometric Pressure Sensor',
controls: [
{ type: 'slider', key: 'temperature', label: 'Temperature', min: -40, max: 85, step: 1, unit: '°C', defaultValue: 24, formatValue: oneDecimal },
{ type: 'slider', key: 'pressure', label: 'Pressure', min: 300, max: 1100, step: 0.25, unit: 'hPa', defaultValue: 1013.25, formatValue: twoDecimal },
],
defaultValues: { temperature: 24, pressure: 1013.25 },
},
// ── HC-SR04 Ultrasonic Distance ───────────────────────────────────────────
'hc-sr04': {
title: 'Ultrasonic Distance Sensor',
controls: [
{ type: 'slider', key: 'distance', label: 'Distance', min: 2, max: 400, step: 1, unit: 'cm', defaultValue: 10 },
],
defaultValues: { distance: 10 },
},
// ── Photoresistor (LDR) ───────────────────────────────────────────────────
'photoresistor-sensor': {
title: 'Photoresistor (LDR)',
controls: [
{ type: 'slider', key: 'lux', label: 'Illumination', min: 0, max: 1000, step: 1, unit: 'lux', defaultValue: 500 },
],
defaultValues: { lux: 500 },
},
// ── PIR Motion Sensor ─────────────────────────────────────────────────────
'pir-motion-sensor': {
title: 'PIR Motion Sensor',
controls: [
{ type: 'button', key: 'trigger', label: 'Simulate motion' },
],
defaultValues: {},
},
// ── NTC Temperature Sensor ────────────────────────────────────────────────
'ntc-temperature-sensor': {
title: 'NTC Temperature Sensor',
controls: [
{ type: 'slider', key: 'temperature', label: 'Temperature', min: -40, max: 125, step: 1, unit: '°C', defaultValue: 25, formatValue: oneDecimal },
],
defaultValues: { temperature: 25 },
},
// ── Gas Sensor (MQ-series) ────────────────────────────────────────────────
'gas-sensor': {
title: 'Gas Sensor (MQ-series)',
controls: [
{ type: 'slider', key: 'gasLevel', label: 'Gas Level', min: 0, max: 1023, step: 1, unit: '', defaultValue: 100 },
],
defaultValues: { gasLevel: 100 },
},
// ── Flame Sensor ──────────────────────────────────────────────────────────
'flame-sensor': {
title: 'Flame Sensor',
controls: [
{ type: 'slider', key: 'intensity', label: 'Flame Intensity', min: 0, max: 1023, step: 1, unit: '', defaultValue: 0 },
],
defaultValues: { intensity: 0 },
},
// ── Big Sound Sensor (FC-04) ──────────────────────────────────────────────
'big-sound-sensor': {
title: 'Sound Sensor',
controls: [
{ type: 'slider', key: 'soundLevel', label: 'Sound Level', min: 0, max: 1023, step: 1, unit: '', defaultValue: 512 },
],
defaultValues: { soundLevel: 512 },
},
// ── Small Sound Sensor (KY-038) ───────────────────────────────────────────
'small-sound-sensor': {
title: 'Sound Sensor (KY-038)',
controls: [
{ type: 'slider', key: 'soundLevel', label: 'Sound Level', min: 0, max: 1023, step: 1, unit: '', defaultValue: 512 },
],
defaultValues: { soundLevel: 512 },
},
// ── Tilt Switch ───────────────────────────────────────────────────────────
'tilt-switch': {
title: 'Tilt Switch',
controls: [
{ type: 'button', key: 'toggle', label: 'Toggle tilt' },
],
defaultValues: {},
},
// ── Analog Joystick ───────────────────────────────────────────────────────
'analog-joystick': {
title: 'Analog Joystick',
controls: [
{ type: 'slider', key: 'xAxis', label: 'X Axis', min: -512, max: 512, step: 1, unit: '', defaultValue: 0 },
{ type: 'slider', key: 'yAxis', label: 'Y Axis', min: -512, max: 512, step: 1, unit: '', defaultValue: 0 },
],
defaultValues: { xAxis: 0, yAxis: 0 },
},
};