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
parent
d170b4f7ea
commit
8ba11800e1
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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 0–1000 → volts 0–5
|
||||
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 0–5V (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);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (0–1023).
|
||||
* 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 (0–1023).
|
||||
*/
|
||||
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 (0–1023).
|
||||
*/
|
||||
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 (0–1 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);
|
||||
};
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
},
|
||||
};
|
||||
Loading…
Reference in New Issue