Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
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
4 changes: 2 additions & 2 deletions .agents/skills/g6-orientation/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ interface.
|---|---|
| **reiserlab/maDisplayTools** | **MATLAB** tools (G4+). Pattern math reference — **web output must match MATLAB exactly** (CI validates, tol 1e-4). Its protocol **runner is G4.1-only and frozen** (decision 2026-10-26); protocol authority = `webDisplayTools/docs/development/v3-spec.md` + conformance fixtures. (Its `docs/development/G4G6_ROADMAP.md` is largely historical now — use the umbrella docs for current G6.) |
| **reiserlab/LED-Display_pyDisplayTools** (pyDisplayTools) | **Python** tools for the displays (G4 pattern + experiment-folder tools). **Accepted destination** for the Python arena library and the **headless runner** (`arena-run`), a peer of the web runner (decision 2026-10-26). |
| **reiserlab/rig-nodes** | **Accepted destination, not yet created:** shared ROS 2 infrastructure for every lab rig (`rig_interfaces`, device nodes for FicTrac / Kinefly / BIAS / ScanImage / SiPM, MCAP recording and supervision primitives). Today that code lives in `webDisplayTools/ros2-poc/` on the 2026-10-20 release candidate. Record: `webDisplayTools/docs/development/rig-software-repos-and-runners.md`. |
| **reiserlab/rig-nodes** | **Accepted destination, not yet created:** shared ROS 2 infrastructure for every lab rig (`rig_interfaces`, device nodes for FicTrac / Kinefly / BIAS / ScanImage / SiPM, MCAP recording and supervision primitives). Today that code lives in `webDisplayTools/rig/` on the 2026-10-20 release candidate. Record: `webDisplayTools/docs/development/rig-software-repos-and-runners.md`. |

### G6 firmware (what the web connects to over USB)
| repo | role |
Expand Down Expand Up @@ -124,7 +124,7 @@ interface.
(WebSocket `ws://localhost:8765` ← UDP `:60000`) → browser. `pixi run bridge` + `pixi run
sim` (start the bridge first). The web runner executes only the `fictrac` and `log` plugins;
`backlight`/`camera`/`temperature` are declared for MATLAB and **skipped on the web**.
- **ROS 2 graph (LAB-303, candidate 2026-10-20)** — `ros2-poc/` replaces the bridge with a graph
- **ROS 2 graph (LAB-303, candidate 2026-10-20)** — `rig/` replaces the bridge with a graph
(rmw_zenoh, MCAP record, BIAS + ScanImage connectors) behind the SAME WebSocket; the Studio is
unchanged. Where the pieces will live from here: `rig-software-repos-and-runners.md` (above).

