Skip to content
rossryPublic

About

a Next Year on Luna project

Resources

Stars

2 stars

Watchers

0 watching

Forks

Repository files navigation

Luminary 2.1

In a hurry? QUICKSTART.md gets patterns playing in three commands.

Luminary drives a physical light installation for Next Year on Luna: a scaffold of structural lines carrying individually addressable LEDs, colored every frame by a pattern and streamed over a bit-efficient wire protocol to Adafruit Scorpio controllers — or to a browser, through the same codec, so the demo continuously exercises the production path.

Install

./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.

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

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

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; '.[vibe]' adds the Claude Agent SDK for vibe mode's session backend.

Quick start

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

# 2. Render a pattern to a static SVG
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)
luminary serve --port 8080 --seed-demo

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

# 5. No hardware handy? Dry-run the full render+encode pipeline with stats
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:

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

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.

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 your own geometries via POST /api/scaffolds + POST /api/lights/from-scaffold. To stand up a shared team server (VPS, Docker, or a container platform), see docs/deploy.md — including the security model for the pattern-upload endpoint.

Where to read more: the authoritative design is plan/spec/luminary-2.1-spec.md (paragraph-numbered; spec §… references appear throughout the code), and CLAUDE.md indexes the documentation for contributors and agents.

How it works

A lights geometry (*.lights.json, spec §6) is the canonical per-light table: identity {controller, channel, index}, kind (active / interpolated / inactive), coordinates in four spaces, direction+extent, and normal — loaded into one NumPy array. Patterns (spec §9) are pure vectorized functions render(lights, t) -> OKLCH. The engine (spec §10) renders and encodes each frame with the codec (spec §11): 6/5/8-bit quantized OKLCH, 2-byte-per-light keyframes, dead-reckoning deltas ranked by error under a byte budget. Drivers (spec §12) move those bytes over serial or WebSocket; the Scorpio firmware (spec §13, firmware/) and the web client (spec §14) decode with bit-identical integer predictors, verified against shared golden vectors (firmware/golden/).

Web API (spec §15)

