fix(sim): the other models that own a pad, and a button with no circuit

Auditing the catalog for parts in the same situation as the DHT22 and the
HC-SR04 turned up three things.

Two more models own a real pad and were not declared: a matrix keypad's
COLUMNS (the QEMU worker even keeps a `_keypad_cols_owned` set, idles them
HIGH and drives them on each scan) and an ePaper panel's BUSY line. Neither is
broken with plain wiring today, only because the connector's older
`sourcedNets` gate happens to skip a net nothing else sits on — put a pull-up
on a keypad column and that gate stops holding, which is exactly how a
level-shifted HC-SR04 lost its echo. Their DC / CS / RST stay absent on
purpose: the host drives those.

Ownership is therefore no longer "is this a single-wire sensor?" but "which
fields does this model declare as pads it drives", which also lets a field
hold a LIST of pads (the keypad's columns).

And `pushbutton-6mm` did nothing at all, on every board. Three parts hand
control of their pin to the electrical solve rather than seeding it; two of
them are modelled by SPICE and the 6mm variant was not, so it stood down for a
circuit that never modelled it. It is the same switch as the full-size one, so
it now shares that mapper through a MAPPER_ALIASES table. Verified in a real
browser: on production the button visibly depresses while the sketch keeps
reading `released`; with this build it reads PRESSED and returns.

The last part is the guard: a test reads the parts themselves and fails if any
part that defers to SPICE has no model, so the next variant is covered the day
it is written. It catches pushbutton-6mm when the alias is removed.
This commit is contained in:
David Montero 2026-08-23 08:18:17 +02:00
parent 041b08fa21
commit bc0731f74a
5 changed files with 154 additions and 11 deletions

View File

@ -89,6 +89,10 @@ const MINIMAL_FIXTURES: Record<string, Fixture> = {
topology: { '+': 'load', '−': 'gnd' },
},
pushbutton: { pins: ['A', 'B'] },
// Same switch, smaller body: it shares the pushbutton mapper (MAPPER_ALIASES),
// so it takes the same fixture. It reached the catalog without one and, with
// no model, stood down for a circuit that never modelled it — dead button.
'pushbutton-6mm': { pins: ['A', 'B'] },
// SPDT: pin 2 is the common wiper; at value=1 it shorts to pin 3, so pin 3
// must be wired or the mapper emits no card.
'slide-switch': { pins: ['1', '2', '3'], properties: { value: 1 } },

View File

@ -165,3 +165,29 @@ describe('one declaration of the sensors whose model owns the line', () => {
expect(sensorRecordOwnsPin({ sensor_type: 'dht22', pin: 4, other_pin: 9 }, 9)).toBe(false);
});
});
describe('models that own a pad without being single-wire', () => {
it('a matrix keypad owns its columns, not its rows', async () => {
const { sensorRecordOwnsPin } = await import('../simulation/sensorModels');
// The worker drives the columns (it keeps a `_keypad_cols_owned` set) and
// the firmware scans them as inputs; the rows are the firmware's outputs.
const kp = { sensor_type: 'matrix-keypad', pin: 13, rows: [13, 12, 14, 27], cols: [26, 25, 33, 32] };
expect(sensorRecordOwnsPin(kp, 26)).toBe(true);
expect(sensorRecordOwnsPin(kp, 32)).toBe(true);
expect(sensorRecordOwnsPin(kp, 12)).toBe(false); // a row: the guest drives it
});
it('an ePaper panel owns BUSY and nothing else', async () => {
const { sensorRecordOwnsPin } = await import('../simulation/sensorModels');
// DC / CS / RST are host-driven — blocking those would strand the panel.
const ep = { sensor_type: 'epaper-ssd168x', pin: 17, dc_pin: 17, cs_pin: 5, rst_pin: 16, busy_pin: 4 };
expect(sensorRecordOwnsPin(ep, 4)).toBe(true);
for (const p of [17, 5, 16]) expect(sensorRecordOwnsPin(ep, p)).toBe(false);
});
it('a kind that owns nothing answers false for every pad', async () => {
const { sensorRecordOwnsPin } = await import('../simulation/sensorModels');
const i2c = { sensor_type: 'bmp280', pin: 200 + 0x76, addr: 0x76 };
for (const p of [0, 4, 200 + 0x76]) expect(sensorRecordOwnsPin(i2c, p)).toBe(false);
});
});

View File

