Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:
cache: pip

- name: Install dependencies
run: pip install -r requirements.txt
run: pip install -e '.[dev]'

- name: pytest (incl. golden-vector, JS, C++ conformance)
run: python -m pytest -q
Expand Down
171 changes: 13 additions & 158 deletions QUICKSTART.md
Original file line number Diff line number Diff line change
@@ -1,167 +1,22 @@
# Quickstart — production deployment

Base station: Linux, Python 3.12. Boards: Adafruit Feather RP2040 SCORPIO,
one per controller id.

Run every command from the repo root. `python` means your virtualenv's.

## 1. Install

```bash
python -m venv .venv && . .venv/bin/activate
pip install -r requirements.txt
sudo usermod -aG dialout $USER # then log out and back in
```

The group change is not optional — without it every board reads as
"blocked" and nothing can open a port.

Boards get a new device node each time they are flashed. To avoid
re-granting access every time, install
`/etc/udev/rules.d/60-luminary-scorpio.rules`:

```
SUBSYSTEM=="tty", ATTRS{idVendor}=="239a", ATTRS{idProduct}=="8121", MODE="0660", GROUP="dialout", TAG+="uaccess"
SUBSYSTEM=="block", ATTRS{idVendor}=="2e8a", ATTRS{idProduct}=="0003", MODE="0660", GROUP="dialout", TAG+="uaccess"
```

then `sudo udevadm control --reload-rules`.

## 2. Verify the install, no hardware

```bash
python -m pytest
python -m luminary.cli serve --seed-demo
```

Open <http://localhost:8080>, pick `pentagon-4A-33` and a pattern, press
Play. Everything on screen went through the real wire codec. If this works,
the software is good and anything that fails later is hardware.

`pytest` skips the JS and C++ decoder conformance tests when `node` and
`g++` are absent — install them (node **22+**) or those checks silently do
not run.

## 3. Register the boards

Plug the boards in and:

```bash
python -m luminary.cli boards
```

Each board must pass both an Adafruit USB identity check and a protocol
probe, so USB-serial adapters and other devices on the bus are never
mistaken for boards. Results are written to `var/boards.yaml`, keyed by
controller id.

Act on anything that is not a plain board:

| Reported | Means |
|---|---|
| `bootsel` | No usable firmware. Flash it (step 4). |
| `blocked` | Port not openable. The dialout group, step 1. |
| `unresponsive` | Enumerates, does not answer. Re-flash. |
| duplicate ids | Two boards share one id and drive the same lights. Re-flash one. |

`-v` lists every device considered and why it was rejected.

## 4. Flash

```bash
pip install platformio
python -m luminary.cli flash --max-per-strip 180
```

Set `--max-per-strip` to that board's longest strip. Every frame clocks out
that many pixels on all eight outputs whatever the geometry uses, so
overshooting costs frame rate. It is a **per-board** setting: flash 180 only
where every strip on that board is 180 — a 360 strip under a 180 build is
clamped, and half of it stays dark. Measured gain on an all-180 board:
ACK p95 11.8 ms -> 9.9 ms.

A board with working firmware is reset into its bootloader automatically.
A board that has never been flashed will not be: hold **BOOTSEL** while
plugging it in, confirm `boards` shows `bootsel`, then flash.

Flash one board at a time — the bootloader volume is not addressable per
board:
# Quickstart

```bash
python -m luminary.cli flash --controller 3 --max-per-strip 180
./install.sh && . .venv/bin/activate
```

Each flash ends with an identity probe, so "ok" means the board came back
and answered with the id it was built for. Anything else is a failure with
the reason.

Re-run `boards` afterwards to register the new device nodes.

## 5. Map
Then, with the boards on USB:

```bash
python -m luminary.cli map # TUI
python -m luminary.cli map --web # browser instead
python -m luminary.cli map --continue # resume
luminary boards # find, verify, register them
luminary flash # build, flash, confirm each one answers
luminary map # opens a window; arrows + enter, it explains itself
luminary flash # again — now it knows each board's strip length
luminary geometry # mapping records -> real geometry; prints an id
luminary play --lights <id> --pattern aurora
```