Endpoint Purpose
POST /api/scaffolds / GET /api/scaffolds[/{id}[/view]] save / list / fetch / render scaffolds
POST /api/lights / `GET /api/lights[/{id}[/view /layout]]`
POST /api/lights/from-scaffold capture with defaults: {scaffold_id, params}
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, 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)
GET /api/audio audio inventory: {dir, files: [{name, seconds}]} — dir is the resolved directory the stage reads (the checkout's var/audio/), seconds null when unreadable
GET /vibe · GET/POST /api/vibe · POST /api/vibe/select vibe mode (below): the prompt-to-pattern page over the stage; its whole shared state (the thread of generations, what is cooking, what is showing, the pattern menu); queue a prompt ({prompt, name?, author?, model?, from_scratch?} → 202 with its number); cut the stage to any pattern ({pattern}). Mutations take the stage key when one is configured; mounted by serve when the stage is (opt out: --no-vibe)

The stage (play queue)

serve runs the stage at /stage: one engine over the production sphere geometry (--stage-lights overrides with a geometry id or lights file) playing a persisted tracklist, gaplessly — entries advance by pattern swap on the same engine, each pattern seeing t from its own entry's start, so long-form shows and audio cue sheets align at 0. An entry is {pattern, duration, audio, repeat}: audio names a file in var/audio/, played by an auto-detected local player (mpv/cvlc/ffplay; --audio-player CMD overrides) started at the entry's t=0. The track times the entry: with audio attached, duration null means the file's exact length, a longer ask is trimmed to it at add time, and a shorter ask cuts the entry there with a short audio fade-out (mpv/ffplay; others cut hard). Without audio, duration null defers to the pattern's own duration attribute (else it plays until skipped). A pattern may declare its soundtrack (Pattern.audio, a bare filename; a composition's movements may each declare their own via Movement(..., audio=…)): left unspecified, an entry picks up the declared file when it is present — and a composition queued with audio unspecified starts each chapter's own declared track at that chapter. The page pre-selects the declaration (♪ per chapter for chaptered shows) and marks declared-but-missing files (wants ♪ name). "Play next" (/api/queue/play_next) inserts right after the playing entry. An exhausted queue takes the next turn of the repeats cycle (below); with that empty too it holds the last pattern, looping — the sphere never goes dark — and both lists survive restarts (var/stage/queue.json). The page is a thin adapter over /api/queue; every playback decision lives server-side in luminary/stage/core.py.

Chapters. A queued composition (a Conductor — anything answering chapters()) expands, the moment it reaches the head of the queue, into one entry per top-level chapter, titled composition/chapter; a nested composition expands one level again when it reaches the head (comp/chapter/subchapter), so the queue always shows the current show at chapter granularity while later chapters stay one level deep. Chapter entries keep the top-level pattern with an offset into its own timeline, so adjacent chapters advance seamlessly — no keyframe, timeline continuous, the composition's own crossfades and audio intact — and skip means next chapter. A skip is a jump, so it re-keyframes. Queued (not yet expanded) compositions are click-to-preview expanders in the page, showing the server-computed chapter tree; the viewer header shows the playing chapter's path with its liner notes (from Movement/Pattern.notes) beneath in italics. An instance of a loop=True composition gets one full pass (pattern.total) as its duration.

Repeats. The stage keeps a second list: a round-robin cycle of {pattern, title, audio} tokens with its own pane and controls. Adding with the repeat box checked (its default is the pattern's own loop flag) queues one instance and one token; whenever the play-through queue runs out, the head token spawns a fresh instance (expanding into chapters at the head as usual) and moves to the back of the cycle — an overnight program repeats forever until its token is cancelled. "Clear queue" drops only the play-through list; the cycle keeps going until its tokens are removed.

Production posture. Configure a stage key (serve --stage-key K, or env LUMINARY_STAGE_KEY; the flag wins) and every mutating endpoint (add, play-next, remove, move, skip, clear, repeats CRUD) requires it in an X-Stage-Key header — wrong or missing gets a 403 with a JSON error the page surfaces. Read-only traffic (the page, the WS stream, queue/layout/patterns/chapters/audio GETs) is never gated, so anyone can watch. Put the key in the systemd unit's environment (Environment=LUMINARY_STAGE_KEY=…) and share the control URL only with VJs — the page takes the key from its footer field (persisted in localStorage) or once via a #key=… URL fragment. With no key configured, endpoints stay open (LAN deployments).

Vibe mode (prompt → pattern, live)

/vibe is the stage for a crowd: the sphere on a canvas, a text box, and a coding model. Type what the sphere should do and press enter; the server hands the prompt to the model, validates the module it writes against the real lights, saves it as generation #N (numbered from 1, in order of asking), and hot-cuts the stage to it — the sphere and every open page switch together. Optional boxes take a name and your name; a model select picks who writes it. The point is play: pair-programming the sphere out loud, several people, quick turns.

  • Shared state. Everything lives on the server (/api/vibe); the page is a thin adapter that polls it, so open it on every phone at the sphere and they all see the same thread, the same cooking prompt, the same menu.
  • Context. A prompt carries the pattern that was showing when it was typed — name and source — so "slower and more purple" means that one even if someone cut elsewhere while the model was busy. Tick "from scratch" to drop it.
  • Always ships. The model may leave a one- or two-sentence note or question beside its result, but it ships its best try to every prompt. A module that fails validation (shape, non-finite values, statefulness, a frame over budget) gets one repair round; a generation that still fails is shown failed in the thread, and the stage stays where it was.
  • Nothing is lost. Generations are ordinary pattern files under var/vibe/ (vibe-0001.py…, registered as vibe-N; rejected drafts kept beside them under a leading underscore), and var/vibe/log.json is the whole thread. The side menu (☰ hides it) lists the named generations, then all of them, then the repo's own patterns by folder — click anything to cut the stage to it, for comparison or as the base of the next prompt. Promote a keeper by copying its file into patterns/book-two/.

Backends. With the Claude Agent SDK installed (pip install -e '.[vibe]') and a claude CLI on PATH (logged in, or ANTHROPIC_API_KEY set), each prompt runs as its own Claude Code session in the checkout, read-only, shipping through one MCP tool that validates on the spot and hands failures straight back — the session iterates inside its own turn. It runs in Claude Code's auto permission mode: its read-only tools and the ship tool are pre-approved, writes, shell and web are denied outright, and only a call outside both lists goes to the permission classifier — nothing ever waits on a prompt (LUMINARY_VIBE_PERMISSION_MODE=default for plain allowlist-only mode). Without the SDK, the server makes one direct Messages API call (ANTHROPIC_API_KEY required). LUMINARY_VIBE_BACKEND=session|api forces one; the page's footer says which is running. LUMINARY_VIBE_MODELS=claude-sonnet-5,claude-opus-5 (the default) is the list the select offers, first entry default — keep the fast one first; a reply that takes a minute is not a conversation.

Security. A generation is model-written code executed in-process, exactly like an upload — so vibe mode mounts only where upload is allowed or a stage key is configured, and then its mutations take the key like the queue's do. serve --no-vibe leaves it off entirely; GET /api/health reports "vibe".

Pattern development

Create a file in patterns/:

import numpy as np
from luminary.geometry.lights import LightColumns
from luminary.patterns.base import Pattern

class MyPattern(Pattern):
    name = "my_pattern"
    description = "What it looks like"

    def render(self, lights: np.ndarray, t: float) -> np.ndarray:
        x = lights[:, LightColumns.X]
        y = lights[:, LightColumns.Y]
        out = np.zeros((lights.shape[0], 3))
        out[:, 0] = 0.6                                  # L: 0..1
        out[:, 1] = 0.3                                  # C: 0..0.4
        out[:, 2] = (x + y + t * 60.0) % 360.0           # H: degrees
        return out

Rules (spec §9.1): vectorized NumPy only, and stateless — output depends only on (lights, t); use luminary.patterns.util.seeded_random for per-entity constants. Files in patterns/ are discovered on server start (recursively — the directory is organized into volumes: book-one/, conifer/, book-two/) and on any POST /api/patterns upload, which hot-reloads the registry.

For composed work there is a shared library in luminary/patterns/: palettes with perceptual OKLab blending, easing, deterministic noise fields, parametrized primitives (Starfield, NoiseGlow, AuroraVeils, RingWave), and Movement/Conductor for sequencing whole shows as stateless patterns with crossfades (patterns/book-two/nocturne.py is a conducted hour). See the "Composing from the library" section of the contributor guide.

The full contributor guide is patterns/README.md: the contract, the lights-array columns, statelessness idioms for events and randomness, craft notes for the physical medium (gamut, scale, motion, wire cost), the iterate/test loop, and a reading list mapping every shipped pattern to the technique it demonstrates. If you have an idea for a pattern, that page is everything you need. Stateful 2.0 patterns that predate the contract are parked in patterns/legacy/.

Firmware

firmware/scorpio/ is a PlatformIO/Arduino project for the Feather RP2040 SCORPIO (spec §13): serial wire in, eight NeoPXL8 strips out, with fixed-point OKLCH→RGB and on-device interpolation of non-transmitted lights. Its decoder core is plain C++ and host-tested against the golden vectors:

cd firmware/test/host && make run
node tests/js/test_decoder.mjs        # same vectors, browser decoder

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.

Tests

python -m pytest            # includes golden-vector + JS + C++ conformance

New code also passes black and strict mypy — see plan/guidelines/code-quality.md and plan/todo/legacy-mypy-debt.md.

Pentagon nets (2.0 heritage)

The pentagon Net (Triangles → Facets → Beams) lives on as a constructor (spec §3.8): luminary.geometry.pentagon.to_scaffold/capture turn any configs/*.json into scaffold/lights geometries, with beam polygons kept as per-light display shapes for the renderers. main.py retains the 2.0 utilities (svg, validate, index) and its pattern sample/preview subcommands now run on the 2.1 engine.

Contributing

Core development is managed with Graphite; contributors can make PRs with whatever git tooling you like. Start with CLAUDE.md for the documentation map and plan/guidelines/ for workflow.

About

a Next Year on Luna project

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages