Skip to content

Packaging, the mapping window, absent boards, and play/stage on the wire - #38

Merged
rossry merged 11 commits into
mainfrom
interpolators/_
Aug 28, 2026
Merged

rossry merged 11 commits into
mainfrom
interpolators/_

Conversation

@rossry

@rossry rossry commented Aug 28, 2026

Copy link
Copy Markdown
Owner

Everything since #33: the CLI you actually run an installation with.

luminary, and one install script

pyproject had no packaging metadata at all. It now has dependencies, a
console_scripts entry point, and [dev]/[flash] extras — so
python -m luminary.cli is just luminary, everywhere. Editable install
deliberately: configs/ and patterns/ live beside the package and the code
resolves them from the source tree.

./install.sh does the rest — virtualenv, deps, dialout, and the udev rules
that keep a board reachable across the re-enumeration every flash causes.
Idempotent, --no-sudo for the privileged half. It handles the two traps this
box hit: Debian splitting ensurepip into python3-venv while PEP 668 blocks
installing it, and a Node below 22 turning the JS conformance test into a
silent skip rather than a failure.

QUICKSTART is 154 lines → 21, so one cat fits a terminal and the top of it
is the part you need.

flash reads the strip length from the mapping

--max-per-strip 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
someone has to remember. Deployment order is flash (360, safe) → map → flash
again.

map opens a window, and x says "not here"

Mapping is spatial; a terminal can say "board 1/6" but not show an operator at
the sphere which board that is. The browser surface existed behind --web
and is now what you get, with --tui to opt out.

x records a board or panel absent — not built, not yet hung. Absent is a
decision, distinct from unmapped: the sequence does not return to it,
--continue does not reopen it, and geometry builds without those lights
rather than refusing. An unmapped panel is still refused, because that is a
gap nobody has decided about. An absent board's panels are never asked about.

Absent boards have no controller id, so they did not fit a store keyed on one
and were silently dropped on save — map then geometry said "unit 9 has no
controller locked" for a board already marked missing. They get an
absent.yaml keyed on the unit vertex.

play and stage drive the boards and a page

Both take no arguments and stream one engine to the hardware and to a local
page carrying the identical wire bytes. The geometry defaults to the mapping
records — the design capture puts 5940 lights on controller 0, past the
firmware's 4096 ceiling, so a board handed it runs the test pattern; anything
over the ceiling now warns and says what the beads mean.

play's page picks the pattern, which reverses an earlier decision: a preview
must not steer the installation, but this is the operator's console on the
base station and there is no other way to change what plays.

stage is the play queue — the control plane the server mounts at /stage,
with the boards on the wire. That needed a sink, and the shape did not fit:
StageCore owns its clock and hands frames to sinks, while the acknowledgement
window works by not rendering when a board is behind. A frame generated and
dropped leaves the encoder's decoder-model ahead of the board and every later
DELTA wrong — so a full window forces a keyframe instead of dropping quietly,
and dropped_to_window says whether it ever fired.

Two consequences of the caller owning the clock, measured and documented
rather than papered over: inbound is polled once per tick, so reported ACK
latency is a frame interval rather than a measurement (~33 ms against a true
~4 ms), and the adaptive DELTA budget does not run, because it lives in
run().

Verified on the board

play with no arguments: 368 frames mirrored, board up, zero stalls.
Switching aurora → tidepool over the socket: frames kept flowing, zero stalls.
stage: queued apollo, its chapters landed, board driven; and 361 ticks at
30.1 fps through the stage sink with 367 acks and zero dropped.

437 Python tests, C++ conformance (6 checks), JS decoder and presentation
conformance.

🤖 Generated with Claude Code

https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx

rossry and others added 11 commits August 28, 2026 00:34
… fits

`python -m luminary.cli` becomes `luminary`, everywhere.

pyproject gains the packaging metadata it never had -- dependencies, a
console_scripts entry point, and `[dev]` / `[flash]` extras. Editable
deliberately: geometry configs and patterns live beside the package rather
than inside it, and the code resolves them from the source tree.
requirements.txt now just points at it, and CI installs the same way, so the
dependency set has one home.

