Self-assembled plant live-imaging chamber: Raspberry Pi 5 (or 4) + touchscreen + SK6812 RGBW NeoPixel strips + IR LED boards + PiNoIR v3 camera. Captures controlled-illumination timelapses of plant movement (gravitropism / phototropism).
This is a ground-up rewrite of the original Buster/Pi-3 software (lamewarden/RaPiD-boxes-software): a modern, reliable single-process backend and a touch-screen React UI, targeting current Raspberry Pi OS (Bookworm).
back/ Python FastAPI backend (hardware control + experiment engine + API)
front/ React + Vite + Tailwind touchscreen UI
deploy/ One-shot installer, systemd service, Chromium kiosk autostart
A single Python process (FastAPI via uvicorn, managed by systemd) serves the
built React UI and the REST API and a WebSocket live-status feed and an
MJPEG camera preview — all on localhost. Chromium runs full-screen in kiosk
mode pointing at it, replacing the old Tkinter UI. No Node.js runs on the device
(it's only used to build the UI). See back/README.md and the project plan for
detail.
# Terminal 1 — backend in simulation mode
cd back
python3 -m venv .venv && . .venv/bin/activate
pip install -e ".[dev]"
RAPIDBOXES_SIMULATION=1 python -m rapidboxes # :8000
# Terminal 2 — UI dev server (proxies /api to :8000)
cd front/plant-imaging-controller-faa-main
npm install && npm run dev # open http://localhost:8080The simulated camera produces annotated frames, so you can drive a whole experiment (start → live progress → gallery) without hardware.
git clone <this repo> ~/RapiDBoxes && cd ~/RapiDBoxes
deploy/install.sh
sudo rebootinstall.sh enables SPI + the camera, installs deps, builds the UI, and registers
the rapidboxes service + kiosk autostart. After reboot the touchscreen shows the
app. Update later with deploy/update.sh; remove with deploy/uninstall.sh.
For a clean SD-card install guide from a pristine Raspberry Pi OS image, see INSTALL.md.
The software assumes the following default Raspberry Pi BCM pin mapping.
| Light source / device | Default connection | Used for | Notes |
|---|---|---|---|
RGBW LED strip data input (DIN) |
GPIO10 (SPI0 MOSI) |
All visible white / colour illumination | This is the current supported wiring for both Pi 4 and Pi 5. |
| Legacy RGBW data input | GPIO18 | Old PWM/DMA rpi_ws281x path |
Legacy only; no longer the recommended wiring, especially on Pi 5. |
| IR LED board 1 control | GPIO26 | Dark-phase / night capture illumination | Controlled as a simple on/off output. |
| IR LED board 2 control | GPIO23 | Dark-phase / night capture illumination | Controlled in parallel with GPIO26. |
| Camera | CSI ribbon connector | Image capture + live preview | PiNoIR v3 / Camera Module 3 expected. |
Important hardware notes:
- RGBW strip data now belongs on GPIO10, not GPIO18. The rewrite moved the strip to SPI because the old PWM/DMA path is not portable to Pi 5.
- The installer enables SPI automatically.
- The RGBW data line should be driven through a 3.3V to 5V level shifter when feeding a 5V SK6812 / NeoPixel strip.
- The strip is handled as one logical RGBW device in software, but the default
settings split it into two functional lighting zones:
lateralSegment = (0, 21)for the side / bending lighttopSegment = (22, 64)for the top day / white light
- Default LED settings also assume
pixelCount = 70andpixelOrder = GRBW. - Default IR settings use the two legacy control pins
[26, 23]. - These defaults are persisted in
settings.jsonand exposed byGET/PUT /api/settings.
deploy/idle.sh blanks the touchscreen after 5 minutes with no touch, and
turns it back on the instant it's touched — screen power only, never system
sleep or suspend. The backend and any running experiment keep running exactly
as before either way; only the panel's own power state changes.
Built on swayidle (touch-as-activity detection) driving wlopm (Wayland
output power) against the DSI panel, both installed by deploy/install.sh and
autostarted alongside the kiosk. Change the timeout with the
RAPIDBOXES_IDLE_TIMEOUT environment variable (seconds) in
~/.config/autostart/rapidboxes-idle.desktop; the output name (default
DSI-1) is configurable the same way via RAPIDBOXES_DISPLAY_OUTPUT if you're
on a different panel — check yours with wlr-randr.
The box can update itself over the air from origin/<RAPIDBOXES_UPDATE_BRANCH>
(default main), git-only and fast-forward-only — it never rewrites history or
force-resets. An update (manual or automatic) is refused outright, cleanly and
without side effects, if the working tree has local changes, if the branch has
diverged such that a fast-forward is impossible, or if an experiment is
currently running/paused/finishing.
Manual: Settings → General → Software Update card. Check for
Updates only fetches and compares — it never touches the working tree.
Update Now does the actual git fetch + fast-forward merge, then rebuilds
only what changed (pip install if anything under back/ changed, npm install && npm run build if anything under front/ changed), then prompts
Restart now? to apply it.
Automatic: rapidboxes-update.timer runs the same logic unattended once a
month (3am on the 1st, Persistent=true so a box that's off at that moment
catches up on next boot). With no user present to confirm a restart, it
restarts on its own — but only if the pull and the rebuild both succeeded;
if the rebuild fails, it deliberately leaves the old, still-consistent build
running rather than restart into mismatched code and dependencies (check
journalctl -u rapidboxes-update if that happens).
Version card: the same Settings → General panel shows the commit the box
is currently running and how long it's been running it. Every successful
update (manual or automatic) is recorded, so once at least one update has
happened, a Roll back to <hash> button appears — this checks out the
previous recorded commit (git checkout --detach, not git reset --hard, so
the tracked branch pointer is left alone) and re-runs the same rebuild step.
Rollback is not permanent protection: if the box still auto-tracks the branch
you rolled back from, the next monthly check (or a manually-pressed "Update
Now") can land back on that same commit once the branch has moved past it
again — the UI flags this when you roll back.
Settings → General also shows what you need to reach the box over SSH: the
account name (the same user rapidboxes.service runs as), whether sshd is
currently active, the box's LAN IP, and the exact command to run —
ssh <user>@<ip>. Handy since the box normally only has a touchscreen
attached, not a keyboard.
The box can mount an institutional SMB/CIFS share and copy experiment images to
it automatically as they are captured, replacing the legacy hand-run
sudo mount -t cifs //ds.asuch.cas.cz/ueb/lhr /mnt/Shared -o user=…,pass=….
Settings → General → Remote Sync card:
- On/off toggle: arms syncing for the researcher currently set on the home screen.
- Server / share: pre-filled with
//ds.asuch.cas.cz/ueb/lhrand freely editable. The value is checked against a strict allowlist (//host/share[/folder], letters, digits, dots, hyphens and underscores only) before it can reach the mount command; anything else is rejected with a clear message. - Username and Password for the share account. The password field is masked, and when a password is already set the field shows a fixed-width placeholder — the UI is never told the real password or its length.
- Check Connection: enabled only once a username and password are both present. It mounts the share (if it isn't already), proves the destination folder is actually writable, and reports the real error text if not — "wrong password" and "host unreachable" need different fixes.
- Sync Entire Folder: a one-shot bulk copy of every local experiment belonging to the current researcher, not just newly captured images.
- Status: mounted / not mounted / credentials needed, the destination path, the last successful sync time, and a count of files still waiting to be copied.
Remote layout mirrors the legacy convention: the mounted share, then a subfolder named after the researcher (created if missing), then one folder per experiment:
/mnt/rapidboxes-remote/<researcher>/<YYYY-MM-DD>_<researcher>_<name>/
dark_00000.jpg
metadata.json
<name>.xml
Thumbnails are not copied — they are regenerated locally on demand.
Sync stops when the toggle is switched off, or when the researcher name changes (starting an experiment under a different name switches sync off and says so, rather than quietly writing into someone else's folder).
The local experiment always wins. Copying happens on a background queue, never on the capture path, so a slow, hung or dead share cannot delay the capture schedule. A failed copy is logged, counted as pending, and retried on the next capture; it never aborts or errors a running experiment.
This is a design decision, not an oversight: the password is held in memory for
the lifetime of the backend process and is written nowhere — not to
settings.json, not to remote_sync.json, not to logs, and not to any
credentials file that outlives the mount call itself. It is also never returned
by any API endpoint (this box has no authentication and binds 0.0.0.0, so
anything it serves is readable by anyone on the LAN); the API exposes only a
passwordSet boolean.
The operational consequence: after any restart the password is gone and must
be re-entered. That includes a reboot, a power blip, and the monthly
rapidboxes-update.timer OTA restart. In that state sync does not quietly
pretend to work — the Remote Sync card turns orange and reads "Inactive —
credentials needed after restart" until someone re-enters the password and
presses Check Connection. The same tradeoff is stated as helper text next to
the password field and confirmed by a toast when credentials are accepted, so
it is known before anyone leaves the box on a long unattended run. Server,
username and the on/off setting all persist normally; only the password does
not.
Mounting needs root, so deploy/install.sh installs
/etc/sudoers.d/rapidboxes (mode 0440, validated with visudo -c before
installation — a malformed sudoers file can lock the account out of sudo
entirely). It grants the service account exactly two commands and nothing else:
Cmnd_Alias RAPIDBOXES_CIFS = \
/usr/bin/mount -t cifs //* /mnt/rapidboxes-remote -o credentials=/run/rapidboxes-cifs/cred-*\,nosuid\,nodev\,noexec\,uid=1000\,gid=1000\,file_mode=0664\,dir_mode=0775, \
/usr/bin/umount /mnt/rapidboxes-remote
<user> ALL=(root) NOPASSWD: RAPIDBOXES_CIFS
That is: one fixed mount point, one fixed trailing option string, and no
blanket ALL. The hardening options (nosuid,nodev,noexec and the
unprivileged uid/gid) come last on purpose — mount options are last-one-wins.
deploy/uninstall.sh removes the rule, the mount point and the mount.
The password reaches mount through a credentials= file created 0600 with
tempfile.mkstemp in the service's private /run/rapidboxes-cifs directory
(systemd RuntimeDirectory=, on tmpfs), and that file is unlinked in a
finally the instant mount returns, success or failure. It is never passed
as -o pass=…, because ps aux is world-readable. Every subprocess call uses
a fixed argument list; shell=True is never used anywhere in the backend, and
a test enforces that.
Simulation mode (RAPIDBOXES_SIMULATION=1, i.e. laptop development) never
attempts a real mount: the share is emulated by a local directory so the whole
sync path stays exercisable with no CIFS server present.
The Tropism workflow models the legacy three-stage protocol:
- dark "apical hook" phase with IR-lit image capture
- bending phase with lateral coloured light between captures
The user configures dark-phase duration, light-phase duration, spectra, interval between captures, and visible-light intensity.
The Growth workflow runs a repeating day/night photoperiod:
- one baseline photo at the start of the run
- top-down day lighting for
dayLengthHours - dark / night period for the remainder of the day
- repeated over
experimentLengthDays
Growth always uses the top LED segment for visible lighting. Night captures can use either:
- IR photo illumination for dark capture
- RGBW photo illumination for low visible-light capture
Each experiment writes images plus structured metadata into:
{storage_root}/{YYYY-MM-DD}_{username}_{experimentName}/
By default that storage root is:
~/rapidboxes/experiments
The touchscreen UI is organized around a persistent top navigation bar and a set of full-screen task views.
The home screen contains two large program buttons:
- Growth Program: opens the Growth configuration screen.
- Tropism Program: opens the Tropism configuration screen.
The home screen also shows the top navigation bar, which is reused on the program configuration screens.
From left to right:
- Close: calls
/api/system/close-kioskand closes the Chromium kiosk window. This does not stop the backend service. - Import: opens the import menu and lets the user load a previous experiment configuration.
- Downloads: opens the downloads menu and lets the user grab a ZIP of their own past experiment folders (images + metadata + saved config).
- User: opens the on-screen keyboard to change the saved researcher name.
- Gallery: opens the image gallery.
- Live: opens the live camera preview.
- If the camera is currently missing, the button switches into a re-check action.
- Tapping it triggers
/api/system/recheck-camera; if a camera is found, the live preview opens.
- Settings: opens the settings menu.
The import menu lists previous experiments from history.
- Tapping any row loads the saved phase / light configuration.
- Camera settings from the saved experiment are also pushed back into the current device settings.
- Import is protocol-aware:
- a Growth config opens the Growth screen
- a Tropism config opens the Tropism screen
- The X button closes the import menu without loading anything.
The downloads menu lists previous experiments from history, filtered to the
ones whose saved username matches the current researcher name (the same
name shown on the User button, and the same one baked into each
experiment's folder name). This box has no login/auth, so the filter is a
convenience for finding your own runs quickly, not an access-control
boundary — any experiment folder is reachable by anyone on the LAN who knows
its ID.
- Each row shows the experiment name, start date, and image count, plus a Download ZIP button.
- Tapping Download ZIP downloads a
.zipof that experiment's entire folder — every captured image,metadata.json, and the saved.xmlprotocol config — viaGET /api/experiments/{id}/download. The browser saves it like any other file download; no separate tool (SSH, Samba, etc.) is required. - The X button closes the downloads menu.
The settings menu has two tabs:
- Camera: opens the full camera settings panel.
- General: system info (hostname, version, disk space), LED strip segment editor, IR pin display, remote CIFS sync configuration, software update / rollback controls, and SSH access info. See Software updates & version rollback, SSH access and Remote storage sync below.
The X button closes the settings menu.
The camera panel is the main operator menu for session camera tuning.
Controls:
- Resolution: Full / Half / Quarter sensor modes.
- Color Mode: Grayscale or Color capture.
- JPEG Quality: compression quality slider.
- Exposure: capture exposure time.
- ISO: sensor gain.
- Settle Time: delay before capture after changing light state.
- AWB Red Gain and AWB Blue Gain: manual white-balance gains.
- Test Photo: captures one preview frame with the currently edited settings.
- 2x: captures a test photo and opens it zoomed in.
- Default: restores the built-in camera defaults in the editor.
- Save: writes the current camera settings into the active device settings.
Behavior notes:
- If an experiment is running or paused, camera settings become read-only.
- Camera settings reset to the backend defaults at process start; they are intentionally session-scoped, even if they were saved earlier.
Both program screens include two large tabs:
- Growth Program: switches to the Growth configuration screen.
- Tropism Program: switches to the Tropism configuration screen.
These are navigation tabs, not start buttons.
The Growth screen configures day/night photoperiod experiments.
Controls:
- Day Length: sets the lit portion of the day, in hours.
- Experiment Length (Days): total run length in days.
- Day Spectrum: chooses one or more visible spectra (
white,red,green,blue). - Interval Between Images (MIN): capture cadence.
- Light Intensity: visible day-light intensity.
- Photo Illumination:
- IR (Dark) uses the IR boards for night captures.
- RGBW (White @10%, Top) uses the top RGBW segment for night captures.
- Test Photo: preview a night capture with the selected illumination source.
- Start Experiment: validates camera availability and starts a Growth run.
- Experiment name button (tag icon): opens the on-screen keyboard to edit the saved experiment name.
- Reset button (rotate icon): restores the default Growth settings.
Behavior notes:
- If the camera is disconnected, the start button first tries a camera re-check.
- Imported Growth configs restore both Growth-specific parameters and saved camera settings.
The Tropism screen configures apical-hook / bending experiments.
Controls:
- Dark Phase checkbox: enables or disables the dark apical-hook stage.
- Dark Phase slider: sets dark-phase duration when enabled.
- Light Phase Length (h): sets the bending / lateral illumination phase duration.
- Day Spectrum: chooses one or more visible spectra for the bending light.
- Interval Between Images (MIN): capture cadence.
- Light Intensity: visible-light intensity during the bending phase.
- Start Experiment: validates camera availability and starts a Tropism run.
- Experiment name button (tag icon): opens the on-screen keyboard to edit the saved experiment name.
- Reset button (rotate icon): restores the default Tropism settings.
Behavior notes:
- Imported Tropism configs restore both Tropism-specific parameters and saved camera settings.
- The Tropism bending phase uses the lateral LED segment, not the top segment.
The live preview screen shows the backend MJPEG stream from /api/preview.
- Close: returns to the page the user came from.
This is intended as a framing / sanity-check tool, not a capture workflow by itself.
Growth and Tropism each have their own progress screen, but the controls behave the same way.
Displayed information:
- live/reconnecting connection indicator
- progress bar
- last captured image
- elapsed time
- captured image count vs planned image count
- next capture countdown (when a capture is scheduled)
- current phase label
- current day counter on Growth runs
Buttons:
- Close in the top bar: returns to the home screen without sending stop.
- Pause: pauses the active experiment.
- Resume: resumes a paused experiment.
- Stop: requests experiment stop and opens the summary screen.
- Summary: appears when the run is no longer active and opens the summary screen.
When the backend reports done, the UI automatically navigates to the summary screen.
The finish screen summarizes the just-completed experiment.
Displayed information:
- total elapsed time
- total frames captured
- first frame
- last frame
- final storage path on disk
Button:
- Close: requests
/api/system/restart-service, waits for the backend to come back up, and then returns the kiosk to the home screen.
This is intentionally different from the top-nav Close button. On the summary screen, Close performs a full backend restart so the kiosk returns to a clean state.
The gallery screen lists the current image set returned by /api/images.
- Close: returns to the home screen.
- Header text: shows the active experiment id and current image count.
- Thumbnail tap: opens the full-size image in an overlay.
- The gallery auto-refreshes every 5 seconds.
Compared with the old single-purpose UI flow, the current system now includes:
- a dedicated Growth program alongside Tropism
- protocol-aware import of previous experiment configurations
- a dedicated camera settings panel with test-photo support
- a live preview page
- an in-app gallery
- a measurement finished screen with first/last frame preview and storage path
- a restart-based summary close flow that returns the kiosk to the home screen
- over-the-air self-update (manual button + monthly unattended timer), fast-forward-only and refused cleanly on any risk of data loss
- one-click rollback to the previously-running version, with how-long-it-ran tracked automatically
- an in-app SSH access panel (username, status, IP, ready-to-run command)
- remote CIFS/SMB sync: images copied to an institutional share as they are captured, plus a one-shot bulk copy of a researcher's whole back catalogue — off the capture path, so a dead share can never stall or fail a running experiment. The share password is session-only and never written to disk, and the UI says so plainly both while it is typed and after a restart clears it.
- a downloads menu for grabbing a ZIP of your own experiment files (images + metadata + config) straight from the browser, no SSH/Samba needed