Repository navigation
Packaging, the mapping window, absent boards, and play/stage on the wire - #38
Merged
Merged
Conversation
… 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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Everything since #33: the CLI you actually run an installation with.
luminary, and one install scriptpyproject had no packaging metadata at all. It now has dependencies, a
console_scripts entry point, and
[dev]/[flash]extras — sopython -m luminary.cliis justluminary, everywhere. Editable installdeliberately:
configs/andpatterns/live beside the package and the coderesolves them from the source tree.
./install.shdoes the rest — virtualenv, deps, dialout, and the udev rulesthat keep a board reachable across the re-enumeration every flash causes.
Idempotent,
--no-sudofor the privileged half. It handles the two traps thisbox hit: Debian splitting
ensurepipintopython3-venvwhile PEP 668 blocksinstalling 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
catfits a terminal and the top of itis the part you need.
flashreads the strip length from the mapping--max-per-stripmust be at least a board's longest strip — under-setting itclamps 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
flashreads it from the records rather than leaving it to a flagsomeone has to remember. Deployment order is flash (360, safe) → map → flash
again.
mapopens a window, andxsays "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
--weband is now what you get, with
--tuito opt out.xrecords a board or panel absent — not built, not yet hung. Absent is adecision, distinct from unmapped: the sequence does not return to it,
--continuedoes not reopen it, andgeometrybuilds without those lightsrather 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 —
mapthengeometrysaid "unit 9 has nocontroller locked" for a board already marked missing. They get an
absent.yamlkeyed on the unit vertex.playandstagedrive the boards and a pageBoth 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 previewmust 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.
stageis 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_windowsays 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
playwith 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 at30.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