install.sh does the setup that was prose in QUICKSTART: virtualenv, deps,
dialout, and the udev rules that keep a board reachable across the
re-enumeration every flash causes. Idempotent, with --no-sudo for the
privileged half. It works around this box's two traps -- Debian splitting
ensurepip into python3-venv while PEP 668 blocks installing it, and a Node
below 22 turning the JS decoder conformance test into a silent skip rather
than a failure.

QUICKSTART drops 154 lines to 49: the install steps are the script's job, and
the mapping session explains itself. What is left is the six commands, what
`boards` says when something is wrong, and the limits that bite --
MAX_ACTIVE_LIGHTS, --max-per-strip being per board and worth roughly double at
180, and --interpolate for boards carrying several 360-LED strips.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
I had this backwards in the docs and, worse, in the example command: 360 is
the normal strip, not 180. `luminary flash --max-per-strip 180` as the
headline example would have clamped every 360 strip and left half of each one
dark.

--max-per-strip sets the frame-rate ceiling and must be at least the board's
longest strip. Which boards are all-180 is the exception, and is only knowable
once they have been mapped -- so it should not be a flag someone has to
remember correctly. `flash` now reads each board's longest recorded strip from
the mapping store and builds for that, falling back to the firmware's 360 for
a board with no records. An explicit --max-per-strip still overrides, and the
help says plainly what happens if it is set too low.

Which makes the deployment order: flash (360, safe), map, then flash again --
now that the records exist, the exceptional all-180 board gets built for 180
and is worth roughly double the frame rate.

Docs corrected throughout to say strips are 360 unless a board's are all 180,
rather than treating 180 as the norm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
49 lines does not fit a standard terminal, so `cat QUICKSTART.md` scrolled the
useful part off the top and left the reader looking at the bottom of a
troubleshooting table. It is 21 lines now: install, the six commands in the
order they are run, and where the preview is. One cat, from a fresh checkout
to lights.

Everything cut moved to README rather than being lost -- the boards
troubleshooting table, the recovery behaviour, MAX_ACTIVE_LIGHTS, and why
flash runs twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
Mapping is a spatial task -- which physical panel is this, which way does its
strip run -- and the terminal surface can only say "board 1/6", which tells an
operator standing at the sphere nothing. The browser window already existed
behind --web and draws the net with the board under the cursor lit, so the
screen and the hardware show the same thing. It is what `luminary map` gives
you now, and it opens a browser at it.

--tui selects the terminal surface. --web is still accepted and does nothing,
since it names what you already get. --no-browser serves without opening one.
Port moves to 8090, clear of serve and show on 8080.

A missing web surface now falls back to the terminal rather than exiting 2:
that guard existed to stop --web failing obscurely, and there is no --web to
fail any more.

The two tests that pinned the old behaviour are replaced by ones that pin the
new: the window is served by default, and --tui still reaches the terminal.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
An installation gets mapped before it is finished. A board that was not built
or a panel not yet hung would previously stall the sequence -- the cursor kept
landing on it -- and `geometry` refused to build over the gap, so a partial
sphere could not be driven at all without --partial, which gives up strictness
everywhere rather than at the one known hole.

x records it absent: the whole board in stage A, the panel under the cursor in
stage B. Absent is a decision, distinct from unmapped. The sequence does not
return to it, --continue does not reopen it, and the deployed geometry is
built without its lights rather than refusing. An unmapped panel is still
refused, because that is a gap nobody has decided about.

Any directional key clears it -- choosing a controller or a channel is the
operator saying it exists after all -- and confirming a panel releases it from
the absent set. Skipping a panel that already held a channel frees the
channel, since an absent panel has no lights to drive.

The key contract is held to one canon across the terminal and the browser by
tests/test_mapping_keys.py, so x lands in all three places at once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
Stage B still walked the panels of a board recorded absent. The guard that
skipped them was written `if board.absent and not state.review`, and review is
the default pass -- so the sequence asked which channel each panel of a
missing board was on, which is exactly the stall x exists to avoid.

Skipped in both modes now. Review still walks the board itself in stage A, so
un-skipping it there brings its panels back into the sequence.

Also: enter on an absent panel re-confirms it absent rather than recording it
against the candidate channel. Review pre-fills each slot so an unchanged
mapping is a run of enters, and that must not quietly resurrect the panels
that were deliberately skipped.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
`luminary map`, then `luminary geometry`: "unit 9 has no controller locked",
for a board that had been marked absent with x.