Stage A locks each board to its planned position; stage B records channel,
density, and winding per panel. The session explains itself as it goes and
saves one YAML per board under `var/mapping/` after every step, so it is
safe to stop at any point.

Practise first, no hardware needed, at
<http://localhost:8080/demo/mapping>.

## 6. Build the deployed geometry

```bash
python -m luminary.cli geometry
```

Turns the mapping records into the geometry the installation actually has —
real `(controller, channel, index)` identities — and prints a store id. It
refuses an incomplete mapping; `--partial` builds what exists and leaves the
rest dark.

If any board carries several **360-LED** strips, add `--interpolate`. A
360 strip then costs 180 lights on the wire and the board reconstructs the
rest. Measured on a SCORPIO with 8x360, this is the difference between an
ACK round-trip p95 of 33.6 ms — at the 33.3 ms frame interval, where frame
rate starts slipping — and 21.4 ms. Boards whose strips are all 180 are
unaffected either way.

## 7. Show

```bash
python -m luminary.cli show --lights <id from step 6> --pattern aurora
```

Streams to every registered board and mirrors the same bytes to
<http://localhost:8080/preview>. The preview is a mirror, not a second
render — one engine feeds both — so it is evidence of what the boards
received. Its header carries the wire health: boards up, ACK latency,
window stalls, and the adaptive byte budget.

`--serial 0=/dev/ttyACM0,1=/dev/ttyACM1` overrides discovery. `--host
0.0.0.0` exposes the preview to the network.

A slow browser loses preview frames and never slows the boards.

## Limits

- **4096 active lights per board.** Above it the board refuses the geometry
and runs a rainbow test pattern — running beads instead of your pattern
means exactly this.
- Frame rate is 30 fps. Under load the sender shrinks per-frame DELTA detail
to protect pacing rather than dropping frames.

## When something breaks

Boards recover on their own: a board that drops is retried every second and
re-sent its geometry, a board that reboots is detected and re-synced, and a
firmware hang is caught by an 8 s watchdog. Nothing here needs a human
unless the hardware is genuinely dead.
Opens a page showing exactly what the boards got.
`luminary stage` runs the play queue the same way.

Start with `python -m luminary.cli boards -v`. It answers "is the board
there, and is it really a board" before anything else is worth checking.
If something is wrong, `luminary boards -v` says what and why.
Limits, tuning, and the rest: [README.md](README.md).
97 changes: 79 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,51 +11,112 @@ the *same* codec, so the demo continuously exercises the production path.

## Install

```bash
./install.sh && . .venv/bin/activate
```

Sets up the virtualenv, installs the `luminary` command, and — on Linux —
grants board access and installs the udev rules so a board stays reachable
after each flash. `--no-sudo` skips the privileged parts. Re-runnable.

Running an installation start to finish: [`QUICKSTART.md`](QUICKSTART.md).

By hand, if you would rather not run a script:

```bash
git clone https://github.com/rossry/luminary && cd luminary
pip install -r requirements.txt
python -m venv .venv && . .venv/bin/activate
pip install -e '.[dev]' # omit [dev] to skip the test tooling
```

Python ≥ 3.11. That's everything for the server, CLI, and web client.
(Firmware builds need PlatformIO — see `firmware/scorpio/README.md`.)
Python ≥ 3.12. Editable deliberately: geometry configs and patterns live
beside the package rather than inside it, and the code resolves them from the
source tree. `pip install -e '.[flash]'` adds PlatformIO for building firmware.

## Quick start

```bash
# 1. Turn a scaffold into a lights geometry (where each LED is, on which strip)
python -m luminary.cli capture --scaffold examples/hex-demo.scaffold.json \
luminary capture --scaffold examples/hex-demo.scaffold.json \
-o hex.lights.json

# 2. Render a pattern to a static SVG
python -m luminary.cli render --lights hex.lights.json --pattern spiral \
luminary render --lights hex.lights.json --pattern spiral \
-t 2.5 -o hex-spiral.svg

# 3. Watch it live: web server + canvas client at http://localhost:8080
# (--seed-demo loads the demo geometries so the UI isn't empty)
python -m luminary.cli serve --port 8080 --seed-demo
luminary serve --port 8080 --seed-demo

# 4. Stream to hardware (Scorpio on USB serial)
python -m luminary.cli play --lights hex.lights.json --pattern kaleidoscope \
luminary play --lights hex.lights.json --pattern kaleidoscope \
--serial /dev/ttyACM0

# 5. No hardware handy? Dry-run the full render+encode pipeline with stats
python -m luminary.cli play --lights hex.lights.json --pattern ripple --duration 5
luminary play --lights hex.lights.json --pattern ripple --duration 5
```

