refactor(sim): one declaration of the sensors whose model owns the line

"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.
This commit is contained in:
David Montero 2026-08-23 05:25:46 +02:00
parent 0ef656849d
commit 1041301969
4 changed files with 133 additions and 44 deletions

View File

@ -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);
});
});

View File

@ -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<string> = new Set(['dht22', 'hc-sr04']);
/** True for a sensor record (`{ sensor_type, pin, ... }`) of a single-wire kind. */
export function isSingleWireSensorRecord(rec: Record<string, unknown>): 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<string, unknown>, 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) */

View File

@ -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<string, string>;
}
/** Keyed by the component's metadataId on the canvas. */
export const SINGLE_WIRE_SENSOR_MODELS: Readonly<Record<string, SensorModelSpec>> = {
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<string> = 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<string, unknown>): 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<string, unknown>, 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;
}

View File

@ -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<string, string>; // 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.