2026-03-04 05:30:25 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Dynamic Component Renderer
|
|
|
|
|
|
*
|
|
|
|
|
|
* Generic component that renders any wokwi-element web component dynamically.
|
|
|
|
|
|
* Replaces individual React wrapper components (LED.tsx, Resistor.tsx, etc.)
|
|
|
|
|
|
*
|
|
|
|
|
|
* Features:
|
|
|
|
|
|
* - Creates web component from metadata
|
|
|
|
|
|
* - Syncs React props to web component properties
|
|
|
|
|
|
* - Extracts pinInfo from DOM for wire connections
|
|
|
|
|
|
* - Handles component lifecycle
|
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
|
|
import React, { useRef, useEffect, useCallback } from 'react';
|
|
|
|
|
|
import type { ComponentMetadata } from '../types/component-metadata';
|
2026-03-04 23:36:33 +07:00
|
|
|
|
import { useSimulatorStore } from '../store/useSimulatorStore';
|
2026-05-13 09:34:58 +07:00
|
|
|
|
import { useElectricalStore } from '../store/useElectricalStore';
|
feat(microsd): SD-over-SPI card storage for AVR, RP2040 and ESP32
Add a working microSD card part backed by a FAT16 image, following the
Wokwi storage model: the project's own workspace files are auto-copied
onto the card (free), and an optional "SD Card" panel uploads extra
files (gated as a paid feature by the velxio.dev overlay; OSS default
allows it).
Frontend (in-browser AVR / RP2040):
- ProtocolParts.ts: rewrite the microsd-card part from a handshake stub
into a real SD-over-SPI device (reply-first Ncr timing, SDSC byte
addressing, single/multi-block read+write, CSD/CID, full CMD set).
- utils/fatImage.ts: dependency-free FAT16 super-floppy builder (8.3 + LFN).
- utils/sdCardFiles.ts: assemble the card image from workspace files plus
uploaded files; base64 helpers.
- components/simulator/SdCardPanel.tsx + ComponentPropertyDialog: upload UI.
- DynamicComponent + useSimulatorStore: build and inject the image on run.
- lib/proSdCardGate.ts: overlay-installable gate for the upload action.
- data/examples-storage-microsd.ts: Arduino Uno + ESP32 gallery examples.
Backend (ESP32 via QEMU):
- services/esp32_sd_slave.py: synchronous SD-over-SPI slave (Python port of
the browser part) with a sparse backing store, idle-state R1 tracking and
real CRC16 on data blocks when the host enables CRC (CMD59) -- both
required by ESP-IDF's sdspi driver.
- esp32_worker.py: route SPI bytes to the slave (returns MISO synchronously)
and feed write-only bulk transfers.
- esp32_lib_manager.py + routes/simulation.py: forward the FAT image
(sd_card.image_b64) from the start config into the worker.
Tested:
- frontend: protocol-parts, fat-image, sd-card-gate and microsd-real-firmware
(real Arduino SD.h on avr8js) -- 86 passing.
- backend: test_esp32_sd_slave (10) covering the ESP-IDF init sequence and
CRC16; validated end to end by running a real SD.h sketch in libqemu-xtensa
(mount, directory listing, read and write-readback).
2026-06-11 08:59:53 +07:00
|
|
|
|
import { useEditorStore } from '../store/useEditorStore';
|
|
|
|
|
|
import { buildProjectSdImage, decodeSdFiles } from '../utils/sdCardFiles';
|
2026-03-04 23:36:33 +07:00
|
|
|
|
import { PartSimulationRegistry } from '../simulation/parts';
|
2026-03-06 07:07:03 +07:00
|
|
|
|
import { isBoardComponent, boardPinToNumber } from '../utils/boardPinMapping';
|
2026-07-18 07:28:02 +07:00
|
|
|
|
import { isKeyBindable, formatKeyLabel } from '../utils/keyButtonBindings';
|
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine. Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.
What ships:
- simulation/PinResolver.ts
* `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
* `DetailedPinTrace` / `DetailedPinTracer` types — the trace
function now reports whether it crossed an active device, on
top of the Arduino pin number.
* `createSpiceResolvedPinResolver()` — new factory; reads voltages
from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).
- simulation/spice/MixedModeScheduler.ts (new)
* Singleton orchestrator that holds the NgSpiceInteractive engine
and the SpiceVoltageSource subscription registry.
* `start()` / `stop()` / `dispose()` lifecycle.
* `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
* `onMcuPinChange()` placeholder for the alter+tran event loop.
* Skeleton: subscribers register but never receive events yet.
Phase 1b continued will wire NgSpiceInteractive into the loop.
- components/DynamicComponent.tsx
* Trace function extended with `traceDetailed()` that tracks
whether the BFS crossed an active component.
* PinResolver factory branches: active-path → SPICE-resolved (uses
the scheduler), digital-only → existing default impl. Default
threshold = vcc/2 with no hysteresis; Phase 3 will replace with
per-logic-family Vil/Vih.
Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.
Tests:
- pin-resolver-phase1b.test.ts (new) — 8/8 passing.
Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
metadata id; SPICE-resolved resolver state reporting, threshold
conversion, hysteresis dead-band, unsubscribe.
- pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
- tsc --noEmit on the new files: clean.
No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
2026-05-15 21:38:30 +07:00
|
|
|
|
import {
|
|
|
|
|
|
createDefaultPinResolver,
|
|
|
|
|
|
createSpiceResolvedPinResolver,
|
feat(sim): Phase 3 — logic families (TTL/CMOS-5V/LVCMOS33/AVR_HC/Schmitt)
Replaces the Phase 1b vcc/2-flat threshold with per-logic-family
Vil/Vih thresholds + Schmitt-trigger hysteresis where applicable.
SPICE-resolved digital reads now match what real ICs actually do —
TTL noise margins, CMOS rail-to-rail, 74HC14 Schmitt hysteresis,
LVCMOS33 vs CMOS-5V interop.
New module: simulation/LogicFamilies.ts
- LogicFamily interface (vcc, vil, vih, vil_schmitt?, vih_schmitt?,
cin_pF, vol_max?, voh_min?, output_impedance_ohm?)
- FAMILIES catalog: TTL, CMOS-5V, CMOS-5V-SCHMITT, CMOS-5V-TTL-INPUTS,
LVCMOS33, AVR_HC, CMOS-3.3V — all sourced from TI / ATmega328P /
JEDEC datasheets.
- BOARD_FAMILY: per-board lookup. Uno/Mega/Nano/ATtiny → AVR_HC,
ESP32 family + Pi Pico → LVCMOS33, fall back to AVR_HC for
unknown boards.
- getBoardLogicFamily() and getLogicFamilyById() helpers.
PinResolver:
- SpiceResolvedConfig docstring rewritten with Phase 3 wording.
- New `configFromLogicFamily()` builder — picks Schmitt thresholds
when the family declares them, falls back to vih/vil otherwise.
DynamicComponent:
- When the trace crosses an active device, the SPICE-resolved
resolver is now built with the OWNER BOARD's logic family
instead of vcc/2. Hysteresis comes through automatically for
boards whose native family is Schmitt-capable.
- Phase 3 continued: per-component logicFamily override from
components-metadata.json (so e.g. a 74HC14 placed on an Arduino
Uno gets Schmitt thresholds even though the BOARD is AVR_HC).
Tests:
- logic-families.test.ts (new) — 19/19 passing.
Covers catalog sanity (vil < vih, vol_max ≤ vil, voh_min ≥ vih),
per-board lookup, Schmitt vs non-Schmitt config, noise rejection
behavior of 74HC14 Schmitt resolver, last-state-wins behavior
of CMOS-5V dead band.
- Phase 0 + Phase 1b regression: 16/16 still passing.
- tsc --noEmit on new files: clean.
No deploy in this commit — staged for end-of-session rebuild.
2026-05-15 21:42:11 +07:00
|
|
|
|
configFromLogicFamily,
|
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine. Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.
What ships:
- simulation/PinResolver.ts
* `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
* `DetailedPinTrace` / `DetailedPinTracer` types — the trace
function now reports whether it crossed an active device, on
top of the Arduino pin number.
* `createSpiceResolvedPinResolver()` — new factory; reads voltages
from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).
- simulation/spice/MixedModeScheduler.ts (new)
* Singleton orchestrator that holds the NgSpiceInteractive engine
and the SpiceVoltageSource subscription registry.
* `start()` / `stop()` / `dispose()` lifecycle.
* `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
* `onMcuPinChange()` placeholder for the alter+tran event loop.
* Skeleton: subscribers register but never receive events yet.
Phase 1b continued will wire NgSpiceInteractive into the loop.
- components/DynamicComponent.tsx
* Trace function extended with `traceDetailed()` that tracks
whether the BFS crossed an active component.
* PinResolver factory branches: active-path → SPICE-resolved (uses
the scheduler), digital-only → existing default impl. Default
threshold = vcc/2 with no hysteresis; Phase 3 will replace with
per-logic-family Vil/Vih.
Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.
Tests:
- pin-resolver-phase1b.test.ts (new) — 8/8 passing.
Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
metadata id; SPICE-resolved resolver state reporting, threshold
conversion, hysteresis dead-band, unsubscribe.
- pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
- tsc --noEmit on the new files: clean.
No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
2026-05-15 21:38:30 +07:00
|
|
|
|
isActiveDevice,
|
|
|
|
|
|
type PinResolver,
|
|
|
|
|
|
} from '../simulation/PinResolver';
|
2026-05-15 20:50:09 +07:00
|
|
|
|
import { BOARD_PIN_GROUPS } from '../simulation/spice/boardPinGroups';
|
2026-06-03 10:40:49 +07:00
|
|
|
|
import { syntheticChipPin } from '../simulation/customChips/syntheticPins';
|
2026-06-05 11:18:08 +07:00
|
|
|
|
import { resolveChipNetKey } from '../simulation/customChips/chipNets';
|
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine. Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.
What ships:
- simulation/PinResolver.ts
* `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
* `DetailedPinTrace` / `DetailedPinTracer` types — the trace
function now reports whether it crossed an active device, on
top of the Arduino pin number.
* `createSpiceResolvedPinResolver()` — new factory; reads voltages
from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).
- simulation/spice/MixedModeScheduler.ts (new)
* Singleton orchestrator that holds the NgSpiceInteractive engine
and the SpiceVoltageSource subscription registry.
* `start()` / `stop()` / `dispose()` lifecycle.
* `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
* `onMcuPinChange()` placeholder for the alter+tran event loop.
* Skeleton: subscribers register but never receive events yet.
Phase 1b continued will wire NgSpiceInteractive into the loop.
- components/DynamicComponent.tsx
* Trace function extended with `traceDetailed()` that tracks
whether the BFS crossed an active component.
* PinResolver factory branches: active-path → SPICE-resolved (uses
the scheduler), digital-only → existing default impl. Default
threshold = vcc/2 with no hysteresis; Phase 3 will replace with
per-logic-family Vil/Vih.
Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.
Tests:
- pin-resolver-phase1b.test.ts (new) — 8/8 passing.
Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
metadata id; SPICE-resolved resolver state reporting, threshold
conversion, hysteresis dead-band, unsubscribe.
- pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
- tsc --noEmit on the new files: clean.
No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
2026-05-15 21:38:30 +07:00
|
|
|
|
import { getMixedModeScheduler } from '../simulation/spice/MixedModeScheduler';
|
feat(sim): Phase 3 — logic families (TTL/CMOS-5V/LVCMOS33/AVR_HC/Schmitt)
Replaces the Phase 1b vcc/2-flat threshold with per-logic-family
Vil/Vih thresholds + Schmitt-trigger hysteresis where applicable.
SPICE-resolved digital reads now match what real ICs actually do —
TTL noise margins, CMOS rail-to-rail, 74HC14 Schmitt hysteresis,
LVCMOS33 vs CMOS-5V interop.
New module: simulation/LogicFamilies.ts
- LogicFamily interface (vcc, vil, vih, vil_schmitt?, vih_schmitt?,
cin_pF, vol_max?, voh_min?, output_impedance_ohm?)
- FAMILIES catalog: TTL, CMOS-5V, CMOS-5V-SCHMITT, CMOS-5V-TTL-INPUTS,
LVCMOS33, AVR_HC, CMOS-3.3V — all sourced from TI / ATmega328P /
JEDEC datasheets.
- BOARD_FAMILY: per-board lookup. Uno/Mega/Nano/ATtiny → AVR_HC,
ESP32 family + Pi Pico → LVCMOS33, fall back to AVR_HC for
unknown boards.
- getBoardLogicFamily() and getLogicFamilyById() helpers.
PinResolver:
- SpiceResolvedConfig docstring rewritten with Phase 3 wording.
- New `configFromLogicFamily()` builder — picks Schmitt thresholds
when the family declares them, falls back to vih/vil otherwise.
DynamicComponent:
- When the trace crosses an active device, the SPICE-resolved
resolver is now built with the OWNER BOARD's logic family
instead of vcc/2. Hysteresis comes through automatically for
boards whose native family is Schmitt-capable.
- Phase 3 continued: per-component logicFamily override from
components-metadata.json (so e.g. a 74HC14 placed on an Arduino
Uno gets Schmitt thresholds even though the BOARD is AVR_HC).
Tests:
- logic-families.test.ts (new) — 19/19 passing.
Covers catalog sanity (vil < vih, vol_max ≤ vil, voh_min ≥ vih),
per-board lookup, Schmitt vs non-Schmitt config, noise rejection
behavior of 74HC14 Schmitt resolver, last-state-wins behavior
of CMOS-5V dead band.
- Phase 0 + Phase 1b regression: 16/16 still passing.
- tsc --noEmit on new files: clean.
No deploy in this commit — staged for end-of-session rebuild.
2026-05-15 21:42:11 +07:00
|
|
|
|
import { getBoardLogicFamily } from '../simulation/LogicFamilies';
|
2026-07-16 02:50:13 +07:00
|
|
|
|
import { breadboardGroupKey } from '../utils/breadboardNets';
|
2026-03-04 05:30:25 +07:00
|
|
|
|
|
2026-04-21 23:50:45 +07:00
|
|
|
|
// Side-effect imports: register every web component we'll create at runtime.
|
2026-04-22 02:45:45 +07:00
|
|
|
|
// `@wokwi/elements` covers the upstream catalog; `../velxio-elements` adds
|
|
|
|
|
|
// the velxio-local elements (e.g. <velxio-capacitor-electrolytic>,
|
|
|
|
|
|
// <velxio-instr-voltmeter>) that don't exist upstream.
|
2026-04-21 02:38:31 +07:00
|
|
|
|
import '@wokwi/elements';
|
2026-04-22 02:45:45 +07:00
|
|
|
|
import '../velxio-elements';
|
2026-07-10 04:13:50 +07:00
|
|
|
|
import './velxio-components/Ssd1306I2cElement'; // registers velxio-ssd1306-i2c-4pin (4-pin I2C OLED)
|
2026-04-21 02:38:31 +07:00
|
|
|
|
|
2026-05-16 04:55:07 +07:00
|
|
|
|
// Map metadataId → [pinA, pinB] for 2-terminal passives.
|
|
|
|
|
|
// "Tracing through" means: if the caller arrived on pinA, continue from pinB
|
|
|
|
|
|
// (and vice-versa).
|
|
|
|
|
|
//
|
|
|
|
|
|
// NOTE: diodes / transistors / op-amps are NOT traced through as passives —
|
|
|
|
|
|
// they have polarity / Vf / non-linear behaviour that the digital layer
|
|
|
|
|
|
// cannot interpret as "same pin". BJTs are an explicit shortcut for the
|
|
|
|
|
|
// canonical "Arduino digital pin controls a load via transistor" pattern so
|
|
|
|
|
|
// 7-segment multiplex circuits with BJT digit drivers still resolve.
|
|
|
|
|
|
const PASSIVE_PIN_PAIRS_BASE: Record<string, [string, string]> = {
|
|
|
|
|
|
resistor: ['1', '2'],
|
|
|
|
|
|
'resistor-us': ['1', '2'],
|
|
|
|
|
|
capacitor: ['1', '2'],
|
|
|
|
|
|
'capacitor-electrolytic': ['+', '−'],
|
|
|
|
|
|
inductor: ['1', '2'],
|
|
|
|
|
|
'analog-resistor': ['A', 'B'],
|
|
|
|
|
|
'analog-capacitor': ['A', 'B'],
|
|
|
|
|
|
'analog-inductor': ['A', 'B'],
|
|
|
|
|
|
'bjt-2n2222': ['C', 'B'],
|
|
|
|
|
|
'bjt-bc547': ['C', 'B'],
|
|
|
|
|
|
'bjt-2n3055': ['C', 'B'],
|
|
|
|
|
|
'bjt-2n3906': ['C', 'B'],
|
|
|
|
|
|
'bjt-bc557': ['C', 'B'],
|
|
|
|
|
|
};
|
|
|
|
|
|
// Preset variants of the generic passives share their parent's tag and pin
|
|
|
|
|
|
// layout. Mirrors the PASSIVE_PRESETS map in spice/componentToSpice.ts.
|
|
|
|
|
|
const PRESET_TO_BASE: Record<string, string> = {
|
|
|
|
|
|
'resistor-220': 'resistor',
|
|
|
|
|
|
'resistor-330': 'resistor',
|
|
|
|
|
|
'resistor-470': 'resistor',
|
|
|
|
|
|
'resistor-1k': 'resistor',
|
|
|
|
|
|
'resistor-2k2': 'resistor',
|
|
|
|
|
|
'resistor-4k7': 'resistor',
|
|
|
|
|
|
'resistor-10k': 'resistor',
|
|
|
|
|
|
'resistor-22k': 'resistor',
|
|
|
|
|
|
'resistor-47k': 'resistor',
|
|
|
|
|
|
'resistor-100k': 'resistor',
|
|
|
|
|
|
'resistor-1m': 'resistor',
|
|
|
|
|
|
'cap-10p': 'capacitor',
|
|
|
|
|
|
'cap-22p': 'capacitor',
|
|
|
|
|
|
'cap-100p': 'capacitor',
|
|
|
|
|
|
'cap-1n': 'capacitor',
|
|
|
|
|
|
'cap-10n': 'capacitor',
|
|
|
|
|
|
'cap-100n': 'capacitor',
|
|
|
|
|
|
'cap-1u': 'capacitor',
|
|
|
|
|
|
'cap-elec-1u': 'capacitor-electrolytic',
|
|
|
|
|
|
'cap-elec-10u': 'capacitor-electrolytic',
|
|
|
|
|
|
'cap-elec-47u': 'capacitor-electrolytic',
|
|
|
|
|
|
'cap-elec-100u': 'capacitor-electrolytic',
|
|
|
|
|
|
'cap-elec-470u': 'capacitor-electrolytic',
|
|
|
|
|
|
'cap-elec-1000u': 'capacitor-electrolytic',
|
|
|
|
|
|
'ind-100u': 'inductor',
|
|
|
|
|
|
'ind-1m': 'inductor',
|
|
|
|
|
|
'ind-10m': 'inductor',
|
|
|
|
|
|
};
|
|
|
|
|
|
const PASSIVE_PIN_PAIRS: Record<string, [string, string]> = {
|
|
|
|
|
|
...PASSIVE_PIN_PAIRS_BASE,
|
|
|
|
|
|
};
|
|
|
|
|
|
for (const [preset, base] of Object.entries(PRESET_TO_BASE)) {
|
|
|
|
|
|
PASSIVE_PIN_PAIRS[preset] = PASSIVE_PIN_PAIRS_BASE[base];
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type TraceState = ReturnType<typeof useSimulatorStore.getState>;
|
|
|
|
|
|
|
2026-06-03 10:40:49 +07:00
|
|
|
|
// Custom-chip output pins get stable synthetic pin numbers from
|
|
|
|
|
|
// simulation/customChips/syntheticPins so the chip is a first-class pin source.
|
|
|
|
|
|
|
2026-05-16 04:55:07 +07:00
|
|
|
|
// Depth-limited BFS: trace from (fromId, fromPin) through wires, traversing
|
|
|
|
|
|
// through passive components to reach a board pin. Returns the arduino pin
|
|
|
|
|
|
// plus a `crossedActiveDevice` flag so the resolver factory can decide
|
|
|
|
|
|
// between digital fast-path and SPICE-resolved per-pin.
|
|
|
|
|
|
//
|
2026-06-03 10:40:49 +07:00
|
|
|
|
// A real board pin always wins (digital GPIO semantics are unchanged). Only
|
|
|
|
|
|
// when NO board pin is reachable do we fall back to a custom-chip pin on the
|
|
|
|
|
|
// net — either a neighbour chip pin, or (when the trace itself started at a
|
|
|
|
|
|
// chip pin) the starting chip pin — resolving it to its synthetic number.
|
|
|
|
|
|
//
|
2026-05-16 04:55:07 +07:00
|
|
|
|
// Lifted to module scope (was inside getArduinoPin) so that getPinResolver
|
|
|
|
|
|
// can call it too — the previous nested-scope version caused a runtime
|
|
|
|
|
|
// ReferenceError "traceDetailed is not defined" on the simulator page.
|
|
|
|
|
|
function traceDetailed(
|
|
|
|
|
|
state: TraceState,
|
|
|
|
|
|
fromId: string,
|
|
|
|
|
|
fromPin: string,
|
|
|
|
|
|
depth: number,
|
|
|
|
|
|
activeSeen = false,
|
|
|
|
|
|
): { arduinoPin: number | null; crossedActiveDevice: boolean } {
|
|
|
|
|
|
if (depth > 6) return { arduinoPin: null, crossedActiveDevice: activeSeen };
|
|
|
|
|
|
|
|
|
|
|
|
const wires = state.wires.filter(
|
|
|
|
|
|
(w) =>
|
|
|
|
|
|
(w.start.componentId === fromId && w.start.pinName === fromPin) ||
|
|
|
|
|
|
(w.end.componentId === fromId && w.end.pinName === fromPin),
|
|
|
|
|
|
);
|
|
|
|
|
|
|
2026-06-03 10:40:49 +07:00
|
|
|
|
// Remember a custom-chip neighbour on this net (if any) as a fallback —
|
|
|
|
|
|
// a real board pin found in any branch still takes priority over it.
|
|
|
|
|
|
let chipNeighbour: { id: string; pin: string } | null = null;
|
|
|
|
|
|
|
2026-05-16 04:55:07 +07:00
|
|
|
|
for (const w of wires) {
|
|
|
|
|
|
const selfEp =
|
|
|
|
|
|
w.start.componentId === fromId && w.start.pinName === fromPin ? w.start : w.end;
|
|
|
|
|
|
const otherEp = selfEp === w.start ? w.end : w.start;
|
|
|
|
|
|
|
|
|
|
|
|
if (isBoardComponent(otherEp.componentId)) {
|
|
|
|
|
|
const boardKind =
|
|
|
|
|
|
state.boards.find((b) => b.id === otherEp.componentId)?.boardKind ??
|
|
|
|
|
|
otherEp.componentId;
|
|
|
|
|
|
const pin = boardPinToNumber(boardKind, otherEp.pinName);
|
|
|
|
|
|
if (pin !== null) return { arduinoPin: pin, crossedActiveDevice: activeSeen };
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const comp = state.components.find((c) => c.id === otherEp.componentId);
|
2026-06-03 10:40:49 +07:00
|
|
|
|
if (!chipNeighbour && comp?.metadataId === 'custom-chip') {
|
|
|
|
|
|
chipNeighbour = { id: otherEp.componentId, pin: otherEp.pinName };
|
|
|
|
|
|
}
|
2026-05-16 04:55:07 +07:00
|
|
|
|
const pair = comp && PASSIVE_PIN_PAIRS[comp.metadataId];
|
|
|
|
|
|
if (pair) {
|
|
|
|
|
|
const [p1, p2] = pair;
|
|
|
|
|
|
const otherPin = otherEp.pinName === p1 ? p2 : p1;
|
|
|
|
|
|
const nowActive =
|
|
|
|
|
|
activeSeen || (comp ? isActiveDevice(comp.metadataId) : false);
|
|
|
|
|
|
const result = traceDetailed(
|
|
|
|
|
|
state,
|
|
|
|
|
|
otherEp.componentId,
|
|
|
|
|
|
otherPin,
|
|
|
|
|
|
depth + 1,
|
|
|
|
|
|
nowActive,
|
|
|
|
|
|
);
|
|
|
|
|
|
if (result.arduinoPin !== null) return result;
|
|
|
|
|
|
}
|
2026-07-16 02:50:13 +07:00
|
|
|
|
|
|
|
|
|
|
// Breadboards join N holes per internal group (5-hole strip / power
|
|
|
|
|
|
// rail), which the 2-terminal PASSIVE_PIN_PAIRS map can't express.
|
|
|
|
|
|
// Continue the trace from every OTHER wired hole in the same group.
|
|
|
|
|
|
const bbGroup = comp && breadboardGroupKey(comp.metadataId, otherEp.pinName);
|
|
|
|
|
|
if (bbGroup && comp) {
|
|
|
|
|
|
const groupPins = new Set<string>();
|
|
|
|
|
|
for (const gw of state.wires) {
|
|
|
|
|
|
for (const ep of [gw.start, gw.end]) {
|
|
|
|
|
|
if (
|
|
|
|
|
|
ep.componentId === comp.id &&
|
|
|
|
|
|
ep.pinName !== otherEp.pinName &&
|
|
|
|
|
|
breadboardGroupKey(comp.metadataId, ep.pinName) === bbGroup
|
|
|
|
|
|
) {
|
|
|
|
|
|
groupPins.add(ep.pinName);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for (const groupPin of groupPins) {
|
|
|
|
|
|
const result = traceDetailed(state, comp.id, groupPin, depth + 1, activeSeen);
|
|
|
|
|
|
if (result.arduinoPin !== null) return result;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-05-16 04:55:07 +07:00
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-06-03 10:40:49 +07:00
|
|
|
|
|
2026-06-05 11:18:08 +07:00
|
|
|
|
// No board pin reachable. Multi-chip digital bus (chipbus flag, Phase 0 of
|
|
|
|
|
|
// project/multichip-bus/): when this net has two or more chip endpoints and
|
|
|
|
|
|
// no board pin, collapse every endpoint onto ONE net-canonical synthetic key
|
|
|
|
|
|
// so a write on one chip is visible to another through the synchronous
|
|
|
|
|
|
// PinManager fan-out (fixes root cause A: per-endpoint keys never matching).
|
|
|
|
|
|
// resolveChipNetKey returns null when the flag is off, when a board owns the
|
|
|
|
|
|
// net, or when there is a single chip endpoint — so the chip-to-component
|
|
|
|
|
|
// rules below (2 and 3) are left exactly as-is. Scoped to depth 0 (the
|
|
|
|
|
|
// starting chip pin); the key is net-bound, so a pin flipping INPUT<->OUTPUT
|
|
|
|
|
|
// keeps the same key with no re-trace.
|
|
|
|
|
|
if (depth === 0) {
|
|
|
|
|
|
const netKey = resolveChipNetKey(state, fromId, fromPin);
|
|
|
|
|
|
if (netKey !== null) {
|
|
|
|
|
|
return { arduinoPin: netKey, crossedActiveDevice: activeSeen };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-03 10:40:49 +07:00
|
|
|
|
// No board pin reachable. Fall back to a custom-chip pin on this net so the
|
|
|
|
|
|
// chip can still drive / read it through the synthetic-pin PinManager key.
|
|
|
|
|
|
if (chipNeighbour) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
arduinoPin: syntheticChipPin(chipNeighbour.id, chipNeighbour.pin),
|
|
|
|
|
|
crossedActiveDevice: activeSeen,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
if (depth === 0 && state.components.find((c) => c.id === fromId)?.metadataId === 'custom-chip') {
|
|
|
|
|
|
return { arduinoPin: syntheticChipPin(fromId, fromPin), crossedActiveDevice: activeSeen };
|
|
|
|
|
|
}
|
2026-05-16 04:55:07 +07:00
|
|
|
|
return { arduinoPin: null, crossedActiveDevice: activeSeen };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-03-04 05:30:25 +07:00
|
|
|
|
interface DynamicComponentProps {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
metadata: ComponentMetadata;
|
|
|
|
|
|
properties: Record<string, any>;
|
|
|
|
|
|
x?: number;
|
|
|
|
|
|
y?: number;
|
|
|
|
|
|
isSelected?: boolean;
|
|
|
|
|
|
onMouseDown?: (e: React.MouseEvent) => void;
|
|
|
|
|
|
onDoubleClick?: (e: React.MouseEvent) => void;
|
|
|
|
|
|
onMouseEnter?: () => void;
|
|
|
|
|
|
onMouseLeave?: () => void;
|
|
|
|
|
|
onPinInfoReady?: (pinInfo: any[]) => void;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export const DynamicComponent: React.FC<DynamicComponentProps> = ({
|
|
|
|
|
|
id,
|
|
|
|
|
|
metadata,
|
|
|
|
|
|
properties,
|
|
|
|
|
|
x = 0,
|
|
|
|
|
|
y = 0,
|
|
|
|
|
|
isSelected = false,
|
|
|
|
|
|
onMouseDown,
|
|
|
|
|
|
onDoubleClick,
|
|
|
|
|
|
onMouseEnter,
|
|
|
|
|
|
onMouseLeave,
|
|
|
|
|
|
onPinInfoReady,
|
|
|
|
|
|
}) => {
|
|
|
|
|
|
const elementRef = useRef<HTMLElement>(null);
|
|
|
|
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
|
|
|
|
const mountedRef = useRef(false);
|
|
|
|
|
|
|
2026-03-04 23:36:33 +07:00
|
|
|
|
const handleComponentEvent = useSimulatorStore((s) => s.handleComponentEvent);
|
2026-03-05 04:03:54 +07:00
|
|
|
|
const running = useSimulatorStore((s) => s.running);
|
2026-03-05 04:27:14 +07:00
|
|
|
|
const simulator = useSimulatorStore((s) => s.simulator);
|
2026-05-13 09:34:58 +07:00
|
|
|
|
// Board-less SPICE circuits (digital / analog gallery) have no MCU to
|
|
|
|
|
|
// run, so `running` is always false — but interactive parts like
|
|
|
|
|
|
// slide-switches and pushbuttons should still show a pointer cursor
|
|
|
|
|
|
// and let the user click them. We treat board-less + un-paused as
|
|
|
|
|
|
// "interactive" so the cursor + dialog gating mirror the MCU mode.
|
|
|
|
|
|
const boardCount = useSimulatorStore((s) => s.boards.length);
|
|
|
|
|
|
const electricalPaused = useElectricalStore((s) => s.paused);
|
|
|
|
|
|
const interactionRunning = running || (boardCount === 0 && !electricalPaused);
|
2026-03-09 12:31:04 +07:00
|
|
|
|
// hexEpoch increments each time a new hex is loaded, triggering a fresh
|
|
|
|
|
|
// attachEvents call (and re-registration of I2C devices on the new bus).
|
|
|
|
|
|
// We intentionally do NOT depend on `running` so that I2C displays and
|
|
|
|
|
|
// other protocol parts (SSD1306, DS1307 …) are NOT torn down and
|
|
|
|
|
|
// re-created on every stop/play cycle — which previously caused the
|
|
|
|
|
|
// display to flash blank and lose its frame buffer.
|
|
|
|
|
|
const hexEpoch = useSimulatorStore((s) => s.hexEpoch);
|
2026-06-18 09:35:56 +07:00
|
|
|
|
// Runtime burnout (P4): destroyed parts render charred + a smoke badge.
|
|
|
|
|
|
const isBurnt = useSimulatorStore((s) => s.burntComponents.has(id));
|
2026-03-05 04:03:54 +07:00
|
|
|
|
|
2026-04-07 21:19:48 +07:00
|
|
|
|
// Track wires connected to this component so attachEvents re-runs when
|
|
|
|
|
|
// wires are added or removed (e.g. disconnecting an LED cathode from GND).
|
|
|
|
|
|
const wireFingerprint = useSimulatorStore((s) => {
|
2026-04-22 02:45:45 +07:00
|
|
|
|
const myWires = s.wires.filter((w) => w.start.componentId === id || w.end.componentId === id);
|
|
|
|
|
|
return myWires.map((w) => w.id).join(',');
|
2026-04-07 21:19:48 +07:00
|
|
|
|
});
|
|
|
|
|
|
|
2026-03-05 04:03:54 +07:00
|
|
|
|
// Check if component is interactive (has simulation logic with attachEvents)
|
|
|
|
|
|
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]);
|
|
|
|
|
|
const isInteractive = logic?.attachEvents !== undefined;
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
2026-03-04 05:30:25 +07:00
|
|
|
|
/**
|
2026-07-19 00:18:27 +07:00
|
|
|
|
* Sync React properties to Web Component.
|
|
|
|
|
|
*
|
|
|
|
|
|
* Values arriving as strings (agent set_component_property, the text
|
|
|
|
|
|
* inputs in the property dialog) are coerced to the type of the
|
|
|
|
|
|
* metadata DEFAULT for that key. Without this, `el.digits = '4'`
|
|
|
|
|
|
* (string) silently breaks wokwi elements that strict-match
|
|
|
|
|
|
* (`switch (this.digits) { case 4: ... }` -> falls back to the 1-digit
|
|
|
|
|
|
* pinout), and `'false'` stays truthy for boolean props like colon.
|
2026-03-04 05:30:25 +07:00
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!elementRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
Object.entries(properties).forEach(([key, value]) => {
|
|
|
|
|
|
try {
|
2026-07-19 00:18:27 +07:00
|
|
|
|
let coerced: any = value;
|
|
|
|
|
|
if (typeof value === 'string') {
|
|
|
|
|
|
const def = metadata.defaultValues?.[key];
|
|
|
|
|
|
if (typeof def === 'number' && value.trim() !== '' && !Number.isNaN(Number(value))) {
|
|
|
|
|
|
coerced = Number(value);
|
|
|
|
|
|
} else if (typeof def === 'boolean') {
|
|
|
|
|
|
coerced = value === 'true' || value === '1';
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
(elementRef.current as any)[key] = coerced;
|
2026-03-04 05:30:25 +07:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn(`Failed to set property ${key} on ${metadata.tagName}:`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
}, [properties, metadata.tagName]);
|
|
|
|
|
|
|
2026-07-19 00:18:27 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Property changes that swap the element's pin SET (7segment digits,
|
|
|
|
|
|
* LED flip, display pins edge) re-render asynchronously and announce
|
|
|
|
|
|
* themselves with a 'pininfo-change' event. Re-derive the breadboard
|
|
|
|
|
|
* seating then — reseating synchronously on the property write would
|
|
|
|
|
|
* read the STALE pinout and seat ghost pins.
|
|
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const el = elementRef.current;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
const onPinInfoChange = () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
useSimulatorStore.getState().reseatComponentOnBreadboard(id);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// headless / tests
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
el.addEventListener('pininfo-change', onPinInfoChange);
|
|
|
|
|
|
return () => el.removeEventListener('pininfo-change', onPinInfoChange);
|
|
|
|
|
|
}, [id, metadata.tagName]);
|
|
|
|
|
|
|
2026-03-04 05:30:25 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Extract pinInfo from web component after it initializes
|
|
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!elementRef.current || !onPinInfoReady) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Wait for web component to fully initialize
|
|
|
|
|
|
const checkPinInfo = () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const pinInfo = (elementRef.current as any)?.pinInfo;
|
|
|
|
|
|
if (pinInfo && Array.isArray(pinInfo) && pinInfo.length > 0) {
|
|
|
|
|
|
onPinInfoReady(pinInfo);
|
|
|
|
|
|
return true;
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
// Element not ready yet
|
|
|
|
|
|
}
|
|
|
|
|
|
return false;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// Try immediately
|
|
|
|
|
|
if (checkPinInfo()) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Otherwise poll every 100ms for up to 2 seconds
|
|
|
|
|
|
const interval = setInterval(() => {
|
|
|
|
|
|
if (checkPinInfo()) {
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
}
|
|
|
|
|
|
}, 100);
|
|
|
|
|
|
|
|
|
|
|
|
const timeout = setTimeout(() => {
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
clearInterval(interval);
|
|
|
|
|
|
clearTimeout(timeout);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [onPinInfoReady]);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Handle mouse events
|
|
|
|
|
|
*/
|
|
|
|
|
|
const handleMouseDown = useCallback(
|
|
|
|
|
|
(e: React.MouseEvent) => {
|
2026-05-20 01:10:42 +07:00
|
|
|
|
if (!onMouseDown) return;
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +07:00
|
|
|
|
// Don't swallow the pointerdown for wokwi components that own their
|
|
|
|
|
|
// own pointer interaction (rotary knobs, pushbuttons, slide-switches,
|
|
|
|
|
|
// joysticks, keypads, encoders). For those the wokwi element binds
|
|
|
|
|
|
// pointerdown/move/up on its shadow-DOM SVG; if we call
|
|
|
|
|
|
// stopPropagation() in the capture phase here the internal logic
|
|
|
|
|
|
// never sees the event and the knob can't rotate, the button never
|
|
|
|
|
|
// reports pressed, etc.
|
2026-05-20 01:10:42 +07:00
|
|
|
|
//
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +07:00
|
|
|
|
// EVERY OTHER component (sensors, displays, LEDs, resistors, even
|
|
|
|
|
|
// ones with attachEvents for the sensor-update / SPICE-prop bridge)
|
|
|
|
|
|
// expects clicks to bubble up to the canvas → open the property
|
|
|
|
|
|
// dialog or grab for drag-to-rearrange. The previous "swallow only
|
|
|
|
|
|
// when isInteractive" heuristic was too broad: it included DHT22,
|
|
|
|
|
|
// HC-SR04, NTC, photoresistor, LED, etc. — all of which have
|
|
|
|
|
|
// attachEvents but no internal pointer handler, so clicks on them
|
|
|
|
|
|
// SHOULD bubble. With the broad guard, those dialogs never opened.
|
2026-05-20 01:10:42 +07:00
|
|
|
|
//
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +07:00
|
|
|
|
// The whitelist below is tight on purpose: only add a tag name when
|
|
|
|
|
|
// the wokwi element actually has its own pointerdown handler that
|
|
|
|
|
|
// the user needs to reach. If a new interactive part is added,
|
|
|
|
|
|
// append its tag here.
|
2026-05-20 01:10:42 +07:00
|
|
|
|
const target = e.target as HTMLElement;
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +07:00
|
|
|
|
const tag = target.tagName?.toLowerCase() ?? '';
|
|
|
|
|
|
const ownsPointer =
|
2026-05-20 01:10:42 +07:00
|
|
|
|
interactionRunning &&
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +07:00
|
|
|
|
(tag === 'wokwi-pushbutton' ||
|
|
|
|
|
|
tag === 'wokwi-pushbutton-6mm' ||
|
|
|
|
|
|
tag === 'wokwi-potentiometer' ||
|
|
|
|
|
|
tag === 'wokwi-slide-potentiometer' ||
|
|
|
|
|
|
tag === 'wokwi-slide-switch' ||
|
|
|
|
|
|
tag === 'wokwi-dip-switch-8' ||
|
|
|
|
|
|
tag === 'wokwi-analog-joystick' ||
|
|
|
|
|
|
tag === 'wokwi-ky-040' ||
|
|
|
|
|
|
tag === 'wokwi-membrane-keypad' ||
|
|
|
|
|
|
tag === 'wokwi-rotary-dialer');
|
|
|
|
|
|
if (ownsPointer) {
|
2026-05-20 01:10:42 +07:00
|
|
|
|
// Let the wokwi component own this pointerdown.
|
|
|
|
|
|
return;
|
2026-03-04 05:30:25 +07:00
|
|
|
|
}
|
2026-05-20 01:10:42 +07:00
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onMouseDown(e);
|
2026-03-04 05:30:25 +07:00
|
|
|
|
},
|
fix(ui): narrow pointer-passthrough whitelist; restore DHT22/HC-SR04 dialog
The previous fix (dd22bcf) used `isInteractive` to decide whether to let
the wokwi component own the pointerdown. That heuristic was too broad —
DHT22, HC-SR04, NTC, photoresistor, LED all register `attachEvents` for
the SPICE/sensor-update bridge but have NO internal pointer handlers, so
clicks on them got silently swallowed by the wokwi shadow DOM and the
property dialog never opened.
Replace with an explicit whitelist of wokwi tags that ACTUALLY own
pointerdown (rotary knobs, pushbuttons, slide switches, joysticks,
keypads, encoders, rotary dialer). Every other component, including
sensors/displays/LEDs with attachEvents, falls through to the canvas
which decides between drag-to-rearrange and click-to-open-dialog.
Documented the model in docs/wiki/component-interaction.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-20 01:16:47 +07:00
|
|
|
|
[onMouseDown, interactionRunning],
|
2026-03-04 05:30:25 +07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleDoubleClick = useCallback(
|
|
|
|
|
|
(e: React.MouseEvent) => {
|
|
|
|
|
|
if (onDoubleClick) {
|
|
|
|
|
|
e.stopPropagation();
|
|
|
|
|
|
onDoubleClick(e);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-04-22 02:45:45 +07:00
|
|
|
|
[onDoubleClick],
|
2026-03-04 05:30:25 +07:00
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Mount web component (only once)
|
|
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
if (!containerRef.current) return;
|
|
|
|
|
|
|
|
|
|
|
|
// Prevent double-mount in React StrictMode
|
|
|
|
|
|
if (mountedRef.current) {
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const element = document.createElement(metadata.tagName);
|
|
|
|
|
|
element.id = id;
|
|
|
|
|
|
|
|
|
|
|
|
// Set initial properties
|
|
|
|
|
|
Object.entries(properties).forEach(([key, value]) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
(element as any)[key] = value;
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn(`Failed to set initial property ${key}:`, error);
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
containerRef.current.appendChild(element);
|
|
|
|
|
|
elementRef.current = element;
|
|
|
|
|
|
mountedRef.current = true;
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (containerRef.current && element.parentNode === containerRef.current) {
|
|
|
|
|
|
containerRef.current.removeChild(element);
|
|
|
|
|
|
}
|
|
|
|
|
|
elementRef.current = null;
|
|
|
|
|
|
mountedRef.current = false;
|
|
|
|
|
|
};
|
|
|
|
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
|
|
|
|
}, [metadata.tagName, id]); // Only re-create if tagName or id changes
|
|
|
|
|
|
|
2026-03-04 23:36:33 +07:00
|
|
|
|
/**
|
|
|
|
|
|
* Attach component-specific DOM events (like button presses)
|
|
|
|
|
|
*/
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const el = elementRef.current;
|
|
|
|
|
|
if (!el) return;
|
|
|
|
|
|
|
|
|
|
|
|
const onButtonPress = (e: Event) => handleComponentEvent(id, 'button-press', e);
|
|
|
|
|
|
const onButtonRelease = (e: Event) => handleComponentEvent(id, 'button-release', e);
|
|
|
|
|
|
|
|
|
|
|
|
el.addEventListener('button-press', onButtonPress);
|
|
|
|
|
|
el.addEventListener('button-release', onButtonRelease);
|
|
|
|
|
|
|
2026-03-05 04:27:14 +07:00
|
|
|
|
const logic = PartSimulationRegistry.get(metadata.id || id.split('-')[0]);
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
|
|
|
|
|
let cleanupSimulationEvents: (() => void) | undefined;
|
2026-05-13 00:26:33 +07:00
|
|
|
|
if (logic && logic.attachEvents) {
|
|
|
|
|
|
// Board-less circuits (analog/digital SPICE examples) have no MCU
|
|
|
|
|
|
// simulator, but input parts (switches, buttons, DIP switches) still
|
|
|
|
|
|
// need their `change`/`button-press` events to fire `emitPropertyChange`
|
|
|
|
|
|
// so the SPICE solver re-runs. Every part already guards its
|
|
|
|
|
|
// `simulator.setPinState` / `pinManager.onPinChange` calls behind a
|
|
|
|
|
|
// null pin lookup (`getArduinoPin` returns null when there's no board),
|
|
|
|
|
|
// so the stub below is enough — it satisfies the type signature without
|
|
|
|
|
|
// doing anything when called.
|
|
|
|
|
|
const stubSimulator =
|
|
|
|
|
|
simulator ??
|
|
|
|
|
|
({
|
|
|
|
|
|
setPinState: () => {},
|
|
|
|
|
|
isRunning: () => false,
|
2026-06-04 02:04:17 +07:00
|
|
|
|
// Board-less circuits have no MCU simulator, but a custom chip still
|
|
|
|
|
|
// needs a real PinManager so its digital pin writes/reads reach the
|
|
|
|
|
|
// components wired to it (LEDs, buttons, other chips). Hand it the
|
|
|
|
|
|
// shared flat PinManager that SimulatorCanvas subscribes LEDs to, so
|
|
|
|
|
|
// both sides talk on the same numeric/synthetic pin ids. Falls back
|
|
|
|
|
|
// to a no-op only if even that isn't ready yet.
|
|
|
|
|
|
pinManager:
|
|
|
|
|
|
(useSimulatorStore.getState().pinManager as any) ?? {
|
|
|
|
|
|
onPinChange: () => () => {},
|
|
|
|
|
|
triggerPinChange: () => {},
|
|
|
|
|
|
},
|
2026-05-13 00:26:33 +07:00
|
|
|
|
} as any);
|
2026-04-13 21:06:03 +07:00
|
|
|
|
// Helper to find Arduino pin connected to a component pin.
|
feat: expand SPICE component catalog (fases 9 + 10)
Adds 44 SPICE mappers, 58 custom metadata entries, and 12 visual
Web Components covering logic gates, transistors, op-amps, regulators,
sources, electromechanical parts and integrated-circuit packaging.
Fase 9 — component catalog expansion
------------------------------------
- 7 logic gates (AND/OR/NAND/NOR/XOR/XNOR + NOT) as SPICE B-sources
- 8 multi-input gates (AND/OR/NAND/NOR with 3 and 4 inputs)
- 9 transistors: 5 BJTs (incl. PNP 2N3906/BC557) + 4 MOSFETs (incl.
P-channel IRF9540/FQP27P06). NMOS refactored from Level=3 W=0.1
(hangs ngspice) to Level=1 with sane W/L
- 5 op-amps: LM358, LM741, TL072, LM324 with per-chip saturation
rails + opamp-ideal
- 4 linear regulators (7805, 7812, 7905, LM317) with dropout
- 3 batteries (9V, AA, coin-cell) with realistic ESR
- Signal generator (sine / square / DC)
- 2 Schottky diodes (1N5817, 1N5819) + photodiode (lux-driven
current source)
Fase 10 — electromechanical + ICs
---------------------------------
- Relay (SPDT): coil + L + S-switch with native hysteresis +
flyback diode, inverted-control trick for the NC contact
- Optocouplers 4N25 and PC817 (LED + CCCS with CTR=0.5 / 1.0)
- 7 74HC ICs as DIP-14 packages emitting 4 or 6 B-sources per
component (first mapper pattern emitting multiple device cards)
- 3 flip-flops (D, T, JK) — digital-sim only (edge detection is
not representable in ngspice .op)
- L293D dual H-bridge motor driver
Infrastructure
--------------
- scripts/component-overrides.json gains a _customComponents[] array
that lets new Velxio-only parts survive metadata regeneration
(previously applyOverrides() could only patch wokwi-elements
components that had already been scanned)
- scripts/generate-component-metadata.ts injects custom entries
before the patch loop
- New ComponentCategory values: 'logic', 'analog', 'electromech'
- frontend/src/components/DynamicComponent.tsx PASSIVE tracing
extended from just ['resistor','resistor-us'] to 9 two-terminal
passives with per-part pin name maps
- New CI workflow test-circuit.yml runs the sandbox on push/PR
- frontend-tests.yml regenerates metadata and fails if committed
JSON is stale
- Documented 2 new ngspice gotchas in circuit-emulation-gotchas.md:
unicode in netlist titles silently hangs the parser, and
MOSFET Level=3 + W=0.1m causes .op to hang
- 164/164 sandbox tests passing in ~9 s (was 88 pre-fase-9)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 06:44:18 +07:00
|
|
|
|
// Traces through electrically-transparent passive components so that a
|
|
|
|
|
|
// circuit like LED-cathode → resistor → GND returns -1 (GND) instead
|
fix(DynamicComponent): pass componentId through to PinTracer
`PinTracer` signature is `(componentId, componentPinName) => number | null`
but the local `getArduinoPin` lambda only accepted one arg and used the
closure-captured `id`. When `createDefaultPinResolver` passed both args
(per the typed signature), JS bound the FIRST arg (the componentId) into
the lambda's single `componentPinName` parameter. `traceDetailed` then
looked up a pin literally named "rgb-led-1" on component "rgb-led-1",
returned null, and the resolver locked itself into 'FLOATING' state —
its onChange path never subscribed and the wokwi-rgb-led element's
ledRed/ledGreen/ledBlue stayed at 0 forever even as the SPICE side
correctly cycled through R, G, B, Y, C, M, W via analogWrite().
Same bug latent for any multi-pin component that goes through the
PinResolver path (multi-pin LEDs, RGB strips, 7-seg drivers, anything
that calls `getPinResolver(<pinName>)` for several pin names).
Fix: lambda now accepts both shapes — `getArduinoPin(pinName)` (legacy
single-arg used by every PartSimulationRegistry handler) AND
`getArduinoPin(componentId, pinName)` (PinTracer 2-arg form used by
createDefaultPinResolver / createSpiceResolvedPinResolver). Picks the
right componentId in either case.
Verified via the rgb-led example: ledRed/ledGreen/ledBlue now cycle
0→255→0 in sync with the SPICE node voltages on pins 9/10/11.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:07:42 +07:00
|
|
|
|
// of null. Delegates to the module-level `traceDetailed`.
|
|
|
|
|
|
//
|
|
|
|
|
|
// Two call shapes are supported because this same function is passed
|
|
|
|
|
|
// BOTH to PartSimulationRegistry handlers (which call it as
|
|
|
|
|
|
// `getArduinoPin(componentPinName)`) AND to `createDefaultPinResolver`
|
|
|
|
|
|
// as a `PinTracer` (which calls it as `tracePin(componentId,
|
|
|
|
|
|
// componentPinName)`). When the second arg is present we treat the
|
|
|
|
|
|
// first as a componentId override; otherwise we use the closure-
|
|
|
|
|
|
// captured component id. The previous single-arg signature silently
|
|
|
|
|
|
// matched the PinTracer 2-arg call as `(componentId, undefined)` —
|
|
|
|
|
|
// traceDetailed then looked up a pin literally named "rgb-led-1" on
|
|
|
|
|
|
// component "rgb-led-1", got null, and the PinResolver reported
|
|
|
|
|
|
// FLOATING forever (the canonical "wokwi-rgb-led never lights up
|
|
|
|
|
|
// even though SPICE is driving R/G/B" symptom).
|
|
|
|
|
|
const getArduinoPin = (
|
|
|
|
|
|
componentIdOrPin: string,
|
|
|
|
|
|
maybePinName?: string,
|
|
|
|
|
|
): number | null => {
|
2026-03-21 03:11:12 +07:00
|
|
|
|
const state = useSimulatorStore.getState();
|
fix(DynamicComponent): pass componentId through to PinTracer
`PinTracer` signature is `(componentId, componentPinName) => number | null`
but the local `getArduinoPin` lambda only accepted one arg and used the
closure-captured `id`. When `createDefaultPinResolver` passed both args
(per the typed signature), JS bound the FIRST arg (the componentId) into
the lambda's single `componentPinName` parameter. `traceDetailed` then
looked up a pin literally named "rgb-led-1" on component "rgb-led-1",
returned null, and the resolver locked itself into 'FLOATING' state —
its onChange path never subscribed and the wokwi-rgb-led element's
ledRed/ledGreen/ledBlue stayed at 0 forever even as the SPICE side
correctly cycled through R, G, B, Y, C, M, W via analogWrite().
Same bug latent for any multi-pin component that goes through the
PinResolver path (multi-pin LEDs, RGB strips, 7-seg drivers, anything
that calls `getPinResolver(<pinName>)` for several pin names).
Fix: lambda now accepts both shapes — `getArduinoPin(pinName)` (legacy
single-arg used by every PartSimulationRegistry handler) AND
`getArduinoPin(componentId, pinName)` (PinTracer 2-arg form used by
createDefaultPinResolver / createSpiceResolvedPinResolver). Picks the
right componentId in either case.
Verified via the rgb-led example: ledRed/ledGreen/ledBlue now cycle
0→255→0 in sync with the SPICE node voltages on pins 9/10/11.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-19 08:07:42 +07:00
|
|
|
|
const componentId = maybePinName !== undefined ? componentIdOrPin : id;
|
|
|
|
|
|
const componentPinName =
|
|
|
|
|
|
maybePinName !== undefined ? maybePinName : componentIdOrPin;
|
|
|
|
|
|
return traceDetailed(state, componentId, componentPinName, 0).arduinoPin;
|
2026-03-05 04:27:14 +07:00
|
|
|
|
};
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
2026-05-15 20:50:09 +07:00
|
|
|
|
// PinResolver factory — Phase 0 of the mixed-mode simulator project
|
|
|
|
|
|
// (see project/sim-mixedmode/ in the velxio-prod repo). For now it
|
|
|
|
|
|
// wraps getArduinoPin + pinManager.onPinChange — zero behavioral
|
|
|
|
|
|
// change vs the legacy path. Phase 1+ will swap in a SPICE-resolved
|
|
|
|
|
|
// implementation that watches node voltages and threshold-converts
|
|
|
|
|
|
// to logic states.
|
|
|
|
|
|
const simState = useSimulatorStore.getState();
|
|
|
|
|
|
const ownerBoard =
|
|
|
|
|
|
simState.boards.find((b) => b.id === simState.activeBoardId) ?? null;
|
|
|
|
|
|
const ownerBoardVcc =
|
|
|
|
|
|
(ownerBoard && BOARD_PIN_GROUPS[ownerBoard.boardKind as keyof typeof BOARD_PIN_GROUPS]?.vcc) ?? 5;
|
|
|
|
|
|
const getPinResolver = (componentPinName: string): PinResolver | null => {
|
|
|
|
|
|
const state = useSimulatorStore.getState();
|
|
|
|
|
|
const pinManager = (stubSimulator as {
|
|
|
|
|
|
pinManager?: {
|
|
|
|
|
|
onPinChange?: (pin: number, cb: (pin: number, state: boolean) => void) => () => void;
|
|
|
|
|
|
getPinState?: (pin: number) => boolean | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
}).pinManager;
|
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine. Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.
What ships:
- simulation/PinResolver.ts
* `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
* `DetailedPinTrace` / `DetailedPinTracer` types — the trace
function now reports whether it crossed an active device, on
top of the Arduino pin number.
* `createSpiceResolvedPinResolver()` — new factory; reads voltages
from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).
- simulation/spice/MixedModeScheduler.ts (new)
* Singleton orchestrator that holds the NgSpiceInteractive engine
and the SpiceVoltageSource subscription registry.
* `start()` / `stop()` / `dispose()` lifecycle.
* `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
* `onMcuPinChange()` placeholder for the alter+tran event loop.
* Skeleton: subscribers register but never receive events yet.
Phase 1b continued will wire NgSpiceInteractive into the loop.
- components/DynamicComponent.tsx
* Trace function extended with `traceDetailed()` that tracks
whether the BFS crossed an active component.
* PinResolver factory branches: active-path → SPICE-resolved (uses
the scheduler), digital-only → existing default impl. Default
threshold = vcc/2 with no hysteresis; Phase 3 will replace with
per-logic-family Vil/Vih.
Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.
Tests:
- pin-resolver-phase1b.test.ts (new) — 8/8 passing.
Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
metadata id; SPICE-resolved resolver state reporting, threshold
conversion, hysteresis dead-band, unsubscribe.
- pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
- tsc --noEmit on the new files: clean.
No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
2026-05-15 21:38:30 +07:00
|
|
|
|
|
|
|
|
|
|
// Phase 1b: detect whether the path between this component pin and
|
|
|
|
|
|
// an Arduino pin passes through any active device (BJT, MOSFET,
|
|
|
|
|
|
// op-amp, diode, regulator). If yes → use the SPICE-resolved
|
|
|
|
|
|
// resolver flavor so the digital state is derived from real node
|
|
|
|
|
|
// voltages (handles transistor inversion, op-amp gain, diode
|
|
|
|
|
|
// forward-drop, etc.). If no → use the legacy digital fast-path
|
|
|
|
|
|
// (zero SPICE cost, identical to Phase 0 behavior).
|
2026-05-16 04:55:07 +07:00
|
|
|
|
const detailed = traceDetailed(state, id, componentPinName, 0);
|
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine. Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.
What ships:
- simulation/PinResolver.ts
* `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
* `DetailedPinTrace` / `DetailedPinTracer` types — the trace
function now reports whether it crossed an active device, on
top of the Arduino pin number.
* `createSpiceResolvedPinResolver()` — new factory; reads voltages
from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).
- simulation/spice/MixedModeScheduler.ts (new)
* Singleton orchestrator that holds the NgSpiceInteractive engine
and the SpiceVoltageSource subscription registry.
* `start()` / `stop()` / `dispose()` lifecycle.
* `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
* `onMcuPinChange()` placeholder for the alter+tran event loop.
* Skeleton: subscribers register but never receive events yet.
Phase 1b continued will wire NgSpiceInteractive into the loop.
- components/DynamicComponent.tsx
* Trace function extended with `traceDetailed()` that tracks
whether the BFS crossed an active component.
* PinResolver factory branches: active-path → SPICE-resolved (uses
the scheduler), digital-only → existing default impl. Default
threshold = vcc/2 with no hysteresis; Phase 3 will replace with
per-logic-family Vil/Vih.
Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.
Tests:
- pin-resolver-phase1b.test.ts (new) — 8/8 passing.
Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
metadata id; SPICE-resolved resolver state reporting, threshold
conversion, hysteresis dead-band, unsubscribe.
- pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
- tsc --noEmit on the new files: clean.
No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
2026-05-15 21:38:30 +07:00
|
|
|
|
if (detailed.crossedActiveDevice) {
|
|
|
|
|
|
const scheduler = getMixedModeScheduler();
|
feat(sim): Phase 3 — logic families (TTL/CMOS-5V/LVCMOS33/AVR_HC/Schmitt)
Replaces the Phase 1b vcc/2-flat threshold with per-logic-family
Vil/Vih thresholds + Schmitt-trigger hysteresis where applicable.
SPICE-resolved digital reads now match what real ICs actually do —
TTL noise margins, CMOS rail-to-rail, 74HC14 Schmitt hysteresis,
LVCMOS33 vs CMOS-5V interop.
New module: simulation/LogicFamilies.ts
- LogicFamily interface (vcc, vil, vih, vil_schmitt?, vih_schmitt?,
cin_pF, vol_max?, voh_min?, output_impedance_ohm?)
- FAMILIES catalog: TTL, CMOS-5V, CMOS-5V-SCHMITT, CMOS-5V-TTL-INPUTS,
LVCMOS33, AVR_HC, CMOS-3.3V — all sourced from TI / ATmega328P /
JEDEC datasheets.
- BOARD_FAMILY: per-board lookup. Uno/Mega/Nano/ATtiny → AVR_HC,
ESP32 family + Pi Pico → LVCMOS33, fall back to AVR_HC for
unknown boards.
- getBoardLogicFamily() and getLogicFamilyById() helpers.
PinResolver:
- SpiceResolvedConfig docstring rewritten with Phase 3 wording.
- New `configFromLogicFamily()` builder — picks Schmitt thresholds
when the family declares them, falls back to vih/vil otherwise.
DynamicComponent:
- When the trace crosses an active device, the SPICE-resolved
resolver is now built with the OWNER BOARD's logic family
instead of vcc/2. Hysteresis comes through automatically for
boards whose native family is Schmitt-capable.
- Phase 3 continued: per-component logicFamily override from
components-metadata.json (so e.g. a 74HC14 placed on an Arduino
Uno gets Schmitt thresholds even though the BOARD is AVR_HC).
Tests:
- logic-families.test.ts (new) — 19/19 passing.
Covers catalog sanity (vil < vih, vol_max ≤ vil, voh_min ≥ vih),
per-board lookup, Schmitt vs non-Schmitt config, noise rejection
behavior of 74HC14 Schmitt resolver, last-state-wins behavior
of CMOS-5V dead band.
- Phase 0 + Phase 1b regression: 16/16 still passing.
- tsc --noEmit on new files: clean.
No deploy in this commit — staged for end-of-session rebuild.
2026-05-15 21:42:11 +07:00
|
|
|
|
// Phase 3: threshold model from the OWNER BOARD's logic family
|
|
|
|
|
|
// (e.g. AVR_HC for Uno, LVCMOS33 for ESP32). Includes Schmitt
|
|
|
|
|
|
// hysteresis when the family declares it. Phase 3 continued
|
|
|
|
|
|
// will let individual components override via a `logicFamily`
|
|
|
|
|
|
// field in components-metadata.json so e.g. a 74HC14 input
|
|
|
|
|
|
// gets Schmitt behavior even when driven from an AVR.
|
|
|
|
|
|
const family = ownerBoard
|
|
|
|
|
|
? getBoardLogicFamily(ownerBoard.boardKind)
|
|
|
|
|
|
: { vcc: ownerBoardVcc, vil: ownerBoardVcc / 2, vih: ownerBoardVcc / 2 };
|
|
|
|
|
|
return createSpiceResolvedPinResolver(
|
|
|
|
|
|
id,
|
|
|
|
|
|
componentPinName,
|
|
|
|
|
|
scheduler,
|
|
|
|
|
|
configFromLogicFamily(family),
|
|
|
|
|
|
);
|
feat(sim): Phase 1b skeleton — SPICE-resolved PinResolver + active-path detection
Adds the architecture pieces for mixed-mode coupling without yet
driving the SPICE engine. Components on a path that crosses an active
device (BJT, MOSFET, op-amp, diode, regulator, LED, relay) now route
through a new SPICE-resolved PinResolver variant; everything else
keeps the digital fast-path from Phase 0.
What ships:
- simulation/PinResolver.ts
* `isActiveDevice(metadataId)` predicate + `ACTIVE_DEVICE_PREFIXES`
list (BJTs, MOSFETs, op-amps, diodes, regulators, LED, relay).
* `DetailedPinTrace` / `DetailedPinTracer` types — the trace
function now reports whether it crossed an active device, on
top of the Arduino pin number.
* `createSpiceResolvedPinResolver()` — new factory; reads voltages
from a `SpiceVoltageSource` and threshold-converts to HIGH/LOW
with hysteresis (thresholdHigh != thresholdLow → Schmitt-like).
- simulation/spice/MixedModeScheduler.ts (new)
* Singleton orchestrator that holds the NgSpiceInteractive engine
and the SpiceVoltageSource subscription registry.
* `start()` / `stop()` / `dispose()` lifecycle.
* `subscribe()` + `getCurrentVoltage()` implement SpiceVoltageSource.
* `onMcuPinChange()` placeholder for the alter+tran event loop.
* Skeleton: subscribers register but never receive events yet.
Phase 1b continued will wire NgSpiceInteractive into the loop.
- components/DynamicComponent.tsx
* Trace function extended with `traceDetailed()` that tracks
whether the BFS crossed an active component.
* PinResolver factory branches: active-path → SPICE-resolved (uses
the scheduler), digital-only → existing default impl. Default
threshold = vcc/2 with no hysteresis; Phase 3 will replace with
per-logic-family Vil/Vih.
Phase 0 LED behavior intact (digital path). Phase 1b SPICE-resolved
path falls back to FLOATING until Phase 1b continued wires the engine.
Tests:
- pin-resolver-phase1b.test.ts (new) — 8/8 passing.
Covers isActiveDevice for every BJT/MOSFET/op-amp/diode/regulator
metadata id; SPICE-resolved resolver state reporting, threshold
conversion, hysteresis dead-band, unsubscribe.
- pin-resolver.test.ts (Phase 0) — 8/8 still passing (no regression).
- tsc --noEmit on the new files: clean.
No deploy in this commit — staged for end-of-session rebuild + push
per user preference.
2026-05-15 21:38:30 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 20:50:09 +07:00
|
|
|
|
return createDefaultPinResolver(
|
|
|
|
|
|
id,
|
|
|
|
|
|
componentPinName,
|
|
|
|
|
|
{
|
|
|
|
|
|
components: state.components,
|
|
|
|
|
|
boards: state.boards,
|
|
|
|
|
|
wires: state.wires,
|
|
|
|
|
|
ownerBoard,
|
|
|
|
|
|
ownerBoardVcc,
|
|
|
|
|
|
subscribeArduinoPin: (pin, cb) => {
|
|
|
|
|
|
if (!pinManager?.onPinChange) return () => {};
|
|
|
|
|
|
return pinManager.onPinChange(pin, cb);
|
|
|
|
|
|
},
|
|
|
|
|
|
readArduinoPin: (pin) => {
|
|
|
|
|
|
if (!pinManager?.getPinState) return null;
|
|
|
|
|
|
try {
|
|
|
|
|
|
return pinManager.getPinState(pin);
|
|
|
|
|
|
} catch {
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
getArduinoPin,
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
feat(microsd): SD-over-SPI card storage for AVR, RP2040 and ESP32
Add a working microSD card part backed by a FAT16 image, following the
Wokwi storage model: the project's own workspace files are auto-copied
onto the card (free), and an optional "SD Card" panel uploads extra
files (gated as a paid feature by the velxio.dev overlay; OSS default
allows it).
Frontend (in-browser AVR / RP2040):
- ProtocolParts.ts: rewrite the microsd-card part from a handshake stub
into a real SD-over-SPI device (reply-first Ncr timing, SDSC byte
addressing, single/multi-block read+write, CSD/CID, full CMD set).
- utils/fatImage.ts: dependency-free FAT16 super-floppy builder (8.3 + LFN).
- utils/sdCardFiles.ts: assemble the card image from workspace files plus
uploaded files; base64 helpers.
- components/simulator/SdCardPanel.tsx + ComponentPropertyDialog: upload UI.
- DynamicComponent + useSimulatorStore: build and inject the image on run.
- lib/proSdCardGate.ts: overlay-installable gate for the upload action.
- data/examples-storage-microsd.ts: Arduino Uno + ESP32 gallery examples.
Backend (ESP32 via QEMU):
- services/esp32_sd_slave.py: synchronous SD-over-SPI slave (Python port of
the browser part) with a sparse backing store, idle-state R1 tracking and
real CRC16 on data blocks when the host enables CRC (CMD59) -- both
required by ESP-IDF's sdspi driver.
- esp32_worker.py: route SPI bytes to the slave (returns MISO synchronously)
and feed write-only bulk transfers.
- esp32_lib_manager.py + routes/simulation.py: forward the FAT image
(sd_card.image_b64) from the start config into the worker.
Tested:
- frontend: protocol-parts, fat-image, sd-card-gate and microsd-real-firmware
(real Arduino SD.h on avr8js) -- 86 passing.
- backend: test_esp32_sd_slave (10) covering the ESP-IDF init sequence and
CRC16; validated end to end by running a real SD.h sketch in libqemu-xtensa
(mount, directory listing, read and write-readback).
2026-06-11 08:59:53 +07:00
|
|
|
|
// microSD auto-copy (free, Wokwi model): bake the project's workspace
|
|
|
|
|
|
// files into a FAT16 image the card serves over SD-over-SPI. Paid uploads
|
|
|
|
|
|
// (the "SD Card" panel) will merge into this list in a later phase.
|
|
|
|
|
|
if (metadata.id === 'microsd-card') {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const uploaded = decodeSdFiles(properties.sdFiles); // paid uploads (if any)
|
|
|
|
|
|
(el as unknown as { sdImageData?: Uint8Array }).sdImageData =
|
|
|
|
|
|
buildProjectSdImage(useEditorStore.getState().files, uploaded);
|
|
|
|
|
|
} catch (e) {
|
|
|
|
|
|
console.warn('[microsd] SD image build failed:', e);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 20:50:09 +07:00
|
|
|
|
cleanupSimulationEvents = logic.attachEvents(
|
|
|
|
|
|
el,
|
|
|
|
|
|
stubSimulator,
|
|
|
|
|
|
getArduinoPin,
|
|
|
|
|
|
id,
|
|
|
|
|
|
getPinResolver,
|
|
|
|
|
|
);
|
2026-03-04 23:36:33 +07:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (cleanupSimulationEvents) cleanupSimulationEvents();
|
|
|
|
|
|
|
|
|
|
|
|
el.removeEventListener('button-press', onButtonPress);
|
|
|
|
|
|
el.removeEventListener('button-release', onButtonRelease);
|
|
|
|
|
|
};
|
2026-04-07 21:19:48 +07:00
|
|
|
|
}, [id, handleComponentEvent, metadata.id, simulator, hexEpoch, wireFingerprint]);
|
2026-03-04 23:36:33 +07:00
|
|
|
|
|
2026-05-13 21:51:52 +07:00
|
|
|
|
// The wrapper uses `onMouseDownCapture` (not `onMouseDown`) so it sees
|
|
|
|
|
|
// the mousedown BEFORE the inner wokwi-element. Interactive wokwi parts
|
|
|
|
|
|
// (pushbutton, slide-switch, potentiometer …) call stopPropagation in
|
|
|
|
|
|
// their own bubble-phase handlers, which used to prevent any drag from
|
|
|
|
|
|
// starting once the simulator was running. Capture phase fires first
|
|
|
|
|
|
// and lets the canvas's drag-threshold logic distinguish click vs drag
|
|
|
|
|
|
// at mouseup time — so the user can rearrange interactive components
|
|
|
|
|
|
// while simulation is live.
|
2026-03-04 05:30:25 +07:00
|
|
|
|
return (
|
|
|
|
|
|
<div
|
2026-06-18 09:35:56 +07:00
|
|
|
|
className={`dynamic-component-wrapper${isBurnt ? ' velxio-burnt' : ''}`}
|
2026-03-04 05:30:25 +07:00
|
|
|
|
style={{
|
|
|
|
|
|
position: 'absolute',
|
|
|
|
|
|
left: `${x}px`,
|
|
|
|
|
|
top: `${y}px`,
|
2026-05-13 09:34:58 +07:00
|
|
|
|
cursor: interactionRunning && isInteractive ? 'pointer' : 'move',
|
2026-03-04 05:30:25 +07:00
|
|
|
|
border: isSelected ? '2px dashed #007acc' : '2px solid transparent',
|
|
|
|
|
|
borderRadius: '4px',
|
|
|
|
|
|
padding: '4px',
|
|
|
|
|
|
userSelect: 'none',
|
2026-03-05 09:40:17 +07:00
|
|
|
|
zIndex: isSelected ? 5 : 1,
|
2026-03-04 05:30:25 +07:00
|
|
|
|
pointerEvents: 'auto',
|
2026-03-04 06:42:17 +07:00
|
|
|
|
transform: properties.rotation ? `rotate(${properties.rotation}deg)` : undefined,
|
|
|
|
|
|
transformOrigin: 'center center',
|
2026-03-04 05:30:25 +07:00
|
|
|
|
}}
|
2026-05-13 21:51:52 +07:00
|
|
|
|
onMouseDownCapture={handleMouseDown}
|
2026-03-04 05:30:25 +07:00
|
|
|
|
onDoubleClick={handleDoubleClick}
|
|
|
|
|
|
onMouseEnter={onMouseEnter}
|
|
|
|
|
|
onMouseLeave={onMouseLeave}
|
|
|
|
|
|
data-component-id={id}
|
|
|
|
|
|
data-component-type={metadata.id}
|
|
|
|
|
|
>
|
|
|
|
|
|
{/* Container for web component */}
|
|
|
|
|
|
<div ref={containerRef} className="web-component-container" />
|
|
|
|
|
|
|
2026-06-18 09:35:56 +07:00
|
|
|
|
{/* Runtime-burnout smoke badge (P4) */}
|
|
|
|
|
|
{isBurnt && (
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="velxio-burnt-smoke"
|
|
|
|
|
|
aria-hidden="true"
|
|
|
|
|
|
style={{ position: 'absolute', top: '-7px', right: '-7px', pointerEvents: 'none', zIndex: 6 }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none">
|
|
|
|
|
|
<circle cx="8" cy="14" r="5" fill="#6b7280" opacity="0.85" />
|
|
|
|
|
|
<circle cx="14" cy="11" r="6" fill="#9ca3af" opacity="0.85" />
|
|
|
|
|
|
<circle cx="17" cy="16" r="4" fill="#4b5563" opacity="0.85" />
|
|
|
|
|
|
<circle cx="11" cy="8" r="3.5" fill="#9ca3af" opacity="0.7" />
|
|
|
|
|
|
</svg>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-04 05:30:25 +07:00
|
|
|
|
{/* Component label */}
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="component-label"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: '11px',
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
marginTop: '4px',
|
|
|
|
|
|
color: '#666',
|
|
|
|
|
|
pointerEvents: 'none',
|
2026-04-07 09:44:24 +07:00
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
gap: '4px',
|
2026-03-04 05:30:25 +07:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-04-22 02:45:45 +07:00
|
|
|
|
{properties.pin !== undefined ? `Pin ${properties.pin}` : metadata.name}
|
2026-07-18 07:28:02 +07:00
|
|
|
|
{isKeyBindable(metadata.id) && typeof properties.key === 'string' && properties.key && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: '9px',
|
|
|
|
|
|
padding: '1px 5px',
|
|
|
|
|
|
borderRadius: '3px',
|
|
|
|
|
|
backgroundColor: '#2d2d2d',
|
|
|
|
|
|
color: '#ddd',
|
|
|
|
|
|
border: '1px solid #555',
|
|
|
|
|
|
borderBottomWidth: '2px',
|
|
|
|
|
|
fontFamily: "'SFMono-Regular', Consolas, 'Liberation Mono', monospace",
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
lineHeight: '1.3',
|
|
|
|
|
|
whiteSpace: 'nowrap',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{formatKeyLabel(properties.key)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-04-07 09:44:24 +07:00
|
|
|
|
{properties.protocol && (
|
|
|
|
|
|
<span
|
|
|
|
|
|
style={{
|
|
|
|
|
|
fontSize: '9px',
|
|
|
|
|
|
padding: '1px 4px',
|
|
|
|
|
|
borderRadius: '3px',
|
|
|
|
|
|
backgroundColor: properties.protocol === 'spi' ? '#e67e22' : '#3498db',
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
fontWeight: 600,
|
|
|
|
|
|
textTransform: 'uppercase',
|
|
|
|
|
|
lineHeight: '1.2',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{String(properties.protocol)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
)}
|
2026-03-04 05:30:25 +07:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Helper function to create a component instance from metadata
|
|
|
|
|
|
*/
|
|
|
|
|
|
export function createComponentFromMetadata(
|
|
|
|
|
|
metadata: ComponentMetadata,
|
|
|
|
|
|
x: number,
|
2026-04-22 02:45:45 +07:00
|
|
|
|
y: number,
|
2026-03-04 05:30:25 +07:00
|
|
|
|
): {
|
|
|
|
|
|
id: string;
|
|
|
|
|
|
metadataId: string;
|
|
|
|
|
|
x: number;
|
|
|
|
|
|
y: number;
|
|
|
|
|
|
properties: Record<string, any>;
|
|
|
|
|
|
} {
|
2026-05-18 20:01:38 +07:00
|
|
|
|
// Underscore separators (not '-') so the resulting id is safe to embed
|
|
|
|
|
|
// in SPICE component / source names. ngspice's WASM build truncates
|
|
|
|
|
|
// vector keys at '-', which broke branch-current lookups for any LED /
|
|
|
|
|
|
// ammeter wired up by the user (visible symptom: correct node voltage,
|
|
|
|
|
|
// dark LED). Also strip '-' from metadata.id (e.g. 'led-bar-graph') so
|
|
|
|
|
|
// the prefix doesn't reintroduce a hyphen.
|
|
|
|
|
|
const safePrefix = metadata.id.replace(/-/g, '_');
|
2026-07-18 23:02:41 +07:00
|
|
|
|
const properties: Record<string, any> = { ...metadata.defaultValues };
|
|
|
|
|
|
// Resistors default to vertical: they read better, take less horizontal
|
|
|
|
|
|
// space, and drop straight into breadboard columns (their pin span
|
|
|
|
|
|
// bridges the center trench). Covers 'resistor' and every preconfigured
|
|
|
|
|
|
// 'resistor-<value>' variant; anything with an explicit rotation in its
|
|
|
|
|
|
// metadata defaults keeps it.
|
|
|
|
|
|
if (metadata.id.startsWith('resistor') && properties.rotation === undefined) {
|
|
|
|
|
|
properties.rotation = 90;
|
|
|
|
|
|
}
|
2026-03-04 05:30:25 +07:00
|
|
|
|
return {
|
2026-05-18 20:01:38 +07:00
|
|
|
|
id: `${safePrefix}_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`,
|
2026-03-04 05:30:25 +07:00
|
|
|
|
metadataId: metadata.id,
|
|
|
|
|
|
x,
|
|
|
|
|
|
y,
|
2026-07-18 23:02:41 +07:00
|
|
|
|
properties,
|
2026-03-04 05:30:25 +07:00
|
|
|
|
};
|
|
|
|
|
|
}
|