Skip to content
Closed
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
14 changes: 10 additions & 4 deletions js/arena-runner-g6.js
Original file line number Diff line number Diff line change
Expand Up @@ -103,14 +103,16 @@ var ArenaRunnerG6 = (function () {
* can't represent, before the encoder's terser RangeError would fire:
* - mode ∉ {2,3,4}
* - non-integer / < 1 patternId
* Gain is coerced; encodeTrialParams enforces the int8 range. frame_rate
* Gain is coerced; encodeTrialParams enforces the int16 range. frame_rate
* is passed through SIGNED — negative plays Mode 2 in reverse (G4-style
* count-down; fw ee74c33+, fw issue #4), sign ignored by firmware in
* Modes 3/4; encodeTrialParams enforces the int16 range.
* Modes 3/4; encodeTrialParams enforces the int16 range. duration is
* seconds (same convention as conditionDuration's wait/trialParams math);
* encodeTrialParams converts to 10 ms ticks on the wire.
*
* @param {object} cmd the trialParams controller command
* @param {{patternId:number}} opts the resolved 1-based SD index
* @returns {{mode:number, patternId:number, frameRate:number, gain:number, initPos:number}}
* @returns {{mode:number, patternId:number, frameRate:number, gain:number, initPos:number, duration:number}}
*/
function buildTrialParams(cmd, opts) {
cmd = cmd || {};
Expand All @@ -129,6 +131,7 @@ var ArenaRunnerG6 = (function () {

const gain = cmd.gain === undefined ? 0 : toNumber(cmd.gain, 'gain');
const initPos = frameIndexToInitPos(cmd.frame_index);
const duration = cmd.duration === undefined ? 0 : toNumber(cmd.duration, 'duration');

const patternId = toNumber(opts.patternId, 'patternId');
if (!Number.isInteger(patternId) || patternId < 1) {
Expand All @@ -138,7 +141,7 @@ var ArenaRunnerG6 = (function () {
);
}

return { mode, patternId, frameRate, gain, initPos };
return { mode, patternId, frameRate, gain, initPos, duration };
}

// The controller commands the sequence runner can EMIT on G6, grounded in the
Expand Down Expand Up @@ -505,6 +508,9 @@ var ArenaRunnerG6 = (function () {
* the controller signals completion), replace this with a function that AWAITS
* the controller's run-complete event instead of sleeping — or inject
* `opts.timing` into runSequence. Nothing else in the runner changes.
* The wire-side duration now exists (buildTrialParams/encodeTrialParams), so
* the swap is unblocked whenever it's picked up — this function is untouched
* for now (host-side timing stays authoritative; see plan scope decision).
*
* Best-effort: a slept/closed tab won't fire the timer, so STOP/abort is the
* primary control.
Expand Down
2 changes: 1 addition & 1 deletion js/arena-session.js
Original file line number Diff line number Diff line change
Expand Up @@ -295,7 +295,7 @@
* Run a SINGLE trial (LAB-94 dry-run): sends only trialParams, arms an
* optional host-timed auto-stop. Pre-empts any active run first.
* @param {object} a
* @param {object} a.params encodeTrialParams arg {mode,patternId,frameRate,gain,initPos}
* @param {object} a.params encodeTrialParams arg {mode,patternId,frameRate,initPos,gain,duration}
* @param {number} [a.durationSec] >0 arms a host-side auto-stop
* @param {string} [a.conditionName]
* @param {Function} [a.onStatus] per-call status sink (also broadcast as 'runstatus')
Expand Down
35 changes: 16 additions & 19 deletions js/arena-wire-g6.js
Original file line number Diff line number Diff line change
Expand Up @@ -145,17 +145,6 @@ const ArenaWireG6 = (function () {
return [value & 0xff, (value >> 8) & 0xff];
}

// Validate a signed int8 and return the unsigned wire byte (two's
// complement). e.g. gain -50 -> 0xCE. This is the easy-to-get-wrong case
// the golden tests pin.
function int8Byte(value, name) {
requireInt(value, name);
if (value < -128 || value > 127) {
throw new RangeError(name + ' must be -128..127 (int8), got ' + value);
}
return value & 0xff;
}

// Validate a signed int16 and return [lo, hi] little-endian two's
// complement. e.g. -2 -> [0xFE, 0xFF]. Used for trial-params frame_rate,
// which firmware reads as int16 (fw #4, ee74c33: negative = Mode-2
Expand Down Expand Up @@ -214,19 +203,23 @@ const ArenaWireG6 = (function () {
/**
* trial-params (0x08) — select display mode + pattern + timing.
* Emits the documented 13-byte combined command:
* [0C 08 mode pat(LE16) rate(LE16) gain init(LE16) 00 00 00]
* The length byte 0x0C = 12 = cmd + 11 param bytes. The 3 trailing reserved
* bytes pad the combined-command length; the firmware reads only the first
* 8 param bytes.
* [0C 08 mode pat(LE16) rate(LE16) init(LE16) gain(LE16) duration(LE16)]
* The length byte 0x0C = 12 = cmd + 11 param bytes, all required (fw #4
* canonical re-layout: gain widened to int16, moved after init_pos, plus
* a new controller-run Duration field).
*
* @param {object} p
* @param {number} [p.mode=2] display mode (2 open / 3 show-frame / 4 closed)
* @param {number} [p.patternId=1] 1-based pattern id (Nth .pat in /patterns)
* @param {number} [p.frameRate=0] frame-advance rate in Hz, int16 — negative
* plays Mode 2 in REVERSE (G4-style count-down;
* fw ee74c33+); sign ignored in Modes 3/4
* @param {number} [p.gain=0] signed int8 velocity gain (×10 fps/V in Mode 4)
* @param {number} [p.initPos=0] initial frame index (0-based)
* @param {number} [p.gain=0] signed int16 velocity gain (×10 fps/V in Mode 4)
* @param {number} [p.duration=0] controller-run trial length, in SECONDS
* (converted to AC::constants::duration_tick_ms —
* 10 ms — ticks on the wire); `0` = no auto-stop,
* the controller runs until told to stop
*/
function encodeTrialParams(p) {
p = p || {};
Expand All @@ -238,10 +231,14 @@ const ArenaWireG6 = (function () {
}
const pat = u16le(patternId, 'patternId');
const rate = i16le(p.frameRate === undefined ? 0 : p.frameRate, 'frameRate');
const gain = int8Byte(p.gain === undefined ? 0 : p.gain, 'gain');
const init = u16le(p.initPos === undefined ? 0 : p.initPos, 'initPos');
// mode, pat(2), rate(2), gain, init(2), reserved(3) = 11 param bytes.
const params = [mode, ...pat, ...rate, gain, ...init, 0, 0, 0];
const gain = i16le(p.gain === undefined ? 0 : p.gain, 'gain');
const durationTicks = u16le(
Math.round((p.duration === undefined ? 0 : p.duration) * 100),
'duration'
); // seconds -> 10ms ticks, matching AC::constants::duration_tick_ms
// mode, pat(2), rate(2), init(2), gain(2), duration(2) = 11 param bytes.
const params = [mode, ...pat, ...rate, ...init, ...gain, ...durationTicks];
return frame(OPCODES.TRIAL_PARAMS, params); // 0C 08 ...
}

Expand Down
11 changes: 6 additions & 5 deletions tests/test-arena-runner-g6.js
Original file line number Diff line number Diff line change
Expand Up @@ -188,11 +188,12 @@ async function main() {
check('frameRate', p.frameRate, 10);
check('gain', p.gain, 0);
check('initPos (from frame_index 1)', p.initPos, 1);
// The encoded frame must match the wire golden vector.
check('duration', p.duration, 5);
// The encoded frame must match the wire golden vector (duration=5s -> 500 ticks -> F4 01).
checkBytes(
'encodeTrialParams(mapped)',
Wire.encodeTrialParams(p),
'0c 08 02 01 00 0a 00 00 01 00 00 00 00'
'0c 08 02 01 00 0a 00 01 00 00 00 f4 01'
);

// THE coercion test: string scalars (as a YAML parser might yield) must work.
Expand All @@ -201,7 +202,7 @@ async function main() {
checkBytes(
'string-typed fields coerce to the same frame',
Wire.encodeTrialParams(ps),
'0c 08 02 01 00 0a 00 00 01 00 00 00 00'
'0c 08 02 01 00 0a 00 01 00 00 00 00 00'
);

// NEGATIVE frame_rate = Mode-2 reverse playback (fw ee74c33+, fw #4) —
Expand Down Expand Up @@ -233,7 +234,7 @@ async function main() {
checkBytes(
'sent the trialParams frame',
link.sent[0],
'0c 08 02 01 00 0a 00 00 01 00 00 00 00'
'0c 08 02 01 00 0a 00 01 00 00 00 f4 01'
);
check('conditionName tracked', runner.conditionName, 'sine_grating');

Expand Down Expand Up @@ -530,7 +531,7 @@ async function main() {
});
check('sent exactly 3 frames (allOn, trialParams, final STOP)', link.sent.length, 3);
checkBytes('1st send: allOn', link.sent[0], '01 ff');
checkBytes('2nd send: trialParams', link.sent[1], '0c 08 02 01 00 0a 00 00 01 00 00 00 00');
checkBytes('2nd send: trialParams', link.sent[1], '0c 08 02 01 00 0a 00 01 00 00 00 f4 01');
checkBytes('3rd send: final STOP', link.sent[2], '01 30');
checkBool('summary.completed true', summary.completed === true);
checkBool('summary.aborted false', summary.aborted === false);
Expand Down
53 changes: 38 additions & 15 deletions tests/test-arena-wire-g6.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
* - scripts/all_on.py (all-on / all-off)
* - scripts/web-serial/main.js (every button's byte sequence)
* - src/commands.h (opcodes)
* in the sibling repo LED-Display_G6_Firmware_Arena. The negative-gain int8
* in the sibling repo LED-Display_G6_Firmware_Arena. The negative-gain int16
* Mode-4 case is pinned explicitly — it's the easy-to-get-wrong one.
*
* Exits 0 on PASS, 1 on any FAIL. Wired into `npm test` for CI.
Expand Down Expand Up @@ -132,33 +132,44 @@ checkBytes(
Wire.encodeTrialParams({ mode: 2, patternId: 1, frameRate: 30, initPos: 0 }),
'0c 08 02 01 00 1e 00 00 00 00 00 00 00'
);
// Defaults: mode=2, patternId=1, frameRate=0, gain=0, initPos=0.
// Defaults: mode=2, patternId=1, frameRate=0, initPos=0, gain=0, duration=0.
checkBytes(
'trial defaults (mode2 pat1)',
Wire.encodeTrialParams(),
'0c 08 02 01 00 00 00 00 00 00 00 00 00'
);
// Mode 4 closed-loop, NEGATIVE gain -50 -> int8 byte 0xCE (the must-pin case).
// Mode 4 closed-loop, NEGATIVE gain -50 -> int16 LE CE FF (the must-pin case).
checkBytes(
'trial mode4 gain -50 -> 0xCE',
'trial mode4 gain -50 -> CE FF',
Wire.encodeTrialParams({ mode: 4, patternId: 1, frameRate: 0, gain: -50, initPos: 0 }),
'0c 08 04 01 00 00 00 ce 00 00 00 00 00'
'0c 08 04 01 00 00 00 00 00 ce ff 00 00'
);
// play_pattern.py docstring example: Mode 4 gain -20 -> int8 byte 0xEC.
// play_pattern.py docstring example: Mode 4 gain -20 -> int16 LE EC FF.
checkBytes(
'trial mode4 gain -20 -> 0xEC',
'trial mode4 gain -20 -> EC FF',
Wire.encodeTrialParams({ mode: 4, patternId: 1, gain: -20 }),
'0c 08 04 01 00 00 00 ec 00 00 00 00 00'
'0c 08 04 01 00 00 00 00 00 ec ff 00 00'
);
// int8 boundaries: -128 -> 0x80, 127 -> 0x7F, -1 -> 0xFF.
check('gain -128 byte', Wire.encodeTrialParams({ gain: -128 })[7], 0x80);
check('gain 127 byte', Wire.encodeTrialParams({ gain: 127 })[7], 0x7f);
check('gain -1 byte', Wire.encodeTrialParams({ gain: -1 })[7], 0xff);
// Widened-gain case: -500 is past the old int8 ceiling, exercising the gain
// high byte -> int16 LE 0C FE.
checkBytes(
'trial mode4 gain -500 -> 0C FE',
Wire.encodeTrialParams({ mode: 4, patternId: 1, gain: -500 }),
'0c 08 04 01 00 00 00 00 00 0c fe 00 00'
);
// int16 boundaries: -32768 -> 00 80, 32767 -> FF 7F, -1 -> FF FF.
check('gain -32768 lo byte', Wire.encodeTrialParams({ gain: -32768 })[9], 0x00);
check('gain -32768 hi byte', Wire.encodeTrialParams({ gain: -32768 })[10], 0x80);
check('gain 32767 lo byte', Wire.encodeTrialParams({ gain: 32767 })[9], 0xff);
check('gain 32767 hi byte', Wire.encodeTrialParams({ gain: 32767 })[10], 0x7f);
check('gain -1 lo byte', Wire.encodeTrialParams({ gain: -1 })[9], 0xff);
check('gain -1 hi byte', Wire.encodeTrialParams({ gain: -1 })[10], 0xff);
// Mode 3 show-frame with a large pattern id / init exercises both u16 hi bytes.
// init_pos now sits at offset 7-8 (post-relayout).
checkBytes(
'trial mode3 pat300 init513',
Wire.encodeTrialParams({ mode: 3, patternId: 300, initPos: 513 }),
'0c 08 03 2c 01 00 00 00 01 02 00 00 00'
'0c 08 03 2c 01 00 00 01 02 00 00 00 00'
);
// NEGATIVE frame_rate = Mode-2 REVERSE (fw reads int16 since ee74c33, fw #4).
// -2 Hz -> int16 LE FE FF — the two's-complement must-pin case for the rate.
Expand All @@ -176,13 +187,20 @@ checkBytes(
check('rate 32767 lo byte', Wire.encodeTrialParams({ frameRate: 32767 })[5], 0xff);
check('rate 32767 hi byte', Wire.encodeTrialParams({ frameRate: 32767 })[6], 0x7f);
check('rate -32768 hi byte', Wire.encodeTrialParams({ frameRate: -32768 })[6], 0x80);
// duration=0 (default) is covered by 'trial defaults' above. 1.5 s -> 150
// ticks (10 ms/tick) -> int16 LE 96 00, at the new offset 11-12.
checkBytes(
'trial duration 1.5s -> 150 ticks (96 00)',
Wire.encodeTrialParams({ mode: 2, patternId: 1, duration: 1.5 }),
'0c 08 02 01 00 00 00 00 00 00 00 96 00'
);
// Length byte is always 0x0C (12 bytes follow: cmd + 11 params); total = 13 B.
check('trial frame total length', Wire.encodeTrialParams().length, 13);
check('trial length byte', Wire.encodeTrialParams()[0], 0x0c);

console.log('\n=== encoder range validation (throws) ===');
checkThrows('gain -129 throws', () => Wire.encodeTrialParams({ gain: -129 }));
checkThrows('gain 128 throws', () => Wire.encodeTrialParams({ gain: 128 }));
checkThrows('gain -32769 throws', () => Wire.encodeTrialParams({ gain: -32769 }));
checkThrows('gain 32768 throws', () => Wire.encodeTrialParams({ gain: 32768 }));
checkThrows('patternId 70000 throws', () => Wire.encodeTrialParams({ patternId: 70000 }));
// frame_rate is int16 now: 32768..65535 would ALIAS to reverse rates on the
// signed firmware — must throw, not silently encode (they were legal as u16).
Expand All @@ -193,6 +211,11 @@ checkThrows('rate 65535 throws (was legal as u16)', () =>
Wire.encodeTrialParams({ frameRate: 65535 })
);
checkThrows('rate -32769 throws', () => Wire.encodeTrialParams({ frameRate: -32769 }));
// duration ticks must fit uint16: 655.35 s is the max representable (65535
// ticks); 655.36 s rounds to 65536 ticks and must throw, not wrap.
checkThrows('duration 655.36s throws (tick overflow)', () =>
Wire.encodeTrialParams({ duration: 655.36 })
);
checkThrows('frame position -1 throws', () => Wire.encodeSetFramePosition(-1));
checkThrows('frame position 70000 throws', () => Wire.encodeSetFramePosition(70000));
checkThrows('non-integer mhz throws', () => Wire.encodeSetSpiClock(20.5));
Expand Down
Loading