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:
parent
0ef656849d
commit
1041301969
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in New Issue