@ -0,0 +1,67 @@
// @vitest-environment node
/**
* A part that DEFERS to the circuit must have a circuit to defer to.
*
* Three parts hand control of their pin to the electrical solve rather than
* seeding it themselves (they check `spiceDriven(simulator)` and stand down).
* That is the right design — a mis-wired button has to read stuck, not "work
* anyway" — but it only works if SPICE actually models the part. `pushbutton`
* and `slide-switch` were modelled; `pushbutton-6mm` was not, so on every board
* with spiceDrivenInputs (all of them) it stood down and nothing took over:
* the button was dead on the canvas, silently, with no error anywhere.
*
* This test reads the parts themselves rather than a list, so the next variant
* someone adds is covered the day it is written.
*/
import { describe, expect, it } from 'vitest';
import { readFileSync, readdirSync } from 'node:fs';
import { join } from 'node:path';
import { isSpiceMapped } from '../simulation/spice/componentToSpice';
const PARTS_DIR = join(__dirname, '..', 'simulation', 'parts');
/** metadataIds whose part stands down when the board solves its inputs. */
function partsThatDeferToSpice(): string[] {
const found = new Set<string>();
for (const file of readdirSync(PARTS_DIR).filter((f) => f.endsWith('.ts'))) {
const src = readFileSync(join(PARTS_DIR, file), 'utf8');
// Walk registrations in order; a `spiceDriven(` inside a registration body
// belongs to the id that opened it.
let current: string | null = null;
for (const line of src.split('\n')) {
const reg = line.match(/PartSimulationRegistry\.register\(\s*['"]([^'"]+)['"]/);
if (reg) current = reg[1];
if (current && /spiceDriven\s*\(/.test(line)) found.add(current);
}
}
return [...found].sort();
}
describe('parts that defer to the electrical solve', () => {
it('every one of them is modelled by SPICE', () => {
const deferring = partsThatDeferToSpice();
// Sanity: the scan finds something, so a rename cannot make this vacuous.
expect(deferring.length).toBeGreaterThan(0);
const unmodelled = deferring.filter((id) => !isSpiceMapped(id));
expect(
unmodelled,
`these parts stand down for the circuit but SPICE has no model for them, ` +
`so they do nothing at all on a board with spiceDrivenInputs: ${unmodelled.join(', ')}`,
).toEqual([]);
});
it('the 6mm pushbutton is modelled as the same switch as the full-size one', async () => {
const { componentToSpice } = await import('../simulation/spice/componentToSpice');
const lookup = (p: string) => (p === '1.l' ? 'n1' : p === '2.l' ? 'n2' : null);
const big = componentToSpice({ id: 'b', metadataId: 'pushbutton', properties: {} } as never, lookup, {
vcc: 3.3,
} as never);
const small = componentToSpice(
{ id: 'b', metadataId: 'pushbutton-6mm', properties: {} } as never,
lookup,
{ vcc: 3.3 } as never,
);
expect(small).not.toBeNull();
expect(small!.cards).toEqual(big!.cards);
});
});

View File

@ -57,6 +57,38 @@ export const SINGLE_WIRE_SENSOR_MODELS: Readonly<Record<string, SensorModelSpec>
},
};
/**
* Pads a MODEL drives, per `sensor_type`, as record fields (a field may hold a
* single pad or a list of them). Anything listed here is off limits to every
* other layer — the SPICE-threshold connector above all.
*
* The single-wire sensors contribute their data pin plus whatever extra pins
* they declare. The other two entries are models that own a pad WITHOUT being
* single-wire, which is why a plain "is it a single-wire sensor?" test was not
* enough:
*
* - a matrix keypad's COLUMNS are driven by the model (the QEMU worker even
* keeps a `_keypad_cols_owned` set); the firmware scans them as inputs;
* - an ePaper panel drives BUSY to tell the firmware it is refreshing. Its
* DC / CS / RST are the opposite case — the host drives those, so they are
* deliberately absent.
*
* Neither is broken with plain wiring today, because the connector's older
* `sourcedNets` gate happens to skip a net nothing else sits on. Put a pull-up
* on a keypad column and that gate stops holding — which is exactly how a
* level-shifted HC-SR04 lost its echo.
*/
const MODEL_OWNED_PIN_FIELDS: Readonly<Record<string, readonly string[]>> = {
...Object.fromEntries(
Object.values(SINGLE_WIRE_SENSOR_MODELS).map((m) => [
m.sensorType,
['pin', ...Object.keys(m.extraPins ?? {})],
]),
),
'matrix-keypad': ['cols'],
'epaper-ssd168x': ['busy_pin'],
};
/** `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),
@ -68,19 +100,18 @@ export function isSingleWireSensorRecord(rec: Record<string, unknown>): boolean
}
/**
* 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.
* Pads a registered sensor record owns, per the declaration above: the fields
* are read off the record itself, so a model that grows a line is covered by
* its entry alone. A record of a kind that owns nothing (an I2C device on a
* virtual pin, a panel's host-driven DC/CS/RST) answers false for every pad.
*/
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;
const fields = MODEL_OWNED_PIN_FIELDS[String(rec['sensor_type'] ?? '')];
if (!fields) return false;
for (const field of fields) {
const v = rec[field];
if (v === gpioPin) return true;
if (Array.isArray(v) && v.includes(gpioPin)) return true;
}
return false;
}

View File

@ -648,6 +648,11 @@ const MAPPERS: Record<string, Mapper> = {
// just 1.l/2.l) means a user can wire GND/GPIO to any leg and it behaves like
// hardware — and wiring both a GPIO and GND to the SAME terminal is a dead
// short, exactly as on a real button. Back-compat: 2-pin variants expose A/B.
// The 6mm variant is the same switch with a smaller body and the same pad
// names, and it is registered as a part that DEFERS to the circuit. Without
// a model here it deferred to nothing: on every board with spiceDrivenInputs
// (which is all of them) pressing it did nothing at all. Aliased below the
// definition so the two can never drift.
pushbutton: (comp, netLookup) => {
const t1l = netLookup('1.l');
const t1r = netLookup('1.r');
@ -1327,6 +1332,16 @@ export function componentToSpice(
return mapper(comp, netLookup, ctx);
}
// Variants that are electrically the SAME device as an entry above: one model,
// several catalog ids. Keep this next to MAPPERS so adding a body size or a
// silkscreen variant is one line and cannot forget the netlist.
const MAPPER_ALIASES: Readonly<Record<string, string>> = {
'pushbutton-6mm': 'pushbutton',
};
for (const [variant, base] of Object.entries(MAPPER_ALIASES)) {
MAPPERS[variant] = MAPPERS[base];
}
/** True if we have a mapping for this metadataId. */
export function isSpiceMapped(metadataId: string): boolean {
return metadataId in MAPPERS || metadataId in proMappers;