This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
sensorbox is an opinionated, self-hosted system for building OpenWrt-based "Orb probe" images that link to an Orb account for persistent-internet monitoring. A user runs the stack locally (target: docker-compose up on macOS/Windows/Linux), picks a device + options in a web UI, and gets an SD-card image with Orb pre-installed and credentials baked in via uci-defaults.
Read GOALS.md first — it is the source of truth for product intent and constraints. Key invariants that must shape design decisions:
- Initial target devices: NanoPi R5C (ethernet + Wi-Fi) and Radxa E20C (ethernet-only). Others come later.
- Opinionated, not configurable. When in doubt, remove a knob rather than add one.
- Devices are ephemeral / "set it and forget it." Reconfiguration = reflash, not SSH. Don't design runtime admin flows.
- Sensitive data (Wi-Fi creds,
ORB_DEPLOYMENT_TOKEN, root password) is baked into images via uci-defaults. This is the reason the stack must run locally — never push these through a public ASU/firmware-selector. - Network defaults: Wi-Fi and ethernet act as DHCP clients (no DHCP server). Wi-Fi is band-agnostic unless the user pins it.
- Orb install reference: https://orb.net/docs/setup-sensor/linux/openwrt — Orb is not in public OpenWrt repos, so ASU is used to bake it in.
sensorbox is licensed MIT. It depends on two upstream OpenWrt projects handled very differently:
- ASU (Attended Sysupgrade Server, https://github.com/openwrt/asu, GPL-2.0). Run from upstream's published image, pinned by digest in
compose.yaml— there is noasu/submodule and no fork. sensorbox previously vendored a fork to obtain custom apk feeds inappendmode, which it needs to bake theorbpackage into 24.10+/25.xx images without losing the stock OpenWrt feeds; that support is upstream now (repositories_mode == "append"inasu/build.py), so the fork is gone. Upstream publishes only a floating:latest, so the image is pinned by digest: this stack bakes credentials into firmware, and build inputs should not shift under a rebuild without a commit recording it. Python/FastAPI + RQ workers + Redis; the worker spawns per-build containers via the Podman API (not Docker — see "Why Podman" below). - Firmware Selector (upstream GitLab: openwrt/web/firmware-selector-openwrt-org). Forked to https://github.com/dboze/firmware-selector-openwrt-org and included as a git submodule at
firmware-selector/. This is where Orb-specific UI code belongs. The submodule has anupstreamremote configured for syncing improvements from OpenWrt. Configurable knobs the Orb UI must expose (per GOALS.md): supported Wi-Fi card, SSID/security/password,ORB_DEPLOYMENT_TOKEN, root password, Wi-Fi band policy (auto / 2.4 / 5 / 6 GHz). Future nice-to-have: pointing the device at the local ASU for periodic security upgrades. GOALS.md— product brief.
Working in the submodule: if you edit anything under firmware-selector/, you're making commits in the fork repo, not in sensorbox. Commit and push inside the submodule first, then come back to sensorbox and commit the updated submodule pointer. git status in sensorbox will show firmware-selector as "modified" until you do.
ASU's build worker imports podman-py and talks to the Podman REST API over a Unix socket to spawn one container per build. Docker's socket speaks a different API — pointing CONTAINER_SOCKET_PATH at /var/run/docker.sock will not work. We committed in GOALS.md to not modifying ASU, so swapping to docker-py is off the table. The stack is Podman-only. On macOS this means podman machine (a Linux VM under the hood); see README.md for setup and the resource bumps that matter for ImageBuilder runs.
Baking the orb package into images. The Orb apk feed lives at https://pkgs.orb.net/stable/openwrt-apk/$ARCH/packages.adb and is signed by orb-apk-ec.pub (EC key). To include Orb in a build, the ASU build request must set:
packagesincluding"orb"repositories = {"orb": "https://pkgs.orb.net/stable/openwrt-apk/<arch>/packages.adb"}repositories_mode = "append"— do not omit this; the default is"replace", which drops the stock OpenWrt feeds and makes every standard package unresolvable.repository_keys = ["<contents of orb-apk-ec.pub>"]REPOSITORY_ALLOW_LISTin ASU's env must include"https://pkgs.orb.net/stable/openwrt-apk/"(already set in.env.example). Empty allow list = ASU denies all custom repos.
This has been validated end-to-end for rockchip/armv8 radxa_e20c on OpenWrt 25.12.2 — orb 1.4.11 landed in the image manifest.
Architecture string for the feed URL is the apk arch (e.g. aarch64_generic), not the OpenWrt target. rockchip/armv8 → aarch64_generic. This is not automatically derivable from the target in the current code path; the client has to send the right URL per target. When the firmware-selector fork learns to send Orb repos by default, it'll need a small arch lookup table.
Legacy info.
- Python project managed with
uv; entrypointasu/main.py(FastAPI). - Runs as a multi-container stack via
podman-compose.yml(server + Podman API + worker + Redis). Builds happen inside containers for isolation — don't try to run ImageBuilder directly on the host. ALLOW_DEFAULTS=1in the ASU env is required for the custom uci-defaults script flow that sensorbox depends on (baking in credentials).- To hack on ASU itself, clone https://github.com/openwrt/asu separately and point
compose.yamlat a local build; there is no in-tree checkout any more. - Custom branches (e.g.
zero2) go throughasu-shim/sitecustomize.py. Upstream ASU never readsBRANCHES_FILE, validates versions/targets/profiles against downloads.openwrt.org, and always pulls the ImageBuilder from ghcr.io. The shim, loaded viaPYTHONPATHin both ASU services, patches those paths in memory sobranches.yamlentries build from openwrt-builder's local image. It reaches into ASU internals (asu.util.reload_*, podman-py'sImagesManager.pull), so after bumping the ASU digest run a real custom-branch build (hwtest/build.py --recipe friendlyarm_nanopi-zero2), not just a stock one.
Because this is GPL-2.0 and we've committed to not modifying it, any Orb-specific behavior should live in sensorbox's own layer (compose file, config, sidecar service, or fork of firmware-selector), not in patches to asu/.
- Plain HTML/CSS/JS under
www/; no build step for the app itself. - Local run:
python3 -m http.serverfrom the subproject root, then openhttp://localhost:8000/www/. - Config lives in
www/config.js. Per-device extra packages live inwww/device_packages.json(seedevice_packages.json.example) — this is the mechanism for attaching the Intel BE200 driver and similar device-specific packages. - uci-defaults scripts authored in the selector land in
/etc/uci-defaults/on the built image and run once on first boot — this is the injection point for Orb deployment token, root password, and Wi-Fi credentials. - Tests:
node --test 'tests/js/*.test.js'(Node 18+), oryarn run test:unit/yarn run test:coverage.
- The end-state is a single
docker-compose/podman-composestack combining ASU + an Orb-flavored firmware selector + any supporting services, runnable cross-platform with one command. ASU upstream shipspodman-compose.yml; the sensorbox compose file will likely wrap or reference it rather than duplicate it. - Orb integration into images has to go through ASU (custom package feed or ImageBuilder inputs) because Orb isn't in public OpenWrt repos.
- Credential injection path: firmware-selector UI form → uci-defaults script → ASU build request → image. Keep this path local-only; never expose it to the public internet without explicit user acknowledgement.
- Device support beyond NanoPi R5C / Radxa E20C may require custom ImageBuilder branches — design the ASU integration so swapping in a forked ImageBuilder per device is straightforward.