From 10413019691a51d5777c30ec4971587eef60fe5f Mon Sep 17 00:00:00 2001 From: David Montero Date: Sun, 23 Aug 2026 05:25:46 +0200 Subject: [PATCH] refactor(sim): one declaration of the sensors whose model owns the line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit "Which sensors drive their own wire" was written down three times: a set of type names in the ESP32 bridge, a wiring map in the store, and a union type plus per-type branches in the overlay's hub. Nothing tied them together, so a third single-wire sensor meant finding all three, and missing one is silent — the same shape of mistake that cost an HC-SR04 its echo pin in the first place. simulation/sensorModels is now the list: the sensor type, its data pin, its properties and the extra pins its model also drives. The store's SENSOR_COMPONENT_MAP IS that declaration, the bridge re-exports the ownership predicate instead of restating the names, and the overlay's hub derives its union from it and gates sensorFromRecord on the shared check. What stays per-device is the MODEL — the waveform each sensor answers with — which is real behaviour and belongs next to the code that produces it. Ownership now walks the extra-pin fields a model DECLARES rather than any field that happens to end in _pin, so a stray property cannot take a pad, and a model that grows a second line is covered by its declaration alone. --- .../src/__tests__/part-pin-ownership.test.ts | 29 +++++++ frontend/src/simulation/Esp32Bridge.ts | 36 +++----- frontend/src/simulation/sensorModels.ts | 86 +++++++++++++++++++ frontend/src/store/useSimulatorStore.ts | 26 ++---- 4 files changed, 133 insertions(+), 44 deletions(-) create mode 100644 frontend/src/simulation/sensorModels.ts diff --git a/frontend/src/__tests__/part-pin-ownership.test.ts b/frontend/src/__tests__/part-pin-ownership.test.ts index d1ca282c..7db93fd9 100644 --- a/frontend/src/__tests__/part-pin-ownership.test.ts +++ b/frontend/src/__tests__/part-pin-ownership.test.ts @@ -136,3 +136,32 @@ describe('sensor records on the ESP32 bridge', () => { expect(sensorRecordOwnsPin({ sensor_type: 'hc-sr04', pin: 21, echo_pin: 41 }, 41)).toBe(true); }); }); + +describe('one declaration of the sensors whose model owns the line', () => { + it('the bridge, the store map and the type list all come from it', async () => { + const models = await import('../simulation/sensorModels'); + const bridge = await import('../simulation/Esp32Bridge'); + // The bridge re-exports rather than restating: same object identity. + expect(bridge.SINGLE_WIRE_SENSOR_TYPES).toBe(models.SINGLE_WIRE_SENSOR_TYPES); + expect(bridge.sensorRecordOwnsPin).toBe(models.sensorRecordOwnsPin); + // And the type set is derived from the wiring specs, not typed twice. + expect([...models.SINGLE_WIRE_SENSOR_TYPES].sort()).toEqual( + Object.values(models.SINGLE_WIRE_SENSOR_MODELS) + .map((m) => m.sensorType) + .sort(), + ); + }); + + it('a new extra pin is owned by declaring it, with no other edit', async () => { + const { sensorRecordOwnsPin, SINGLE_WIRE_SENSOR_MODELS } = await import( + '../simulation/sensorModels' + ); + // Ownership walks the fields the model DECLARES, so echo_pin is covered + // because hc-sr04 declares it — not because anything greps for "echo". + expect(SINGLE_WIRE_SENSOR_MODELS['hc-sr04'].extraPins).toEqual({ echo_pin: 'ECHO' }); + expect(sensorRecordOwnsPin({ sensor_type: 'hc-sr04', pin: 21, echo_pin: 41 }, 41)).toBe(true); + // A field the model does NOT declare is not owned, even on a single-wire + // record — otherwise any stray *_pin property would silently take a pad. + expect(sensorRecordOwnsPin({ sensor_type: 'dht22', pin: 4, other_pin: 9 }, 9)).toBe(false); + }); +}); diff --git a/frontend/src/simulation/Esp32Bridge.ts b/frontend/src/simulation/Esp32Bridge.ts index 6da600d2..4267999f 100644 --- a/frontend/src/simulation/Esp32Bridge.ts +++ b/frontend/src/simulation/Esp32Bridge.ts @@ -37,6 +37,7 @@ import type { BoardKind } from '../types/board'; import { MicroPythonSession, type MpyProgram } from './micropythonSession'; import { getProBoard } from '../lib/proBoardRegistry'; +import { sensorRecordOwnsPin as recordOwnsPin } from './sensorModels'; import { generateUUID } from '../utils/uuid'; /** @@ -63,29 +64,6 @@ export function toQemuBoardType(kind: BoardKind): 'esp32' | 'esp32-s3' | 'esp32- return 'esp32'; // esp32, esp32-devkit-c-v4, esp32-cam, wemos-lolin32-lite } -/** Sensor kinds whose model drives the pad itself for the whole exchange — a - * DHT22's DATA line, an HC-SR04's TRIG/ECHO pair. Anything else registered - * through the sensor channel (ePaper, keypad, I2C devices) leaves its GPIOs to - * the host. Exported so an overlay engine answers the same question with the - * same list instead of a copy that drifts. */ -export const SINGLE_WIRE_SENSOR_TYPES: ReadonlySet = new Set(['dht22', 'hc-sr04']); - -/** True for a sensor record (`{ sensor_type, pin, ... }`) of a single-wire kind. */ -export function isSingleWireSensorRecord(rec: Record): boolean { - return SINGLE_WIRE_SENSOR_TYPES.has(String(rec['sensor_type'] ?? '')); -} - -/** Pads a single-wire sensor record owns: its data pin and any extra pin it - * names (`echo_pin` for an HC-SR04). */ -export function sensorRecordOwnsPin(rec: Record, gpioPin: number): boolean { - if (!isSingleWireSensorRecord(rec)) return false; - if (rec['pin'] === gpioPin) return true; - for (const [k, v] of Object.entries(rec)) { - if ((k.endsWith('_pin') || k.endsWith('Pin')) && v === gpioPin) return true; - } - return false; -} - /** * Upsert sensor records by `pin` — the one merge every bridge uses. * @@ -113,6 +91,16 @@ export function upsertSensorRecords( return merged; } +// The sensors whose model owns its line are declared once, in sensorModels. +// Re-exported here because this bridge is the import site the overlay engines +// and the tests already use; the list itself lives with the wiring spec so it +// cannot drift from what the store pre-registers. +export { + SINGLE_WIRE_SENSOR_TYPES, + isSingleWireSensorRecord, + sensorRecordOwnsPin, +} from './sensorModels'; + const API_BASE = (): string => { // The desktop shell injects the sidecar URL at runtime (random port) via // window.__VELXIO_API_BASE__; honor it first so the QEMU-board WebSocket @@ -717,7 +705,7 @@ export class Esp32Bridge { // the host to drive their real GPIOs. Blocking those was the difference // between this guard and the in-browser engines' narrow // SingleWireSensorHub.ownsPin, which is the behaviour to match. - return this._pendingSensors.some((s) => sensorRecordOwnsPin(s, gpioPin)); + return this._pendingSensors.some((s) => recordOwnsPin(s, gpioPin)); } /** Drive a GPIO pin from an external source (e.g. connected Arduino) */ diff --git a/frontend/src/simulation/sensorModels.ts b/frontend/src/simulation/sensorModels.ts new file mode 100644 index 00000000..03fb6cd0 --- /dev/null +++ b/frontend/src/simulation/sensorModels.ts @@ -0,0 +1,86 @@ +/** + * sensorModels — the one declaration of the sensors whose MODEL drives the line. + * + * A handful of parts are not in the SPICE netlist and are not driven by the + * host either: the sensor's own model owns the wire for the whole exchange. An + * HC-SR04 answers a trigger with a timed ECHO pulse; a DHT22 bit-bangs a + * 40-bit frame on its DATA line. Whoever emulates that model — the backend + * QEMU worker, or an in-browser engine's sensor hub — is the only thing + * allowed to move those pads, and every other layer has to stand down. + * + * That fact used to be written down in several places at once: a set of type + * names in the ESP32 bridge, a union type and a pair of `if (type === ...)` + * branches in the overlay's hub, and a wiring map in the store. Nothing tied + * them together, so adding a third single-wire sensor meant finding all of + * them, and missing one was silent — exactly the failure that cost an HC-SR04 + * its echo pin. This module is the single list; the code that MODELS each + * sensor stays where it is (that is real per-device behaviour, not a list). + * + * NOT in here on purpose: sensors that register through the same channel but + * only LISTEN — an ePaper panel's DC / CS / RST, a membrane keypad's rows, any + * I2C device on a virtual 200+addr pin. The host drives those, so they must + * stay drivable. + * + * Adding a single-wire sensor: one entry here, plus the model itself (a part + * under simulation/parts and, for the in-browser engines, a case in the + * overlay's singleWireSensors). Nothing else needs to learn its name. + */ + +export interface SensorModelSpec { + /** `sensor_type` in the wire protocol — what the backend / engine dispatches on. */ + sensorType: string; + /** Component pin carrying the data (or trigger) line, as named on the part. */ + dataPinName: string; + /** Component properties forwarded to the model (temperature, distance, …). */ + propertyKeys: string[]; + /** + * Extra pins the model also drives, as `record field -> component pin name`. + * The field name is what reaches the backend (`echo_pin`), and the same + * fields are what `ownsPin` walks — so a model that gains a second line only + * has to declare it here. + */ + extraPins?: Record; +} + +/** Keyed by the component's metadataId on the canvas. */ +export const SINGLE_WIRE_SENSOR_MODELS: Readonly> = { + dht22: { + sensorType: 'dht22', + dataPinName: 'SDA', + propertyKeys: ['temperature', 'humidity'], + }, + 'hc-sr04': { + sensorType: 'hc-sr04', + dataPinName: 'TRIG', + propertyKeys: ['distance'], + extraPins: { echo_pin: 'ECHO' }, + }, +}; + +/** `sensor_type` values whose model drives its own line. Derived, never typed twice. */ +export const SINGLE_WIRE_SENSOR_TYPES: ReadonlySet = new Set( + Object.values(SINGLE_WIRE_SENSOR_MODELS).map((m) => m.sensorType), +); + +/** True for a sensor record (`{ sensor_type, pin, ... }`) of a single-wire kind. */ +export function isSingleWireSensorRecord(rec: Record): boolean { + return SINGLE_WIRE_SENSOR_TYPES.has(String(rec['sensor_type'] ?? '')); +} + +/** + * Pads a single-wire sensor record owns: its data pin plus every extra pin its + * model declares (`echo_pin` for an HC-SR04). Read off the record's own fields + * rather than a hard-coded field list, so a new extra pin is covered by the + * declaration above alone. + */ +export function sensorRecordOwnsPin(rec: Record, gpioPin: number): boolean { + if (!isSingleWireSensorRecord(rec)) return false; + if (rec['pin'] === gpioPin) return true; + const spec = Object.values(SINGLE_WIRE_SENSOR_MODELS).find( + (m) => m.sensorType === rec['sensor_type'], + ); + for (const field of Object.keys(spec?.extraPins ?? {})) { + if (rec[field] === gpioPin) return true; + } + return false; +} diff --git a/frontend/src/store/useSimulatorStore.ts b/frontend/src/store/useSimulatorStore.ts index bb31f9bc..757f2795 100644 --- a/frontend/src/store/useSimulatorStore.ts +++ b/frontend/src/store/useSimulatorStore.ts @@ -65,30 +65,16 @@ import { setInterconnectRuntime, } from '../simulation/Interconnect'; import { SENSOR_CONTROLS, getSensorControl } from '../simulation/sensorControlConfig'; +import { SINGLE_WIRE_SENSOR_MODELS } from '../simulation/sensorModels'; import { traceBoardGpio } from '../simulation/PinTrace'; import { dispatchSensorUpdate } from '../simulation/SensorUpdateRegistry'; // ── Sensor pre-registration ────────────────────────────────────────────────── -// Maps component metadataId → { sensorType, dataPinName, propertyKeys } -// Used to pre-register sensors in the start_esp32 payload so the QEMU worker -// has them ready before the firmware starts executing (prevents race conditions). -const SENSOR_COMPONENT_MAP: Record< - string, - { - sensorType: string; - dataPinName: string; - propertyKeys: string[]; - extraPins?: Record; // extra pin mappings: prop name → component pin name - } -> = { - dht22: { sensorType: 'dht22', dataPinName: 'SDA', propertyKeys: ['temperature', 'humidity'] }, - 'hc-sr04': { - sensorType: 'hc-sr04', - dataPinName: 'TRIG', - propertyKeys: ['distance'], - extraPins: { echo_pin: 'ECHO' }, - }, -}; +// Sensors whose model drives its own line (DHT22, HC-SR04) — declared once in +// simulation/sensorModels, which the ESP32 bridge also reads to know which pads +// the host must not touch. Used here to pre-register them in the start_esp32 +// payload so the worker has them before the firmware runs. +const SENSOR_COMPONENT_MAP = SINGLE_WIRE_SENSOR_MODELS; // ── I2C sensor pre-registration ─────────────────────────────────────────────── // I2C sensors use virtual pins (200 + i2c_addr) instead of real GPIO pins.