Running an actual installation is a different path — find and register the
boards, flash them, map them, then stream to all of them with a live preview:

```bash
python -m luminary.cli boards # verify + register what's on USB
python -m luminary.cli flash --max-per-strip 180 # build, flash, prove it came back
python -m luminary.cli map # interactive deployment mapping
python -m luminary.cli show --lights pentagon-4A-33 --pattern aurora
luminary boards # verify + register what's on USB
luminary flash # build, flash, prove it came back
luminary map # interactive deployment mapping
luminary play --lights pentagon-4A-33 --pattern aurora # one pattern
luminary stage # the play queue
```

`show` streams to the boards and mirrors the same wire bytes to
`/preview` — one engine, so the preview is evidence of what the hardware
received rather than a second render. Step-by-step:
[`QUICKSTART.md`](QUICKSTART.md).
Both stream to the boards and open a local page carrying the same wire bytes
— one engine, so the page is evidence of what the hardware received rather
than a second render, and both take no arguments: the geometry comes from the
mapping records.

`play` runs one pattern and its page picks which; `stage` runs the queue, with
the control plane the main server mounts at `/stage`. These pages are the
operator's console rather than viewers, so unlike the demo server they steer
the installation instead of rendering their own copy of it. `--dry-run` on
`play` touches no hardware and reports codec stats. `show` is `play` under its
older name.

Step-by-step, from a fresh checkout: [`QUICKSTART.md`](QUICKSTART.md).

**Flash again after mapping.** `--max-per-strip` sets the frame-rate ceiling
and must be at least a board's longest strip — under-setting it clamps, and
the rest of every longer strip stays dark. Strips are 360 unless every strip
on a board is 180, which is the exception and only knowable once mapped, so
`flash` reads it from the records rather than leaving it to a flag. The
all-180 board is worth roughly double the frame rate.

Add `--interpolate` to `geometry` if a board carries several 360-LED strips:
each then costs 180 lights on the wire and the board reconstructs the rest.

Boards or panels that are not on the sphere yet: press **x** while mapping to
record one absent. The sequence moves on and does not come back to it, and
`geometry` builds without it rather than refusing — as opposed to an unmapped
panel, which it still refuses, because that is a gap rather than a decision.

### When a board misbehaves

`luminary boards -v` lists every device on USB and why it was accepted or
rejected. A board that has never been flashed does not enumerate at all — hold
BOOTSEL while plugging it in.

| Reported | Means |
|---|---|
| `bootsel` | No usable firmware. `luminary flash`. |
| `blocked` | Port not openable. Re-run `install.sh`, then log out and in. |
| `unresponsive` | Enumerates, does not answer. Re-flash. |
| duplicate ids | Two boards driving the same lights. Re-flash one. |

Everything else recovers on its own: a dropped board is retried every second
and re-sent its geometry, a reboot is detected and re-synced, and a firmware
hang is caught by an 8 s watchdog.

**Limits.** 4096 active lights per board — above it the board refuses the
geometry and runs a rainbow test pattern, which is what running beads mean.
Production frame rate is 30 fps, against a measured board ceiling of 67.6 fps
at 8x360.