save_state writes one YAML per board keyed on controller id, and skips boards
that have none -- which is every absent board. So the flag was set in the state
machine, shown in the window, and then dropped on the way to disk. On reload
the unit was indistinguishable from one nobody had mapped yet, and geometry
refused the deployment over a board the operator had already said was missing.

Absent boards now get a home of their own: an `absent.yaml` listing the data
units that are not on the sphere, removed when nothing is absent. Keyed on the
unit vertex because that is the only identity an absent board has -- it has no
controller id, which is the whole reason it did not fit the existing scheme.

Loading merges the two, and refuses a unit that is both recorded absent and
has a mapping file.

Verified end to end: map with one board skipped, save, reload, build -- five
controllers and 4860 of 5940 lights, strict, no --partial.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
Both now do what `show` did: one engine to the hardware and to a page carrying
the identical wire bytes, so the page is evidence of what the installation
received rather than a second render of what it ought to be showing.

`play` gains the page, which makes it the same command `show` already was, so
`show` becomes an alias rather than a second thing to keep in step. Its
dry-run -- render and encode, touch no hardware, report codec stats -- moves
behind --dry-run.

`stage` is new: the play queue with the control plane the server mounts at
/stage, plus the boards on the wire. It needed a sink, and the shape did not
fit. StageCore owns its clock: it renders on its ticker and hands frames to
its sinks, while the acknowledgement window works by *not rendering* when a
board is behind -- the encoder models the decoder's state, so a frame
generated and then dropped leaves the two disagreeing and every later DELTA
decodes against a state the board never reached.

So StageSerialSink does not drop silently. A full window discards the frames
and asks the engine for a keyframe, which resynchronizes from any state. That
spends bandwidth exactly when it is short, so it is a fallback, not the
mechanism -- and `dropped_to_window` says whether it ever fired.

Two consequences of the caller owning the clock, both measured and documented
rather than papered over: inbound is polled once per tick, so reported ACK
latency is rounded up to a frame interval (~33 ms at 30 fps against a true
median near 4 ms) -- flow control is unaffected, since the window counts
frames rather than latency -- and the adaptive DELTA budget does not run,
because it lives in run().

SerialDriver grows poll/window_full/send so a caller with its own clock does
not reach into privates. create_app exposes the stage core on app.state the
way it already exposes the broadcast session.

Verified on the board: 361 ticks at 30.1 fps through the stage sink, 367 acks,
zero dropped to the window, zero disconnects; and `play` mirroring 368 frames
to the page with zero stalls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
`luminary play` should put lights on the sphere and open a page, so --lights
and --pattern are optional.

With nothing named, the geometry comes from the mapping records -- the
installation that exists, its panels split across their real boards. The
design capture is only the fallback, and it puts every light on controller 0:
5940 of them for 4A-33, well past the firmware's 4096 ceiling, so a board
handed it refuses the SESSION and runs the test pattern. Defaulting to that
would have meant `luminary play` showing running rainbow beads on a mapped
sphere. Anything over the ceiling now warns and says what the beads mean.

The preview page fetched its layout by store id, which a resolved geometry may
not have -- built from mapping records, or the default net, neither is stored.
It now reads /api/preview/layout, straight from the running session.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
`luminary play` opened a page that could only watch. It exists to put a
pattern on the sphere, so the page lists what is loaded and switches between
them; the choice goes down the same socket the frames come back on, and the
engine that changes is the one driving the boards.

That reverses a decision made when the preview was built. The comment there
said a preview must not be able to steer the installation, which is right for
a viewer somewhere on the network and wrong for this: it is the operator's
console on the base station, and there is no other way to change what is
playing.

A keyframe follows the swap, since the boards are mid-DELTA against a pattern
that has just gone away.

Verified on the board: opened on aurora, switched to tidepool over the socket,
frames kept flowing, zero window stalls.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
Only requirements.txt conflicted. main added mutagen for exact stage-audio
track lengths; this branch had reduced the file to a pointer at pyproject, so
the dependency moves there rather than being lost.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FbzmvdzTNSUxDUjVraRZMx
@rossry
rossry merged commit 0644607 into main Aug 28, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant