feat(uno-q): expose participant files to the agent + document mux scan

- Mount each student's own App Lab files into the app container so the
  agent can read/fix them: mount-user-workspace.sh binds ~/sketches (rw),
  ~/ArduinoApps/* (rw), ~/Arduino/libraries (ro) under /app/workspace via
  a root oneshot ordered before arduino-app-cli.service (the /app bind is
  rprivate, so binds must precede container start; App Lab has no app.yaml
  volumes field). Wired into provision-node-app, provision-fleet (systemd,
  best-effort sudo), and package-onboard-app (bundled under host-setup/).
- uno-q-hardware skill: document the mux-aware i2c_scan output format
  (0x70:mux, 0x70.2=0x1d) and tell the agent its student's files live at
  /app/workspace. See USER-WORKSPACE.md.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Omar Sobh
2026-07-23 16:35:17 -07:00
co-authored by Claude Opus 4.8
parent a9a5176f7c
commit 0156f97b36
7 changed files with 263 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
# Exposing participant files to the agent (`/app/workspace`)
Goal: let the containerized agent **read, correct, and complete a student's own
Arduino code**. By default it can't — the App Lab container only mounts its own app
directory. This wires the student's files in.
## Where participants store their files (Uno Q, via App Lab)
| Location on the board | What it holds | Created when… |
|---|---|---|
| `~/sketches/<name>/<name>.ino` | **Sketches** from App Lab's sketch editor — the primary place implementations live | student opens the sketch editor and saves |
| `~/ArduinoApps/<name>/` | Full **App Lab apps** (`app.yaml` + `python/` + `sketch/` + `web/`) | student runs *New App* |
| `~/Arduino/libraries/` | Installed Arduino **libraries** | library manager / `arduino-cli lib install` |
(Our own node is `~/ArduinoApps/apess-onboard` — excluded from the mount to avoid a
recursive self-mount.)
## Why a bind-mount, and why at boot
- App Lab's `app.yaml` has **no `volumes` field**; it generates the compose itself
(`.cache/app-compose.yaml`) with a fixed hardware mount profile — no injection point.
- The container's `/app` bind is **`rprivate`**, so a host submount added *after* the
container starts does **not** propagate in. The binds must exist **before** the app
container is created.
- `mount(2)` is privileged → this runs as **root at boot, ordered before
`arduino-app-cli.service`** (the App Lab daemon that launches the default app).
Result inside the container:
```
/app/workspace/sketches/ <- ~/sketches (rw)
/app/workspace/apps/<name>/ <- ~/ArduinoApps/<name> (rw, minus apess-onboard)
/app/workspace/libraries/ <- ~/Arduino/libraries (ro)
```
The agent is told about this in the `uno-q-hardware` skill ("The student's own files").
## Install (on the board, once — needs root)
```sh
# copy the mount script + unit onto the board
adb push deploy/uno-q/mount-user-workspace.sh /home/arduino/mount-user-workspace.sh
adb shell 'chmod +x /home/arduino/mount-user-workspace.sh'
adb push deploy/uno-q/systemd/apess-user-workspace.service /tmp/apess-user-workspace.service
adb shell 'sudo install /tmp/apess-user-workspace.service /etc/systemd/system/ \
&& sudo systemctl daemon-reload \
&& sudo systemctl enable --now apess-user-workspace.service'
# the binds only reach the ALREADY-running container after it is recreated
# (rprivate), so restart the app once:
adb shell 'arduino-app-cli app restart /home/arduino/ArduinoApps/apess-onboard'
# verify
adb shell 'docker exec apess-onboard-main-1 ls -la /app/workspace/sketches'
```
After this it survives reboots (the unit runs before the app each boot).
## Live vs. restart
- **New sketches** (`~/sketches/...`) appear **live** — they're files inside the
single `~/sketches` bind, not new mounts. No restart needed.
- A **new sibling App Lab app** is a new mount → re-run the script and restart the
app: `sudo /home/arduino/mount-user-workspace.sh && arduino-app-cli app restart …`.
## Wired into provisioning
- **`provision-fleet.sh`** (`MODE=systemd`) — pushes the script + unit, `sudo -n`
installs/enables it, and restarts the app, per board. Falls back to a staged-file
message if root isn't available (mount can't run cron-only).
- **`provision-node-app.sh`** — same, for a single dev board (targets whichever app
it provisions via `.apess-workspace.env` → `APP_DIR`).
- **`package-onboard-app.sh`** — bundles `mount-user-workspace.sh` +
`apess-user-workspace.service` into the app under `host-setup/` (plus this doc as
`host-setup/README.md`), since App Lab self-import can't run root steps. The
epilogue prints the one-time enable command for imported boards.
The unit reads `APP_DIR` from `/home/arduino/.apess-workspace.env` (default
`…/apess-onboard`), so the same unit works for both the distributable and the dev app.
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Expose each participant's own Arduino files INTO the apess-onboard app container
# so the agent can read, correct, and complete their implementation.
#
# WHY THIS EXISTS
# The App Lab container only bind-mounts the app's own directory
# (/home/arduino/ArduinoApps/apess-onboard -> /app). A student's real work lives
# elsewhere and is invisible to the agent:
# ~/sketches/<name>/<name>.ino App Lab "sketch editor" projects (primary)
# ~/ArduinoApps/<name>/ full App Lab apps
# ~/Arduino/libraries/ installed libraries
# App Lab has NO volumes field in app.yaml and generates the compose itself, so
# we can't declare these there. Instead we bind the user paths UNDERNEATH the app
# dir (which /app already maps). The catch: the /app bind is `rprivate`, so a
# submount added AFTER the container starts does NOT propagate in — the binds must
# exist BEFORE the app container is created. Hence a root oneshot ordered
# Before=arduino-app-cli.service (see apess-user-workspace.service).
#
# RESULT INSIDE THE CONTAINER
# /app/workspace/sketches/ <- ~/sketches (rw)
# /app/workspace/apps/<name>/ <- ~/ArduinoApps/<name> (rw, minus ourselves)
# /app/workspace/libraries/ <- ~/Arduino/libraries (ro, reference)
#
# Idempotent — safe to re-run. Requires root (mount(2) is privileged).
set -euo pipefail
USER_HOME=${USER_HOME:-/home/arduino}
APP_DIR=${APP_DIR:-$USER_HOME/ArduinoApps/apess-onboard}
WS="$APP_DIR/workspace"
SELF=$(basename "$APP_DIR")
if [ "$(id -u)" -ne 0 ]; then
echo "must run as root (mount is privileged) — try: sudo $0" >&2
exit 1
fi
bind() { # src dst [ro]
local src=$1 dst=$2 ro=${3:-}
if [ ! -d "$src" ]; then echo "skip (no source dir): $src"; return 0; fi
mkdir -p "$dst"
if mountpoint -q "$dst"; then echo "already mounted: $dst"; return 0; fi
mount --bind "$src" "$dst"
[ "$ro" = ro ] && mount -o remount,ro,bind "$dst"
echo "mounted: $src -> $dst${ro:+ (ro)}"
}
mkdir -p "$WS" "$WS/apps"
chown "$(stat -c '%u:%g' "$USER_HOME")" "$WS" "$WS/apps" 2>/dev/null || true
bind "$USER_HOME/sketches" "$WS/sketches"
bind "$USER_HOME/Arduino/libraries" "$WS/libraries" ro
# Each OTHER App Lab app — skip ourselves so we don't recursively self-mount.
if [ -d "$USER_HOME/ArduinoApps" ]; then
for d in "$USER_HOME/ArduinoApps"/*/; do
[ -d "$d" ] || continue
name=$(basename "$d")
[ "$name" = "$SELF" ] && continue
bind "$d" "$WS/apps/$name"
done
fi
echo "workspace ready: $WS"
echo "note: a NEW sibling app created after boot needs a re-run + app restart to appear"
echo " (rprivate /app); new *sketches* in ~/sketches appear live, no restart needed."
+20
View File
@@ -58,6 +58,17 @@ ok "config (single 'default' agent, matrix + i2c_scan)"
cp -r "$HERE/skills" "$OUT/.zeroclaw/shared/skills" cp -r "$HERE/skills" "$OUT/.zeroclaw/shared/skills"
ok "skills ($(ls "$HERE/skills" | wc -l | tr -d ' ') bundles)" ok "skills ($(ls "$HERE/skills" | wc -l | tr -d ' ') bundles)"
# Host-setup helpers that CAN'T ride the container: the participant-workspace
# bind-mount (root, before app start) that exposes ~/sketches + ~/ArduinoApps +
# libraries at /app/workspace so the agent can fix student code. App Lab import
# can't run these (no root), so they travel in host-setup/ for a one-time enable.
mkdir -p "$OUT/host-setup/systemd"
cp "$HERE/mount-user-workspace.sh" "$OUT/host-setup/mount-user-workspace.sh"
cp "$HERE/systemd/apess-user-workspace.service" "$OUT/host-setup/systemd/apess-user-workspace.service"
cp "$HERE/USER-WORKSPACE.md" "$OUT/host-setup/README.md"
chmod +x "$OUT/host-setup/mount-user-workspace.sh"
ok "host-setup/ (participant-workspace mount — enable once per board, needs root)"
# BAKED cloud token (per the workshop decision) — the instructor's Max token, # BAKED cloud token (per the workshop decision) — the instructor's Max token,
# shared across the fleet. Kept in the app bundle only, never in the repo. # shared across the fleet. Kept in the app bundle only, never in the repo.
printf '%s' "$ANTHROPIC_OAUTH_TOKEN" > "$OUT/.zeroclaw/oauth_token" printf '%s' "$ANTHROPIC_OAUTH_TOKEN" > "$OUT/.zeroclaw/oauth_token"
@@ -104,6 +115,15 @@ Distribute it:
Students download it, open App Lab → "Import an app" → pick the zip → Run. Students download it, open App Lab → "Import an app" → pick the zip → Run.
• Instructor smoke-test on a board: • Instructor smoke-test on a board:
arduino-app-cli app import "$ZIP" arduino-app-cli app import "$ZIP"
• Expose participant files to the agent (/app/workspace) — one-time, needs root
(App Lab import can't do this itself). On each board after import:
adb push <app>/host-setup/mount-user-workspace.sh /home/arduino/ && \\
adb shell 'chmod +x /home/arduino/mount-user-workspace.sh' && \\
adb push <app>/host-setup/systemd/apess-user-workspace.service /tmp/ && \\
adb shell 'sudo install /tmp/apess-user-workspace.service /etc/systemd/system/ \\
&& sudo systemctl enable --now apess-user-workspace.service \\
&& arduino-app-cli app restart /home/arduino/ArduinoApps/apess-onboard'
Fleet boards: provision-fleet.sh does this automatically (MODE=systemd). See host-setup/README.md.
• APESS_URL: default is mDNS apess-api.local. Set per team by editing • APESS_URL: default is mDNS apess-api.local. Set per team by editing
.zeroclaw/apess-node.env before packaging, or pass APESS_URL=http://<laptop>:3000. .zeroclaw/apess-node.env before packaging, or pass APESS_URL=http://<laptop>:3000.
EOF EOF
+17
View File
@@ -77,6 +77,23 @@ provision() { # kit serial -> 0 ok / 1 fail
echo " ok — modalities (reload-watcher up; lockdown staged)" echo " ok — modalities (reload-watcher up; lockdown staged)"
fi fi
# participant workspace — bind ~/sketches + ~/ArduinoApps + ~/Arduino/libraries
# into the app container (/app/workspace) so the agent can read/fix student code.
# mount(2) is root-only and can't fall back to cron, so this is systemd-only,
# best-effort. See USER-WORKSPACE.md.
if [ -r "$HERE/mount-user-workspace.sh" ]; then
adb -s "$serial" push "$HERE/mount-user-workspace.sh" /home/arduino/ >/dev/null 2>&1
adb -s "$serial" shell 'chmod +x /home/arduino/mount-user-workspace.sh' >/dev/null 2>&1
adb -s "$serial" push "$HERE/systemd/apess-user-workspace.service" /tmp/ >/dev/null 2>&1
if adb -s "$serial" shell 'sudo -n cp /tmp/apess-user-workspace.service /etc/systemd/system/ \
&& sudo -n systemctl daemon-reload && sudo -n systemctl enable --now apess-user-workspace.service' >/dev/null 2>&1; then
adb -s "$serial" shell 'TMPDIR=/tmp arduino-app-cli app restart /home/arduino/ArduinoApps/apess-onboard >/dev/null 2>&1 || true' >/dev/null 2>&1
echo " ok — participant workspace mounted (/app/workspace)"
else
echo " ! workspace mount needs root (sudo -n failed) — staged; enable apess-user-workspace.service on the board"
fi
fi
# default skills — install every bundled skill (the comprehensive arduino-uno-q # default skills — install every bundled skill (the comprehensive arduino-uno-q
# reference + the fork's granular set) into every agent's workspace, so each # reference + the fork's granular set) into every agent's workspace, so each
# node has them by default. Best-effort. # node has them by default. Best-effort.
+24
View File
@@ -10,11 +10,13 @@
# #
# Env: SERIAL (65301572), NODE_APP_DIR (repo app dir), plus the node-env vars above. # Env: SERIAL (65301572), NODE_APP_DIR (repo app dir), plus the node-env vars above.
set -u set -u
HERE="$(cd "$(dirname "$0")" && pwd)"
SERIAL="${SERIAL:-65301572}" SERIAL="${SERIAL:-65301572}"
NODE_APP_DIR="${NODE_APP_DIR:-$HOME/projects/zeroclaw/firmware/zeroclaw-node}" NODE_APP_DIR="${NODE_APP_DIR:-$HOME/projects/zeroclaw/firmware/zeroclaw-node}"
DEST=/home/arduino/ArduinoApps/zeroclaw-node DEST=/home/arduino/ArduinoApps/zeroclaw-node
S(){ adb -s "$SERIAL" shell "$@"; } S(){ adb -s "$SERIAL" shell "$@"; }
ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; } ok(){ printf ' \033[32m✓\033[0m %s\n' "$*"; }
warn(){ printf ' \033[33m!\033[0m %s\n' "$*"; }
bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; } bad(){ printf ' \033[31m✗\033[0m %s\n' "$*"; }
adb -s "$SERIAL" get-state >/dev/null 2>&1 || { bad "board $SERIAL not attached"; exit 1; } adb -s "$SERIAL" get-state >/dev/null 2>&1 || { bad "board $SERIAL not attached"; exit 1; }
@@ -91,6 +93,28 @@ S "cd $DEST && TMPDIR=/tmp timeout 300 arduino-app-cli app start $DEST 2>&1 | ta
S 'crontab -l 2>/dev/null | grep -v "zeroclaw-supervisor" | crontab - 2>/dev/null; for p in $(pgrep -f "[z]eroclaw-supervisor"); do kill -9 $p 2>/dev/null; done' S 'crontab -l 2>/dev/null | grep -v "zeroclaw-supervisor" | crontab - 2>/dev/null; for p in $(pgrep -f "[z]eroclaw-supervisor"); do kill -9 $p 2>/dev/null; done'
ok "removed legacy supervisor @reboot cron (App Lab app owns boot now)" ok "removed legacy supervisor @reboot cron (App Lab app owns boot now)"
echo "→ exposing participant files to the agent (/app/workspace)"
# Bind ~/sketches + ~/ArduinoApps + ~/Arduino/libraries under this app dir so the
# agent can read/fix student code. mount(2) is root-only and the App Lab /app bind
# is rprivate (submounts must precede the container), so this is a root oneshot
# ordered before arduino-app-cli.service. See USER-WORKSPACE.md.
adb -s "$SERIAL" push "$HERE/mount-user-workspace.sh" /home/arduino/mount-user-workspace.sh >/dev/null 2>&1
S "chmod +x /home/arduino/mount-user-workspace.sh"
adb -s "$SERIAL" push "$HERE/systemd/apess-user-workspace.service" /tmp/apess-user-workspace.service >/dev/null 2>&1
printf 'APP_DIR=%s\n' "$DEST" | S "cat > /home/arduino/.apess-workspace.env" # this app dir maps to /app
if S 'sudo -n cp /tmp/apess-user-workspace.service /etc/systemd/system/ \
&& sudo -n systemctl daemon-reload \
&& sudo -n systemctl enable --now apess-user-workspace.service' >/dev/null 2>&1; then
# rprivate: the running container must be recreated to pick up the new binds.
S "cd $DEST && TMPDIR=/tmp arduino-app-cli app restart $DEST >/dev/null 2>&1 || true"
ok "workspace mounted → agent sees ~/sketches, ~/ArduinoApps, ~/Arduino/libraries at /app/workspace"
else
warn "workspace mount needs root — sudo unavailable over adb. Files are staged; enable once on the board:"
echo " sudo install /tmp/apess-user-workspace.service /etc/systemd/system/ \\"
echo " && sudo systemctl enable --now apess-user-workspace.service \\"
echo " && arduino-app-cli app restart $DEST"
fi
echo "→ enable Run-at-startup for boot persistence:" echo "→ enable Run-at-startup for boot persistence:"
echo " adb -s $SERIAL shell 'arduino-app-cli properties set default $DEST'" echo " adb -s $SERIAL shell 'arduino-app-cli properties set default $DEST'"
ok "provisioned. In App Lab, open 'ZeroClaw Node' → Run." ok "provisioned. In App Lab, open 'ZeroClaw Node' → Run."
@@ -32,6 +32,42 @@ Sketches always target the MCU (`arduino:zephyr:unoq`).
- `analogRead()` returns 0–1023; volts = `raw * 3.3 / 1023.0`. - `analogRead()` returns 0–1023; volts = `raw * 3.3 / 1023.0`.
- For a 5 V sensor, divide down: 5 V → 10 kΩ → A0 → 20 kΩ → GND. - For a 5 V sensor, divide down: 5 V → 10 kΩ → A0 → 20 kΩ → GND.
## Checking sensors — the `i2c_scan` tool (mux-aware)
To see what's wired to the board's I2C, call **`i2c_scan`**. It probes the MCU's
Arduino Wire bus (Qwiic + I2C headers) — this is where student sensors hang, NOT
Linux `/dev/i2c-*` (those are MPU-side and unreachable from the app container).
The APESS kit hangs its ADXL355s behind a **PCA9548A I2C mux at `0x70`**, and two
sensors can share address `0x1d` on different channels — so the scan walks the mux
too. Read the comma-separated result like this:
- `0x1d` — a device directly on the bus (e.g. a lone ADXL355 wired to Qwiic).
- `0x70:mux` — an I2C mux is present at `0x70`.
- `0x70.2=0x1d` — a device at `0x1d` behind mux `0x70` on **channel 2**.
- `none` — nothing ACKed.
So `0x70:mux,0x70.2=0x1d,0x70.5=0x1d` = the mux plus two ADXL355s, one on channel 2
and one on channel 5. If a student sees only `0x70:mux`, their sensors aren't wired
to the mux channels (or aren't powered) — a mux with nothing behind it. If they see
nothing at all, check power and SDA/SCL. **ADXL355** = `0x1d` (or `0x1e` if ADDR is
pulled high); the FabLab kit reads it at `0x1d`.
## The student's own files — help fix their implementation
The participant's Arduino work is mounted into this container under **`/app/workspace/`**:
- `/app/workspace/sketches/<name>/<name>.ino` — sketches they wrote in App Lab's
sketch editor (this is where most implementations live).
- `/app/workspace/apps/<name>/` — full App Lab apps they built.
- `/app/workspace/libraries/` — installed Arduino libraries (read-only reference).
Read these to review, correct, and complete a student's code when they ask for help
("why doesn't my sensor read?", "fix my sketch"). You can edit files under
`sketches/` and `apps/`; `libraries/` is reference only. If `/app/workspace/` is
empty, the workspace mounts aren't set up on this board yet — say so rather than
guessing at their code.
## On-board LEDs ## On-board LEDs
- RGB LED 1/2 are MPU-owned (`/sys/class/leds/*`, use the `sysfs_led` tool). - RGB LED 1/2 are MPU-owned (`/sys/class/leds/*`, use the `sysfs_led` tool).
@@ -0,0 +1,21 @@
[Unit]
Description=APESS — bind participant workspace into the apess-onboard app container
# The app's /app bind is rprivate, so these submounts must exist BEFORE the App Lab
# daemon starts the default app container. Order strictly before it.
Before=arduino-app-cli.service
After=home-arduino.mount local-fs.target
RequiresMountsFor=/home/arduino
[Service]
Type=oneshot
RemainAfterExit=yes
# Which app dir maps to /app. Default = the distributable apess-onboard; the dev
# provisioner overrides it (to zeroclaw-node) by writing .apess-workspace.env.
Environment=APP_DIR=/home/arduino/ArduinoApps/apess-onboard
EnvironmentFile=-/home/arduino/.apess-workspace.env
ExecStart=/home/arduino/mount-user-workspace.sh
# Clean unmount on stop so the next start rebinds fresh.
ExecStop=/bin/sh -c 'for m in "$APP_DIR"/workspace/sketches "$APP_DIR"/workspace/libraries "$APP_DIR"/workspace/apps/*; do mountpoint -q "$m" && umount "$m" || true; done; exit 0'
[Install]
WantedBy=multi-user.target