Expand Down
33 changes: 33 additions & 0 deletions .agents/skills/protocol-yaml/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -314,6 +314,39 @@ changes may tune a trial, never restructure it. Web-only; MATLAB ignores `runtim
to run it** until the web runner implements that capability; MATLAB may run it. Leave it out
otherwise.

### `controller:` — sticky controller settings the run must assert (Studio v0.93)

The controller keeps a few **sticky** settings across runs (panel display mode, refresh rate,
Digital IO roles…). Wiring belongs in the rig YAML; what the *experiment* needs goes in a
top-level `controller:` block, and the Studio asserts it before every run (GET → SET → GET,
refuse on mismatch) and writes the before/after snapshot into the run header. Design:
`docs/development/controller-settings-strategy.md`; user doc: `docs/development/controller-settings.md`.

```yaml
rig: "./configs/rigs/bergamo_g6_2x10_2p.yaml"
requires: [controller_block] # a runner without this feature REFUSES the file instead of ignoring the block
controller:
panel_mode: triggered # 0 oneshot | 1 persistent | 2 triggered | 3 gated (names or numbers)
refresh_policy: line_sync_safe # cap the re-stream rate at the rig's limits.max_refresh_hz — OR refresh_hz: <int>
panel_firmware: "2p" # SD footer version (or prefix) the panel image must match; "2p" = the line-sync build
```

- **Most protocols do not need the block.** A course protocol inherits the rig's
`defaults.panel_mode` (persistent); the Studio asserts that too. Only declare a mode when the
experiment depends on it (imaging: `triggered`).
- **A protocol with a `controller:` block is strict**: rig mismatch (its `rig:` vs the session
rig), an unreadable setting, or the wrong panel firmware **blocks the run** (green and blue
buttons). On rigs with `strict: true` in the rig YAML this holds for every protocol.
- `refresh_hz` and `refresh_policy` are mutually exclusive. Prefer the policy: the refresh a
pattern needs follows its grayscale (GS2 streams at 1000 Hz by default, GS16 at 300), so the
Studio derives it from the referenced patterns and the rig cap; a bare number is for refresh
experiments only and must not exceed the rig's `limits.max_refresh_hz`.
- Panel firmware is checked against the SD footer (what image was uploaded) **and** the
session's fleet verify (which panels actually run it). Unverified panels only warn; a failed
verify blocks. Settings → Controller → *Verify panels* runs the fleet check (~1 s per panel).
- The validator prints the block and cross-checks it against the rig file when that is in the repo.
- MATLAB does not implement the block yet; the `requires` token is what makes it refuse.

## Plugins

`plugins:` normally comes from the rig (the Studio pre-fills New protocols from the rig
Expand Down
40 changes: 40 additions & 0 deletions .agents/skills/protocol-yaml/scripts/validate-protocol.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,46 @@ if (Array.isArray(exp.requires) && exp.requires.length) {
console.log('ℹ requires: [' + exp.requires.join(', ') + ']' +
(missing.length ? ' — the WEB runner refuses to run this (missing: ' + missing.join(', ') + ')' : ''));
}
// ── controller: block (sticky controller settings asserted before the run) ──────
// docs/development/controller-settings-strategy.md. Malformed values are already in
// `blocking` (collectBlockingErrors); here: what it declares, the capability token, and a
// cross-check against the rig file when it is readable from the repo root.
const cb = exp.controller;
if (cb && cb.declared) {
const parts = [];
if (cb.panel_mode != null) parts.push('panel_mode ' + cb.panel_mode + ' (' + ['oneshot', 'persistent', 'triggered', 'gated'][cb.panel_mode] + ')');
if (cb.refresh_hz != null) parts.push('refresh_hz ' + cb.refresh_hz);
if (cb.refresh_policy) parts.push('refresh_policy ' + cb.refresh_policy);
if (cb.panel_firmware) parts.push('panel_firmware "' + cb.panel_firmware + '"');
console.log('ℹ controller: ' + (parts.join(', ') || '(empty block)') + ' — asserted by the Studio before every run, recorded in the run header');
const rigPath = String(exp.rig_path || '');
const rigFile = rigPath.startsWith('./') || rigPath.startsWith('configs/') ? resolve(rigPath) : null;
if (rigFile && existsSync(rigFile)) {
try {
const { createRequire } = await import('node:module');
const Registry = createRequire(import.meta.url)(resolve('js/plugin-registry.js'));
const rig = Registry.parseRigIo(v3.parseRigYAMLText(readFileSync(rigFile, 'utf8')));
const notes = [];
if (rig.defaults.panel_mode != null && cb.panel_mode != null && rig.defaults.panel_mode !== cb.panel_mode) {
notes.push('protocol panel_mode ' + cb.panel_mode + ' overrides the rig default ' + rig.defaults.panel_mode + ' (allowed; the run asserts the protocol\'s value)');
}
if (cb.refresh_hz != null && rig.limits.max_refresh_hz != null && cb.refresh_hz > rig.limits.max_refresh_hz) {
console.error('✗ BLOCKING: controller.refresh_hz ' + cb.refresh_hz + ' exceeds the rig limit max_refresh_hz ' + rig.limits.max_refresh_hz + ' (' + rigPath + ')');
process.exitCode = 1;
}
if (cb.refresh_policy === 'line_sync_safe' && rig.limits.max_refresh_hz == null) {
console.warn('⚠ controller: refresh_policy line_sync_safe but the rig ' + rigPath + ' declares no limits.max_refresh_hz — nothing to cap at');
}
if (rig.requires.panel_firmware && cb.panel_firmware && rig.requires.panel_firmware !== cb.panel_firmware) {
notes.push('protocol requires panel firmware "' + cb.panel_firmware + '", rig requires "' + rig.requires.panel_firmware + '" — both are checked');
}
for (const w of rig.warnings || []) console.warn('⚠ rig ' + rigPath + ': ' + w);
for (const n of notes) console.log('ℹ ' + n);
console.log('ℹ rig ' + rigPath + ': defaults.panel_mode ' + rig.defaults.panel_mode + ', limits.max_refresh_hz ' + rig.limits.max_refresh_hz +
', requires.panel_firmware ' + JSON.stringify(rig.requires.panel_firmware) + ', strict ' + rig.strict);
} catch (e) { console.warn('⚠ rig ' + rigPath + ' could not be cross-checked: ' + (e && e.message ? e.message : e)); }
}
}
const rcNames = Object.keys(exp.runtime_controls || {});
if (rcNames.length) {
const { createRequire } = await import('node:module');
Expand Down
Original file line number Diff line number Diff line change
@@ -1,30 +1,30 @@
# Build ros2-poc/interfaces/arena_interfaces (the typed ROS 2 messages of the arena bridge graph,
# Build rig/interfaces/rig_interfaces (the typed ROS 2 messages of the arena bridge graph,
# LAB-303) ONCE per platform in CI and publish the result as a conda channel, so a rig PC installs
# typed messages as a download — no MSVC, no build backend, no MAX_PATH workaround. Decision and
# the consumer-side manifest edits: ros2-poc/docs/typed-distribution-decision.md; the measurements
# behind it: ros2-poc/docs/typed-windows.md.
# the consumer-side manifest edits: rig/docs/typed-distribution-decision.md; the measurements
# behind it: rig/docs/typed-windows.md.
#
# Runs: build-only on a PR that touches the package or this file (proves the three platforms still
# build); the `arena-interfaces-channel` artifact on every run; the hosted-channel upload only on an
# `arena-interfaces-v*` tag AND only when the repository variable ARENA_INTERFACES_CHANNEL names a
# build); the `rig-interfaces-channel` artifact on every run; the hosted-channel upload only on an
# `rig-interfaces-v*` tag AND only when the repository variable RIG_INTERFACES_CHANNEL names a
# prefix.dev channel (with the PREFIX_API_KEY secret). Until the lab owns one, the channel artifact
# is committed into ros2-poc/channel/ by hand (the decision note has the recipe).
name: arena-interfaces
# is committed into rig/channel/ by hand (the decision note has the recipe).
name: rig-interfaces

on:
pull_request:
paths:
- 'ros2-poc/interfaces/**'
- '.github/workflows/arena-interfaces.yml'
- 'rig/interfaces/**'
- '.github/workflows/rig-interfaces.yml'
push:
tags: ['arena-interfaces-v*']
tags: ['rig-interfaces-v*']
workflow_dispatch:

permissions:
contents: read

concurrency:
group: arena-interfaces-${{ github.event.pull_request.number || github.ref }}
group: rig-interfaces-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true

env:
Expand All @@ -48,7 +48,7 @@ jobs:
defaults:
run:
shell: bash
working-directory: ros2-poc
working-directory: rig
steps:
- uses: actions/checkout@v7
with:
Expand All @@ -57,16 +57,16 @@ jobs:
with:
pixi-version: ${{ env.PIXI_VERSION }}
run-install: false
# MAX_PATH: the runner's checkout (D:\a\webDisplayTools\webDisplayTools\ros2-poc, 45
# MAX_PATH: the runner's checkout (D:\a\webDisplayTools\webDisplayTools\rig, 40
# chars) plus the ~211 chars the rosidl build adds crosses 260. detached-environments
# moves .pixi (build workspace included) to a 4-char root — the ww18 recipe.
- name: Windows build root
if: runner.os == 'Windows'
run: pixi config set --local detached-environments 'C:\px'
- name: pixi build arena_interfaces
- name: pixi build rig_interfaces
run: |
rm -rf ../dist
pixi build --path interfaces/arena_interfaces/pixi.toml --output-dir ../dist
pixi build --path interfaces/rig_interfaces/pixi.toml --output-dir ../dist
mkdir -p ../dist/${{ matrix.subdir }}
mv ../dist/*.conda ../dist/${{ matrix.subdir }}/
ls -la ../dist/${{ matrix.subdir }}
Expand All @@ -84,7 +84,7 @@ jobs:
EOF
- uses: actions/upload-artifact@v5
with:
name: arena_interfaces-${{ matrix.subdir }}
name: rig_interfaces-${{ matrix.subdir }}
path: dist/${{ matrix.subdir }}/*.conda
if-no-files-found: error
retention-days: 30
Expand All @@ -102,13 +102,13 @@ jobs:
# dist/<subdir>, so the subdir is restored here from the artifact name).
- uses: actions/download-artifact@v5
with:
pattern: arena_interfaces-*
pattern: rig_interfaces-*
path: artifacts
- name: assemble + index the channel
run: |
mkdir -p channel
for d in artifacts/arena_interfaces-*; do
subdir=${d#artifacts/arena_interfaces-}
for d in artifacts/rig_interfaces-*; do
subdir=${d#artifacts/rig_interfaces-}
mkdir -p "channel/$subdir"
cp "$d"/*.conda "channel/$subdir/"
done
Expand All @@ -121,20 +121,20 @@ jobs:
for name, rec in pk.items():
print(rd.parent.name, name, rec["sha256"], [d for d in rec["depends"] if d.startswith("ros2-distro-mutex")])
EOF
# The deliverable: a complete three-platform channel. Commit it into ros2-poc/channel/
# The deliverable: a complete three-platform channel. Commit it into rig/channel/
# (decision note §"Releasing a new build") or let the step below upload it.
- uses: actions/upload-artifact@v5
with:
name: arena-interfaces-channel
name: rig-interfaces-channel
path: channel
if-no-files-found: error
retention-days: 90
# Hosted channel (prefix.dev), only when the lab has one: set the repository variable
# ARENA_INTERFACES_CHANNEL to its name and the secret PREFIX_API_KEY. Tags only, so a
# RIG_INTERFACES_CHANNEL to its name and the secret PREFIX_API_KEY. Tags only, so a
# PR build can never publish.
- name: upload to the hosted channel
if: startsWith(github.ref, 'refs/tags/arena-interfaces-v') && vars.ARENA_INTERFACES_CHANNEL != ''
if: startsWith(github.ref, 'refs/tags/rig-interfaces-v') && vars.RIG_INTERFACES_CHANNEL != ''
env:
PREFIX_API_KEY: ${{ secrets.PREFIX_API_KEY }}
ARENA_INTERFACES_CHANNEL: ${{ vars.ARENA_INTERFACES_CHANNEL }}
run: pixi exec rattler-build upload prefix --channel "$ARENA_INTERFACES_CHANNEL" channel/*/*.conda
RIG_INTERFACES_CHANNEL: ${{ vars.RIG_INTERFACES_CHANNEL }}
run: pixi exec rattler-build upload prefix --channel "$RIG_INTERFACES_CHANNEL" channel/*/*.conda
8 changes: 4 additions & 4 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,7 +108,7 @@ When editing web tools, audit all buttons and controls in the modified code sect
- Web outputs must match MATLAB outputs exactly

**Repository boundaries (decision 2026-10-26):** `docs/development/rig-software-repos-and-runners.md`
is the record. The browser tools and their rig-side gateway (`ros2-poc/nodes/bridge_node.py`) stay
is the record. The browser tools and their rig-side gateway (`rig/nodes/bridge_node.py`) stay
here; the Python arena library + headless runner go to `LED-Display_pyDisplayTools`; shared ROS 2
infrastructure (interfaces, device nodes, recording, supervision) goes to `rig-nodes`, which does not
exist yet. Until the extraction session, every file is where the record's inventory says it is today.
Expand Down Expand Up @@ -369,17 +369,17 @@ fix flows to every page automatically; two hand-written HTML pages never will.
colocated < uploaded — every write names its layer, arrival order never decides). The awaits
before the load are allowlisted in `tests/test-studio-preview-sources.js`.
- **ROS 2 bridge = the bridge going forward (LAB-303, decided 2026-10-16/17).** Rig PCs run
`cd ros2-poc && pixi run rig` (its own pixi workspace: Zenoh router + FicTrac adapter + bridge node
`cd rig && pixi run rig` (its own pixi workspace: Zenoh router + FicTrac adapter + bridge node
+ optional `--bias` / `--scanimage` / `--foxglove`). Bridge 3.4 (`pixi run bridge` at the root) is
the legacy path for course rigs, BUT `fictrac-bridge/bridge.py` is STILL IMPORTED by
`ros2-poc/nodes/bridge_node.py` and `mcap_convert.py` (Hub, LogWriter, Pipeline, the pure
`rig/nodes/bridge_node.py` and `mcap_convert.py` (Hub, LogWriter, Pipeline, the pure
encoders): a change there changes ROS acquisition and the conversion of every historical bag.
**MCAP bag = the record**; the `behavior_v2` JSONL is DERIVED (converter), transitional and
FicTrac-only — new plugin streams go into the bag, never into a new `behavior_vN`. Bags are
archived on the group share (later a bucket), never pushed to GitHub, so `--max-bag-mb` (rosbag2
splitting) is an optional knob, off by default. `mcap_index.reindex` replaces the bag ATOMICALLY
(`os.replace`); never reintroduce an unlink-then-rename on the record of truth. Row encoders shared
by the live writer and the converter live in `ros2-poc/nodes/_rows.py` (NO ROS imports — the
by the live writer and the converter live in `rig/nodes/_rows.py` (NO ROS imports — the
converter must run without a ROS process; `converted == live` depends on ONE encoder).
- **Connector plugins (LAB-303):** a protocol plugin whose class is `BiasPlugin` → connector `bias`,
`ScanImagePlugin` → `scanimage` (`connectorPluginNames(exp)` in the Studio). The runner turns their
Expand Down
Loading
Loading