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

+
fleet + + +
batch @@ -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