feat(sim): seated sockets are electrical, and capability is probed

Three generic seams, none naming any board:

The pin tracer gains a socket hop: a component that declares boardSocket
with a board actually seated on it resolves a pad to the SAME-NAMED pin
of that board — seating IS the connection, like a XIAO pushed into a
shield header or a HAT on the 40-pin. Until now a seated shield's
buttons and LEDs were dead unless the user drew wires the real stack
does not have. Wires keep working exactly as before for every other
host (Uno, Mega, Pi, STM32, ...).

setMicrophoneSource now RETURNS whether a bridge actually took the
source, so a part can state that this host has no I2S instead of
pretending to stream into a void — capability reported, never invented.

createNeopixelDecoder is exported: boards are not the only things
carrying an addressable LED, and a part with one on board needs the
same decode rather than a drifting copy.
This commit is contained in:
David Montero Crespo 2026-07-31 22:32:47 +02:00
parent 8ff8fdb292
commit ab7949502d
3 changed files with 59 additions and 5 deletions

View File

@ -127,6 +127,36 @@ type TraceState = ReturnType<typeof useSimulatorStore.getState>;
// 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;

View File

@ -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,

View File

@ -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;
}
/**