Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
124 changes: 118 additions & 6 deletions arena_studio.html

Large diffs are not rendered by default.

10 changes: 10 additions & 0 deletions docs/development/arena-studio-release-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,16 @@ The Studio's footer used to carry the full changelog inline; it now shows one li
history lives here. Newest first. (Per-session engineering detail stays in
`arena-studio-handover.md` and the design docs — this file is the user-facing what-changed list.)

## v0.93 (2026-09-29) · Panel inventory in every run log; Digital IO 1 trigger fix

<!-- #233 -->
- **The Studio now records which panels are present and what firmware each one runs.** On connect it asks the controller for its panel inventory (controller firmware with the panel-inventory feature) and shows it in the Run view's Auto-captured box ("20/20 present · fw 0x9BE0D3C7 ×20 …"). It reads it again at the start of every run, so the run log's `panels` record (next to `firmware`) describes the panels at that run's start: a missing panel or one on different firmware is on record for that run.
- **Console → Panel firmware → Inventory / Rescan.** Inventory lists the panels grouped by firmware fingerprint and names any that differ from the image on the SD card (or from the majority when there is none); Rescan probes the panels again.
- **The record always says what it is** — `status` ok / pending / failed / unsupported — so an absent inventory is never mistaken for "no panels". On older controller firmware the box says "firmware without panel inventory" and nothing is sent.

<!-- #234 -->
- **Digital IO 1 can no longer be set as the trigger input.** Only Digital IO 2 is wired to the panels' trigger. A rig file that declares it on Digital IO 1 gets a warning at connect instead of a trigger that silently does nothing.

## v0.92 (2026-09-28) · Release tiers on the page; SD purge timeout

<!-- #227 -->
Expand Down
134 changes: 132 additions & 2 deletions js/arena-wire-g6.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};

Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -1344,6 +1471,8 @@ const ArenaWireG6 = (function () {
encodeGetFirmwareInfo,
encodeG6ProgramPanel,
encodeG6VerifyPanel,
encodePanelInventoryScan,
encodeGetPanelInventory,
encodeSetAoVoltage,
encodeGetAoVoltage,
encodeGetDigitalOut,
Expand Down Expand Up @@ -1390,7 +1519,8 @@ const ArenaWireG6 = (function () {
decodeAnalogIn,
decodeSetFirmwareFileResponse,
decodeFirmwareInfo,
decodeProgramPanelResponse
decodeProgramPanelResponse,
decodePanelInventory
};
})();

Expand Down
8 changes: 8 additions & 0 deletions js/plugin-registry.js
Original file line number Diff line number Diff line change
Expand Up @@ -1253,6 +1253,14 @@ function parseRigIo(rigData) {
}
var slot = result.dio[port - 1];
slot.role = _rigIoRole(entry.role, RIG_IO_ROLES.dio, 'dio port ' + port, result.warnings);
// Only "Digital IO 2 (5V)" is wired to the panels' EINT trigger net (U3 + J30 shunt).
// Digital IO 1 has no route, so in_trigger there would be a silently dead trigger (#168).
if (port === 1 && slot.role === 'in_trigger') {
result.warnings.push(
'dio port 1 cannot be in_trigger: only "Digital IO 2 (5V)" reaches the panels\' trigger input (EINT); treated as off'
);
slot.role = 'off';
}
// default is outputs-only; clamp anything truthy-numeric/boolean to 0|1.
if (entry.default != null)
slot.default = entry.default === true || Number(entry.default) === 1 ? 1 : 0;
Expand Down
11 changes: 10 additions & 1 deletion js/studio-meta.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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` +
Expand Down
Loading
Loading