In the web UI, pick a geometry and a pattern and press Play; the header
shows live fps and bytes/light·frame so you can watch the codec work. Add
Expand Down Expand Up @@ -94,7 +155,7 @@ against shared golden vectors (`firmware/golden/`).
| `GET/POST /api/patterns` | list / upload+hot-reload patterns |
| `WS /api/play?lights=ID&pattern=NAME` | wire-protocol streaming |
| `GET /demo/mapping` | the scrambled-build mapping tutorial, mounted here by `serve` (opt out: `--no-mapping-demo`); also standalone via `python -m luminary.mapping.web` |
| `GET …/api/mapping/layout` · `WS …/api/mapping/{window,wire,control}` | the mapping app's own API (`luminary.mapping.web`, under whatever prefix it serves at): layout+plan+state JSON; wire-codec streams; key events. A live session's window page is `/window` — its own process, `python -m luminary.cli map --web` |
| `GET …/api/mapping/layout` · `WS …/api/mapping/{window,wire,control}` | the mapping app's own API (`luminary.mapping.web`, under whatever prefix it serves at): layout+plan+state JSON; wire-codec streams; key events. A live session's window page is `/window` — its own process, `luminary map --web` |
| `GET /stage` · `GET/POST /api/queue` · `DELETE /api/queue/{i}` · `POST /api/queue/{play_next,move,skip,clear}` | the stage: viewer/control page and the play-queue API (tracklist + repeats cycle + now-playing; mounted by `serve`, opt out: `--no-stage`). Mutations take the stage key when one is configured (below) |
| `POST /api/repeats/move` · `DELETE /api/repeats/{i}` | reorder / cancel turns of the stage's repeats cycle |
| `WS /api/stage` · `GET /api/stage/{layout,patterns,chapters?pattern=N}` | the stage's wire-codec stream (SESSION on join, `{"type":"resync"}` back), its canvas draw layout, panel pattern metadata (notes, `loop`, `has_chapters`), and one pattern's chapter tree (`[]` if chapterless) |
Expand Down Expand Up @@ -227,7 +288,7 @@ cd firmware/test/host && make run
node tests/js/test_decoder.mjs # same vectors, browser decoder
```

Flashing is `python -m luminary.cli flash` (PlatformIO underneath, one
Flashing is `luminary flash` (PlatformIO underneath, one
build per controller id, verified with an identity probe afterwards). A
board that has never been flashed does not enumerate at all: hold BOOTSEL
while plugging it in, and `luminary boards` will report it as `bootsel`.
Expand Down
4 changes: 2 additions & 2 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ sudo -u luminary python3 -m venv /opt/luminary/venv
sudo -u luminary /opt/luminary/venv/bin/pip install -r requirements.txt

# one-time: demo geometries so the UI isn't empty (idempotent)
sudo -u luminary /opt/luminary/venv/bin/python -m luminary.cli seed \
sudo -u luminary /opt/luminary/venv/bin/luminary seed \
--store /opt/luminary/var
```

Expand All @@ -55,7 +55,7 @@ After=network.target
[Service]
User=luminary
WorkingDirectory=/opt/luminary/app
ExecStart=/opt/luminary/venv/bin/python -m luminary.cli \
ExecStart=/opt/luminary/venv/bin/luminary \
--store /opt/luminary/var serve --host 127.0.0.1 --port 8080 \
--disable-pattern-upload
Restart=on-failure
Expand Down
9 changes: 5 additions & 4 deletions firmware/scorpio/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,8 +105,9 @@ This does not raise the *installation's* frame rate, which is set by its
busiest board, but every lighter board gains that much headroom — margin for
jitter rather than throughput.

So the levers are `MAX_PER_STRIP`, set per board to that board's longest
strip, and how many outputs each board serves. Optimising the colour pipeline
So the levers are `MAX_PER_STRIP` — which is not free to choose, being the
board's longest strip, 360 unless every strip on that board is 180 — and how
many outputs each board serves. Optimising the colour pipeline
further buys nothing until the DMA shrinks; this is why the hardware
interpolators were investigated and not adopted.

Expand Down Expand Up @@ -222,7 +223,7 @@ at the RP2040's DMA and CPU contending for the same banked SRAM:

## Building

Normally you do not build by hand: `python -m luminary.cli flash
Normally you do not build by hand: `luminary flash
--max-per-strip <n>` builds per controller id, resets the board into its
bootloader, writes the UF2, and then re-probes to confirm the board came back
answering with the id it was built for. It uses the `deploy` env and passes
Expand Down Expand Up @@ -253,7 +254,7 @@ within ±2/255 of the Python float reference.

## Driving it

python -m luminary.cli play --lights <geometry> --pattern <name> \
luminary play --lights <geometry> --pattern <name> \
--serial /dev/ttyACM0

The server negotiates nothing beyond the SESSION frame; a freshly booted
Expand Down
Loading
Loading