A lightweight way to keep tabs on a fleet of CircuitPython boards. Each board checks in periodically with a small central server, reporting who it is, what firmware it's running, and a few health metrics. A single dashboard then shows the whole fleet at a glance — which boards are healthy, which have gone quiet, what CircuitPython and codebase versions are deployed where, and how much memory and battery each one has left.
Once you're past a handful of boards — sensors scattered around the house, a mix of mains-powered and battery deep-sleep nodes, boards on different codebases — it gets hard to answer simple questions like is everything still reporting? or which boards are still on the old firmware? This tracks that for you: one page, colour-coded by freshness, with per-board history of every version and codebase change.
The server is FastAPI over a single SQLite file — no external database, no message broker. It exposes a small JSON API and serves the dashboard as one self-contained HTML page.
The client is a single file, board_inventory.py, that lives in lib/ on each
board. In your code.py you construct a BoardInventory, call checkin()
once at startup, and then call checkin_if_due() in your main loop. Checkins
are best-effort: any network error is caught and printed, never raised, so the
inventory can never take down your actual application.
Two extras ride along on the checkin channel:
- History is recorded only when something meaningful changes — a CircuitPython upgrade or a codebase deploy — not on every checkin, so the log stays signal and doesn't fill with routine memory/uptime noise.
- Commands can be queued for a board from the dashboard; the board picks
them up on its next checkin. Actions that can take a board offline (
reboot,enter_uf2) are opt-in on both ends (see Commands below).
Each board shows a coloured dot based on how long it's been since its last checkin:
- green — seen within ~15.5 minutes (online)
- yellow — seen within ~1 hour (stale)
- red — not seen in over an hour (offline)
These thresholds are the ONLINE_SECS and STALE_SECS constants near the top
of the <script> block in dashboard.html. Tune them to your fleet's checkin
interval plus a little slack. Battery boards that deep-sleep for long stretches
will naturally sit at yellow between wakes — that's expected, and a quick way
to spot a sleeper that has stopped waking at all (it'll drift to red).
The server ships with a Dockerfile and docker-compose.yml. From the
server/ directory:
docker compose up -d --buildThe dashboard is then at http://<host>:5000/. The SQLite database lives on a
named volume (inventory-data), so it survives rebuilds and restarts. To stop:
docker compose down(Add -v to also delete the data volume.)
Docker is the easy path, but the server is plain FastAPI + SQLite and runs fine
directly under any Python 3.10+. Brief FreeBSD-jail notes — packages, the Rust
build-time dependency, and an rc.d service sketch — are in
server/FREEBSD.md. The short version:
pip install -r requirements.txt
uvicorn app:app --host 0.0.0.0 --port 5000Run it from the directory containing app.py and dashboard.html (the server
reads the dashboard from beside itself). The database is created next to
app.py unless you set BOARD_INVENTORY_DB.
Upgrading an existing install is just replacing the two files and restarting:
on startup the server adds any missing columns (battery, rssi) to your
existing database in place, so older data is preserved.
- Copy
circuitpython/lib/board_inventory.pyinto thelib/folder on the board's CIRCUITPY drive. - Copy
circuitpython/settings.toml.exampletosettings.tomland fill in your Wi-Fi credentials (and, optionally, a friendly hostname) assuming you don't already have a settings.toml. - Set
DEFAULT_SERVER_URLnear the top ofboard_inventory.pyto your server's address — or override it per board withBOARD_INVENTORY_URLinsettings.toml. - Use
circuitpython/code.pyas a starting point.
Minimal code.py:
import time
from board_inventory import BoardInventory
CODEBASE = "common_sensor"
VERSION = "2.1.0"
inventory = BoardInventory(
codebase_name=CODEBASE,
codebase_version=VERSION,
description="HDC302x temp/humidity, garage west wall",
aio_group_key="garage-env",
)
inventory.checkin()
while True:
# ... your application work ...
inventory.checkin_if_due()
time.sleep(60)| Field | Source |
|---|---|
chip_id |
microcontroller.cpu.uid (the primary key; always sent) |
board_type |
os.uname().machine, shortened for display |
hostname |
explicit, else a settings.toml key, else chip_id |
cp_version |
os.uname().version |
ip_address |
wifi.radio.ipv4_address |
codebase_name / codebase_version |
your CODEBASE / VERSION constants |
description |
free text you set at construction |
aio_group_key |
Adafruit IO group, if you use one |
uptime_seconds |
time.monotonic() — time since power-on |
free_memory |
gc.mem_free() after a collect |
rssi |
running-average Wi-Fi RSSI in dBm |
battery |
free text you set (see below) |
battery is deliberately free-form text, so report whatever suits the board —
"87%", "4.02V", or "87% / 4.02V". It's read fresh at each checkin, so set
it in your loop before the checkin fires:
inventory.battery = f"{read_battery_pct()}%"
inventory.checkin_if_due()How you read the battery is board-specific (a fuel-gauge breakout, a raw ADC
voltage divider, or nothing at all), so it stays in your code.py. Boards
without a battery simply never set the attribute and show - on the dashboard.
Battery percentage is color coded by dashboard.html see the constants at the
beginning of the script section:
- green — 30-100%
- yellow — 15-29%
- red — below 15%
To report something other than battery, pass any extra keyword to the report
and it's stored server-side and shown on the board's detail view — no schema or
library change needed. Anything the server doesn't recognise is kept in a JSON
extra field.
The dashboard can queue a command for a board; the board receives it on its next checkin and passes it to a handler you supply:
def handle_command(cmd):
if cmd.get("action") == "set_debug":
set_debug(cmd.get("value"))
inventory = BoardInventory(..., command_handler=handle_command)reboot and enter_uf2 can take a board offline, so they're filtered out
before they ever reach your handler unless you opt in with
allow_unsafe_commands=True. The dashboard mirrors this: those two actions are
hidden from the command menu unless you flip ALLOW_UNSAFE_COMMANDS to true
near the top of dashboard.html. This opt-in also means a stray or repeated
reboot command can't cycle a board you didn't mean to touch.
| Method | Path | Purpose |
|---|---|---|
POST |
/api/checkin |
Board reports status; response carries any queued commands |
GET |
/api/boards |
JSON list of all boards |
GET |
/api/boards/{chip_id} |
One board's detail plus recent history |
DELETE |
/api/boards/{chip_id} |
Remove a board and all its history |
GET |
/api/history/{chip_id} |
Version/codebase change history |
POST |
/api/command/{chip_id} |
Queue a command, e.g. {"action": "set_debug", "value": true} |
GET |
/api/commands/{chip_id} |
Recent commands for a board |
GET |
/ |
Dashboard UI |
Server — BOARD_INVENTORY_DB sets the SQLite file path (the Docker image
points it at the /data volume; otherwise it defaults beside app.py).
Client settings.toml keys, all optional:
BOARD_INVENTORY_URL— override the server URL for this boardBOARD_INVENTORY_HOSTNAME— friendly name on the dashboardCIRCUITPY_WIFI_HOSTNAME/CIRCUITPY_WEB_INSTANCE_NAME— used as hostname fallbacks beforechip_id
Client constructor options include checkin_interval (default 300s),
request_timeout, allow_unsafe_commands, and socket_pool / ssl_context /
session for sharing one connection manager with other network code on the
board. See the docstring at the top of board_inventory.py for the full list.
There's no authentication on the API or dashboard — anyone who can reach the port can view the fleet, queue commands, and delete boards. It's built for a trusted LAN. Don't expose it directly to the internet; put it behind a VPN, a reverse proxy with auth, or a firewall rule if you need remote access.
GRGrant_CircuitPython_board_inventory/
├── server/
│ ├── app.py FastAPI + SQLite server
│ ├── dashboard.html single-page dashboard
│ ├── requirements.txt
│ ├── Dockerfile
│ ├── docker-compose.yml
│ └── FREEBSD.md native FreeBSD-jail notes
├── circuitpython/
│ ├── lib/
│ │ └── board_inventory.py the client library (copy to lib/)
│ ├── code.py example usage
│ └── settings.toml.example
├── LICENSE
└── README.md
MIT — see LICENSE.