diff --git a/frontend/src/components/DynamicComponent.tsx b/frontend/src/components/DynamicComponent.tsx index 1fd86ff8..a1548b0b 100644 --- a/frontend/src/components/DynamicComponent.tsx +++ b/frontend/src/components/DynamicComponent.tsx @@ -127,6 +127,36 @@ type TraceState = ReturnType; // 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. + +/** + * Resolve a component pad through a SEATED board rather than a wire. + * + * Sockets are a real electrical connection with nothing to draw: the board's + * pads sit on the component's pads. `boardSocket` (read off the element, the + * rule-6a way) says the component is a socket; isBoardSeated says a board is + * actually in it; and the shared pad NAME is the contract that makes the two + * grids one net — which is exactly why a socket's pinInfo uses the board's own + * names. Returns null for anything that is not a seated socket pad. + */ +function traceThroughSocket( + state: TraceState, + componentId: string, + pinName: string, +): { pin: number; boardId: string } | null { + const el = document.getElementById(componentId) as + | (HTMLElement & { boardSocket?: { anchorPin: string; accepts: string[] } }) + | null; + const sock = el?.boardSocket; + if (!sock || !Array.isArray(sock.accepts)) return null; + for (const b of state.boards) { + if (!sock.accepts.some((prefix) => b.boardKind.startsWith(prefix))) continue; + if (!isBoardSeated(b.id, b.boardKind, b.x, b.y, state.components)) continue; + const pin = boardPinToNumber(b.boardKind, pinName); + if (pin !== null) return { pin, boardId: b.id }; + } + return null; +} + export function traceDetailed( state: TraceState, fromId: string, @@ -142,6 +172,18 @@ export function traceDetailed( (w.end.componentId === fromId && w.end.pinName === fromPin), ); + // A board SEATED on a socket component is connected without any wire — that + // is what seating means, and it is how the hardware ships: a XIAO pushed + // into a shield's header, a Pi HAT dropped onto the 40-pin. So when this + // component declares a socket (boardSocket, the same contract the magnet + // reads) and a board is seated on it, a pad resolves to the SAME-NAMED pin + // of that board. Without this hop a seated shield's buttons and LEDs were + // dead until the user drew wires that the real stack does not have. + const socketPin = traceThroughSocket(state, fromId, fromPin); + if (socketPin !== null) { + return { arduinoPin: socketPin.pin, crossedActiveDevice: activeSeen, boardId: socketPin.boardId }; + } + // 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; diff --git a/frontend/src/simulation/parts/SensorParts.ts b/frontend/src/simulation/parts/SensorParts.ts index 6ed72fd9..a1bb7da1 100644 --- a/frontend/src/simulation/parts/SensorParts.ts +++ b/frontend/src/simulation/parts/SensorParts.ts @@ -451,7 +451,13 @@ PartSimulationRegistry.register('stepper-motor', { /** * Decode WS2812B bit-stream from DIN pin changes for NeoPixel devices. */ -function createNeopixelDecoder( +/** + * WS2812/NeoPixel bit-bang decoder, shared. Exported because a board is not + * the only thing that carries an addressable LED: any part with one on board + * (the reSpeaker Lite's RGB, driven by whatever host is wired to it) needs the + * same decode, and re-implementing it per part is how two of them drift. + */ +export function createNeopixelDecoder( simulator: any, pinDIN: number, onPixel: (index: number, r: number, g: number, b: number) => void, diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index b060735d..06017322 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -402,10 +402,16 @@ class Esp32BridgeShim { * setMicrophoneSource (velxio-prod overlay); everywhere else it's a no-op, * which reads as a silent mic. */ - setMicrophoneSource(source: (() => number) | null): void { - ( - this.bridge as { setMicrophoneSource?: (s: (() => number) | null) => void } - ).setMicrophoneSource?.(source); + setMicrophoneSource(source: (() => number) | null): boolean { + const b = this.bridge as { setMicrophoneSource?: (s: (() => number) | null) => void }; + if (typeof b?.setMicrophoneSource === 'function') { + b.setMicrophoneSource(source); + return true; + } + // No bridge, or one without an audio path: the caller learns the truth + // instead of talking into a void — a part can then SAY the host has no + // I2S rather than pretending to stream. + return false; } /**