diff --git a/arena_studio.html b/arena_studio.html
index 126d66e1..160890d6 100644
--- a/arena_studio.html
+++ b/arena_studio.html
@@ -2548,6 +2548,7 @@
Auto-captured
sha256: —
arena: — · rig: —
firmware: —
+ panels: —
controller: —
run_id: — (assigned at run start)
@@ -3047,6 +3048,10 @@ Import error
Flash
Verify
+ fleet
+ Inventory
+ Rescan
+
batch
verify each
@@ -3159,6 +3164,7 @@
Import error
+
@@ -4009,6 +4015,61 @@ Import error
return null;
};
+ // Panel inventory (fw #59, GET_PANEL_INVENTORY 0xD1): which panels answered and
+ // the firmware fingerprint each one runs, read at every link-up → provenance
+ // box + run_metadata.panels. Gated on the 0xC2 feature bit `panel_inventory`
+ // (older firmware answers 0xD1 with CE_UNKNOWN_CMD and an error glyph). The
+ // boot fingerprint sweep may still be running right after a reset, so a read
+ // that says "in progress" schedules one follow-up read.
+ // `Studio.panels` is never left stale: it is cleared before every read, on a
+ // failed read and on disconnect, and `Studio.panelsStatus` says why it is
+ // null ('unsupported' | 'failed' | 'disconnected') or how to read it
+ // ('ok' | 'pending'). runOnce() re-reads it right before startRunLog() so
+ // run_metadata.panels is a run-start snapshot, not connect-time state.
+ Studio.panels = null; // StudioPanelInventory.summarize() result, or null
+ Studio.panelsStatus = 'disconnected';
+ Studio.scheduleInventoryFollowUp = function (ms) {
+ if (Studio._panelsFollowUp) clearTimeout(Studio._panelsFollowUp);
+ Studio._panelsFollowUp = setTimeout(() => {
+ Studio._panelsFollowUp = null;
+ if (Studio.session && Studio.session.connected && !Studio.session.running) Studio.refreshPanelInventory();
+ }, ms);
+ };
+ Studio.refreshPanelInventory = async function (opts) {
+ const quiet = !!(opts && opts.quiet);
+ const PI = window.StudioPanelInventory;
+ const feats = Studio.features || [];
+ const has = feats.includes('panel_inventory');
+ Studio.panels = null;
+ if (!Studio.session || !Studio.session.connected || !PI || !has) {
+ Studio.panelsStatus = !Studio.session || !Studio.session.connected ? 'disconnected' : 'unsupported';
+ if ($('pvPanels')) $('pvPanels').textContent = has ? '—' : '— (firmware without panel inventory)';
+ return null;
+ }
+ try {
+ const inv = PI.summarize(await PI.readAll((b, o) => Studio.session.send(b, o)));
+ Studio.panels = inv;
+ Studio.panelsStatus = inv.presenceValid ? 'ok' : 'pending';
+ const label = PI.describe(inv);
+ const worry = inv.missing.length > 0 || inv.mismatched.length > 0;
+ if ($('pvPanels')) {
+ $('pvPanels').textContent = label;
+ $('pvPanels').title = 'GET_PANEL_INVENTORY 0xD1 · scan ' + inv.scanId + ' · ' + inv.flagNames.join(' ') +
+ (inv.mismatched.length ? ' · check panels ' + inv.mismatched.join(', ') : '');
+ }
+ if ($('cInvSum')) $('cInvSum').textContent = label;
+ if (!quiet) rawLine('‹ panels: ' + label, worry ? 'warn' : 'info');
+ if (inv.fpInProgress || !inv.presenceValid) Studio.scheduleInventoryFollowUp(4000);
+ return inv;
+ } catch (e) {
+ Studio.panelsStatus = 'failed';
+ if ($('pvPanels')) $('pvPanels').textContent = '— (panel inventory read failed)';
+ if ($('cInvSum')) $('cInvSum').textContent = 'read failed';
+ rawLine('panel inventory: ' + (e && e.message ? e.message : e), 'warn');
+ return null;
+ }
+ };
+
// ---- connection ----------------------------------------------------------
async function toggleConnect() {
if (!Studio.session) {
@@ -4031,15 +4092,18 @@ Import error
// Capability names (e.g. 'io_ext') — gates the rig-I/O
// role apply-path + Console readouts per connection.
Studio.capabilities = info.capabilities || [];
+ Studio.features = info.features || []; // 0xC2 feature bitmap (panel_inventory, qwiic_i2c, …)
// Physical-setup identity (Teensy unique ID → Ethernet MAC);
// null until the firmware appends it to the 0xC2 reply.
Studio.controllerId = info.mac || null;
rawLine('‹ controller info: fw v' + info.version +
' · caps [' + info.capabilities.join(', ') + ']' +
+ (info.features && info.features.length ? ' · features [' + info.features.join(', ') + ']' : '') +
(info.mac ? ' · id ' + info.mac : ''), 'info');
const ctl = $('pvCtl');
if (ctl) ctl.textContent = Studio.controllerId || '— (needs fw with MAC in 0xC2)';
await Studio.refreshFirmwareVersion(); // build identity → run_metadata.firmware
+ await Studio.refreshPanelInventory(); // which panels + their firmware → run_metadata.panels
if (Studio.initTelemetry) await Studio.initTelemetry(); // ring drain (T4) when the fw has it
// Un-grey the fw-gated rig-I/O roles now that caps are known
// (the role menu may have rendered before this reply landed).
@@ -5252,11 +5316,13 @@ Import error
const info = Wire.decodeControllerInfo(await Studio.session.send(Wire.encodeGetControllerInfo(), { timeoutMs: 2000 }));
if (info) {
Studio.capabilities = info.capabilities || [];
+ Studio.features = info.features || [];
Studio.controllerId = info.mac || Studio.controllerId || null;
if (!Studio.firmware) Studio.firmware = 'v' + info.version;
if ($('pvCtl')) $('pvCtl').textContent = Studio.controllerId || '—';
}
await Studio.refreshFirmwareVersion();
+ await Studio.refreshPanelInventory();
if (Studio.initTelemetry) await Studio.initTelemetry();
refreshState();
} catch (_) { /* best-effort: a wedged controller answers nothing */ }
@@ -5809,18 +5875,43 @@ Import error
if (info) {
Studio.firmware = 'v' + info.version;
Studio.capabilities = info.capabilities || [];
+ Studio.features = info.features || [];
Studio.controllerId = info.mac || Studio.controllerId || null;
if ($('pvCtl')) $('pvCtl').textContent = Studio.controllerId || '— (needs fw with MAC in 0xC2)';
Studio.refreshState();
if (Studio.updateRigIoGating) Studio.updateRigIoGating(); // refresh fw-gated role options
clog(' fw v' + info.version + ' · caps [' + info.capabilities.join(', ') + ']' +
+ (info.features && info.features.length ? ' · features [' + info.features.join(', ') + ']' : '') +
(info.mac ? ' · id ' + info.mac : ' · (no MAC in reply — older fw)'), 'info');
const fv = await Studio.refreshFirmwareVersion();
if (fv) clog(' build ' + fv.label, 'info');
else if ((info.capabilities || []).includes('health')) clog(' build: (no 0xCB reply)', 'warn');
else clog(' build: unknown — this firmware predates GET_FIRMWARE_VERSION (0xCB)', 'warn');
+ const inv = await Studio.refreshPanelInventory();
+ if (inv) clog(' panels: ' + window.StudioPanelInventory.describe(inv), inv.mismatched.length || inv.missing.length ? 'warn' : 'info');
}
},
+ // panel fleet inventory (fw #59, feature bit 0)
+ cinv: async () => {
+ if (!connected()) return;
+ if (!(Studio.features || []).includes('panel_inventory')) { clog('panel inventory: this firmware has no GET_PANEL_INVENTORY (0xC2 feature bit 0 clear)', 'warn'); return; }
+ const inv = await Studio.refreshPanelInventory();
+ if (!inv) return;
+ clog(' ' + window.StudioPanelInventory.describe(inv), inv.mismatched.length || inv.missing.length ? 'warn' : 'info');
+ if (inv.mismatched.length) clog(' check panels ' + inv.mismatched.join(', ') + (inv.refPresent ? ' (≠ /firmware/panel.bin)' : ' (fingerprint minority / no ISP reply)'), 'warn');
+ for (const f of inv.firmware) clog(' ' + f.hex + ' ×' + f.panels.length + ': panels ' + f.panels.join(', '), 'info');
+ },
+ cinvscan: () => guardDestructive(async () => {
+ // Stops the display (0xD0 needs ALL_OFF) — confirm-and-STOP like the other destructive actions.
+ if (!connected()) return;
+ if (!(Studio.features || []).includes('panel_inventory')) { clog('panel inventory: this firmware has no PANEL_INVENTORY_SCAN (0xC2 feature bit 0 clear)', 'warn'); return; }
+ await quiesceDisplay();
+ const r = await send(Wire.encodePanelInventoryScan(1), 'PANEL_INVENTORY_SCAN 0xD0 · presence + fingerprints', { timeoutMs: 10000, expectedCmd: Wire.OPCODES.PANEL_INVENTORY_SCAN });
+ const page0 = r && Wire.decodePanelInventory(r);
+ if (!page0) return;
+ if ($('cInvSum')) $('cInvSum').textContent = 'rescanning… (scan ' + page0.scanId + ')';
+ Studio.scheduleInventoryFollowUp(3000); // the sweep runs one panel per loop() pass; gated on connected + not running
+ }),
ip: async () => { const r = await send(Wire.encodeGetIp(), 'GET_ETHERNET_IP 0xC1'); const ip = r && Wire.decodeIp && Wire.decodeIp(r); if (ip) clog(' ip ' + ip, 'info'); },
frames: async () => { const r = await send(Wire.encodeGetFramesSent(), 'GET_FRAMES_SENT 0x33'); const n = r && Wire.decodeFramesSent(r); if (n != null) clog(' frames sent ' + n, 'info'); },
resetframes: () => send(Wire.encodeResetFramesSent(), 'RESET_FRAMES_SENT 0x34'),
@@ -6524,6 +6615,10 @@ Import error
// Forget the controller's capabilities so the NEXT connect re-detects
// io_ext (avoids stale caps gating the rig-I/O roles for a swapped rig).
Studio.capabilities = null;
+ Studio.features = null;
+ Studio.panels = null; // never carry one controller's panel inventory into the next connection
+ Studio.panelsStatus = 'disconnected';
+ if (Studio._panelsFollowUp) { clearTimeout(Studio._panelsFollowUp); Studio._panelsFollowUp = null; }
if (Studio.updateRigIoGating) Studio.updateRigIoGating();
}
}
@@ -6919,6 +7014,7 @@ Import error
);
if (info) {
Studio.capabilities = info.capabilities || [];
+ Studio.features = info.features || [];
if (info.mac && !Studio.controllerId) Studio.controllerId = info.mac;
}
} catch (_) { /* older fw / no reply — hasIoExt() stays false */ }
@@ -8636,6 +8732,10 @@ Import error
if (refuseUnsupportedRequires(exp)) return null;
const { steps } = RunnerLib.flattenStructure(exp, { shuffle: shuffleInPlace });
const conditionsByName = new Map((exp.conditions || []).map((c) => [c.name, c]));
+ // Run-start snapshot of the panel fleet (0xD1 is a pure read, ~10 ms per page):
+ // run_metadata.panels must describe the panels at THIS run's start, not whatever
+ // was cached at connect. Feature-gated inside; a failure records status 'failed'.
+ await Studio.refreshPanelInventory({ quiet: true });
startRunLog(isExperiment);
document.body.classList.toggle('testing', !isExperiment);
// Surface the live scope for BOTH run and test — the whole point of a test is
@@ -9161,7 +9261,11 @@ Import error
notes: $('mNotes').value
},
doc: { filename: doc.filename, sha256: doc.sha256 },
- session: { firmware: Studio.firmware, controllerId: Studio.controllerId },
+ session: {
+ firmware: Studio.firmware,
+ controllerId: Studio.controllerId,
+ panels: window.StudioPanelInventory ? window.StudioPanelInventory.toRunMeta(Studio.panels, Studio.panelsStatus) : null
+ },
rig: doc.rig,
toolVersion: Studio.TOOL_VERSION,
build: Studio.build
@@ -16655,6 +16759,9 @@ Import error
'.rail-btn[data-panel="io"]': 'The controller’s analog and digital input/output connectors.',
'.rail-btn[data-panel="ai"]': 'Watch the two analog inputs live, and check the wiring by looping Analog Out back into an input.',
'.rail-btn[data-panel="fw"]': 'Update or check the firmware on the LED panels.',
+ '[data-cmd="cinv"]': 'Show which panels the controller found at start-up and which firmware each one runs.',
+ '[data-cmd="cinvscan"]': 'Look for the panels again and re-check their firmware (the display is stopped while this runs).',
+ '#pvPanels': 'How many panels answered when the arena connected, and their firmware. Saved with every run.',
'[data-cmd="ctelem"]': 'Turn the controller\'s built-in activity recording on or off (it is written into the run log alongside the tracking data).',
'[data-cmd="chealth"]': 'Ask the controller how it is doing — loop timing, memory-card read times, and what it was doing before its last restart.',
'#fmSoak': 'Repeat the open protocol unattended (overnight) to catch the rare controller freeze; on a freeze it records diagnostics and, if you chose so, restarts the controller and continues.',
diff --git a/js/arena-wire-g6.js b/js/arena-wire-g6.js
index cc0e221f..de8479e7 100644
--- a/js/arena-wire-g6.js
+++ b/js/arena-wire-g6.js
@@ -88,6 +88,8 @@ const ArenaWireG6 = (function () {
GET_FIRMWARE_INFO: 0xe3, // [01 E3] reply: 32-byte footer {magic[8], version[16], crc32 LE, size LE}
G6_PROGRAM_PANEL: 0xc8, // [02 C8 panel_index] reflash one panel from /firmware/panel.bin via SPI ISP
G6_VERIFY_PANEL: 0xc9, // [02 C9 panel_index] CRC the panel's running app flash vs /firmware/panel.bin footer
+ PANEL_INVENTORY_SCAN: 0xd0, // [02 D0 action] re-probe the fleet: 0 presence (keeps fingerprints), 1 presence + fresh fingerprints; replies with 0xD1 page 0 (feature bit 0)
+ GET_PANEL_INVENTORY: 0xd1, // [01 D1] | [02 D1 first] per-panel presence + firmware fingerprint, 32-panel pages (feature bit 0)
ALL_ON: 0xff
};
@@ -124,11 +126,42 @@ const ArenaWireG6 = (function () {
// 0xAD / SET_AO_MODE 0xA3 / GET_ANALOG_IN 0xA4 — hosts detect the
// DIO-role machinery by this bit, not by firmware-version guessing.
[5, 'io_ext'],
+ // Per-board analog-input calibration (fw #47): GET_ANALOG_IN_RAW 0xA5 /
+ // SET_ANALOG_CAL 0xA6 / GET_ANALOG_CAL 0xA7 + the 0xA4 flags byte.
+ [6, 'ai_cal'],
// Controller health counters + reset breadcrumb (GET_HEALTH 0xCA) — the
// fw #50 soak/post-mortem probe. Hosts grey the health readout without it.
[7, 'health']
];
+ // The capability byte is full. New command families are advertised in the
+ // feature bitmap appended to the 0xC2 reply after the MAC: payload[8] = N
+ // bytes follow, bit k (LSB of features[0] = bit 0) = feature k. Presence is
+ // signalled by payload length, like the MAC (g6_03-controller.md § 0xC2).
+ const FEATURE_BITS = [
+ [0, 'panel_inventory'], // PANEL_INVENTORY_SCAN 0xD0 / GET_PANEL_INVENTORY 0xD1
+ [1, 'qwiic_i2c'], // GET_I2C_SCAN 0xB0 / I2C_TRANSFER 0xB1 (arena_12-18 only)
+ [2, 'ai_stream'] // reserved: sampled analog-input block stream
+ ];
+
+ // GET_PANEL_INVENTORY entry status byte (firmware PanelInventory::PanelStatus).
+ const PANEL_STATUS_NAMES = [
+ 'unknown', // 0 not scanned
+ 'absent', // 1 no COMM_CHECK reply
+ 'present', // 2 replied; not fingerprinted (yet)
+ 'fw_match', // 3 fingerprint == /firmware/panel.bin
+ 'fw_differs', // 4 fingerprint != /firmware/panel.bin
+ 'fw_no_reference', // 5 fingerprinted; no SD image to compare against
+ 'fw_failed' // 6 replied to COMM_CHECK but not to ISP
+ ];
+ const PANEL_INVENTORY_FLAGS = {
+ PRESENCE_VALID: 0x01,
+ FP_VALID: 0x02,
+ FP_IN_PROGRESS: 0x04,
+ REF_PRESENT: 0x08,
+ FP_PREFIX: 0x10
+ };
+
// ───────────────────────── validation helpers ─────────────────────────
function requireInt(value, name) {
@@ -676,7 +709,98 @@ const ArenaWireG6 = (function () {
.map((b) => b.toString(16).padStart(2, '0').toUpperCase())
.join(':');
}
- return { version, capability, capabilities, mac };
+ // Optional feature bitmap [N, features[N]] after the MAC. Valid only when
+ // the payload holds all N bytes; a truncated bitmap reads as no features.
+ let featureBits = null;
+ let features = [];
+ if (r.payload.length >= 9) {
+ const n = r.payload[8];
+ if (n > 0 && r.payload.length >= 9 + n) {
+ featureBits = 0;
+ for (let i = 0; i < Math.min(n, 4); i++) {
+ featureBits |= r.payload[9 + i] << (8 * i);
+ }
+ featureBits >>>= 0;
+ features = FEATURE_BITS.filter(([bit]) => featureBits & (1 << bit)).map(
+ ([, name]) => name
+ );
+ }
+ }
+ return { version, capability, capabilities, mac, featureBits, features };
+ }
+
+ /**
+ * panel-inventory-scan (0xD0): [02 D0 action]. action 0 = presence only
+ * (panels still present keep their fingerprint), 1 = presence + restart the
+ * fingerprint sweep. Needs ALL_OFF (else status 10) and no SD transfer.
+ * Replies with 0xD1 page 0 (echo 0xD0). Gate on feature 'panel_inventory'.
+ */
+ function encodePanelInventoryScan(action) {
+ requireInt(action, 'action');
+ if (action !== 0 && action !== 1) {
+ throw new RangeError('action must be 0 (presence) or 1 (presence + fingerprints)');
+ }
+ return Uint8Array.from([0x02, OPCODES.PANEL_INVENTORY_SCAN, action]);
+ }
+
+ /**
+ * get-panel-inventory (0xD1): [01 D1] or [02 D1 first]. `first` = 0-based
+ * index of the page's first panel (32 per page). Pure read, always allowed.
+ */
+ function encodeGetPanelInventory(first) {
+ if (first === undefined || first === null) {
+ return Uint8Array.from([0x01, OPCODES.GET_PANEL_INVENTORY]);
+ }
+ requireInt(first, 'first');
+ if (first < 0 || first > 255) throw new RangeError('first must be 0..255');
+ return Uint8Array.from([0x02, OPCODES.GET_PANEL_INVENTORY, first]);
+ }
+
+ /**
+ * 0xD1 page (also the 0xD0 reply) -> {version, panelCount, flags, flagNames,
+ * first, n, refCrc32, fpLen, ageMs, scanId, entries[{panel, status, statusName,
+ * crc32}]} or null. Header (18 B, LE): version=1, panel_count, flags, first, n,
+ * ref_crc32 u32, fp_len u32, age_ms u32, scan_id; then n × {status u8, crc32
+ * u32}. `panel` is the 1-based panel number (first + k + 1), the numbering
+ * 0xC8/0xC9 use. `scanId` changes on every presence scan: the pages of one
+ * multi-page read must agree, else re-read (firmware PanelInventory.h).
+ */
+ function decodePanelInventory(resp) {
+ const r = asResponse(resp);
+ if (!r || !r.ok) return null;
+ const p = r.payload;
+ if (p.length < 18 || p[0] !== 1) return null;
+ const n = p[4];
+ if (p.length !== 18 + 5 * n) return null;
+ const u32 = (o) => (p[o] | (p[o + 1] << 8) | (p[o + 2] << 16) | (p[o + 3] << 24)) >>> 0;
+ const flags = p[2];
+ const first = p[3];
+ const entries = [];
+ for (let k = 0; k < n; k++) {
+ const o = 18 + 5 * k;
+ const status = p[o];
+ entries.push({
+ panel: first + k + 1,
+ status,
+ statusName: PANEL_STATUS_NAMES[status] || 'status_' + status,
+ crc32: u32(o + 1)
+ });
+ }
+ return {
+ version: p[0],
+ panelCount: p[1],
+ flags,
+ flagNames: Object.keys(PANEL_INVENTORY_FLAGS).filter(
+ (k) => flags & PANEL_INVENTORY_FLAGS[k]
+ ),
+ first,
+ n,
+ refCrc32: u32(5),
+ fpLen: u32(9),
+ ageMs: u32(13),
+ scanId: p[17],
+ entries
+ };
}
// set/get-spi-clock (0xC5/0xC6) reply carries the clock as uint16 LE MHz.
@@ -1298,6 +1422,9 @@ const ArenaWireG6 = (function () {
MODES,
PANEL_DISPLAY_MODE_NAMES,
CAPABILITY_BITS,
+ FEATURE_BITS,
+ PANEL_STATUS_NAMES,
+ PANEL_INVENTORY_FLAGS,
STREAM_FRAME_BYTES,
// Encoders (request frames)
@@ -1344,6 +1471,8 @@ const ArenaWireG6 = (function () {
encodeGetFirmwareInfo,
encodeG6ProgramPanel,
encodeG6VerifyPanel,
+ encodePanelInventoryScan,
+ encodeGetPanelInventory,
encodeSetAoVoltage,
encodeGetAoVoltage,
encodeGetDigitalOut,
@@ -1390,7 +1519,8 @@ const ArenaWireG6 = (function () {
decodeAnalogIn,
decodeSetFirmwareFileResponse,
decodeFirmwareInfo,
- decodeProgramPanelResponse
+ decodeProgramPanelResponse,
+ decodePanelInventory
};
})();
diff --git a/js/studio-meta.js b/js/studio-meta.js
index 1e997146..5478556c 100644
--- a/js/studio-meta.js
+++ b/js/studio-meta.js
@@ -41,7 +41,9 @@
* @param {object} a
* @param {object} a.panel {experimenter, genotype, age, sex, fly_number, notes} from the side panel
* @param {object} a.doc {filename, sha256} loaded/edited protocol
- * @param {object} a.session {firmware, controllerId} from GET_CONTROLLER_INFO 0xC2 only
+ * @param {object} a.session {firmware, controllerId, panels} — firmware/controllerId from
+ * GET_CONTROLLER_INFO 0xC2 / 0xCB, panels = StudioPanelInventory
+ * .toRunMeta() of the 0xD1 inventory (null on firmware without it)
* @param {object} a.rig {name, arenaConfig} from the loaded rig YAML (NOT the controller)
* @param {string} a.toolVersion e.g. 'Arena Studio v0.1'
* @param {string} a.runId
@@ -74,6 +76,13 @@
// from the Teensy's burned-in unique ID. null until the firmware
// ships it in the 0xC2 reply (tolerant decode on the web side).
controller_id: session.controllerId || null,
+ // Which panels answered at RUN START and the firmware fingerprint each
+ // runs (GET_PANEL_INVENTORY 0xD1, fw #59), StudioPanelInventory.toRunMeta():
+ // {schema: 1, status: 'ok'|'pending'|'failed'|'unsupported'|'disconnected',
+ // count, present, missing, panel_status[], firmware[{crc32, panels}],
+ // mismatched, ref_crc32, fp_len, presence_valid, fp_valid, fp_in_progress,
+ // scan_id, age_ms}. Only null when the Studio asset itself is missing.
+ panels: session.panels || null,
tool_version: o.toolVersion || null,
// Which release tier ran this (docs/development/release-process.md): a Next
// candidate shares its upcoming tool_version with the release, so `channel` +
diff --git a/js/studio-panel-inventory.js b/js/studio-panel-inventory.js
new file mode 100644
index 00000000..1aa33a03
--- /dev/null
+++ b/js/studio-panel-inventory.js
@@ -0,0 +1,195 @@
+/**
+ * studio-panel-inventory.js — read and summarize the controller's panel
+ * inventory (GET_PANEL_INVENTORY 0xD1 / PANEL_INVENTORY_SCAN 0xD0, firmware
+ * #59; g6_03-controller.md § 0xD_), DOM-free so it is Node-testable
+ * (tests/test-studio-panel-inventory.js):
+ *
+ * - readAll(send) pages the whole fleet (32 panels per page) and re-reads
+ * from the start when the pages' scan_id disagree (a boot
+ * retry or another host's rescan landed between pages)
+ * - summarize(pages) per-panel statuses → present/missing, distinct firmware
+ * fingerprints, the panels that differ from the majority
+ * - describe(inv) one-line label for the Console log / provenance box
+ * - toRunMeta(inv) the compact record written to run_metadata.panels
+ *
+ * Gate every call on the 0xC2 feature 'panel_inventory' (decodeControllerInfo
+ * ().features): firmware without it answers 0xD1 with CE_UNKNOWN_CMD and an error
+ * glyph on the arena.
+ *
+ * LOADING: classic