From script to product
There was a 200-line script. It worked. The story did not end there — without the next step, there would be no family.
There was a script. Two hundred lines. It worked. The story didn’t end there — without the next step, Lumware wouldn’t be a family.
The context
The first host was a 200-line main.py. It opened a file,
computed colors for each LED, wrote to serial. No tests.
No structured logs. No separation between reading,
transforming, and sending.
It worked because everything lived in one person’s head — mine. The day I dropped it for a month and came back, I had to re-learn what it did. The second week I added a “small improvement” and it broke. No test warned me; there were none to warn.
The easy path: fix the regression, move on. The real path: stop and treat it like a product.
The decision
Three simultaneous, non-negotiable changes:
1. A modular layout. The lumware/scripts/ package
stops being one file and becomes five areas with sharp
boundaries:
core/— events, settings, scheduler, cache, colors.protocol/— binary codec, CRC, frame definitions.transport/— real serial and a loopback for tests.importers/— images and video into RGB frames.server/— FastAPI + WebSocket for the web UI.
Plus cli/ (the Unity-stdin layer) and orchestrator.py
(playback on its own thread).
2. Real tests. No demos. No if __name__ asserts.
Three levels: unit on core/, integration on server/
and orchestrator/, compat for the Unity stdin contract.
An 80 % coverage floor on core/, anchored in
pyproject.toml (it sits around 98 % today).
3. Explicit conventions. Relative imports inside the
package. No print() in core (only EventBus).
time.perf_counter always for the scheduler — never
time.time(). Immutable settings. tools/ stays on
the sidelines: it may use shell=True, but nothing
in runtime imports from it.
Why these rules and not others. Each rule is anchored to a concrete reason: no
print()comes from the Unity contract (the UI expects serialized events);perf_countercomes from a real drift bug with wall-clock in the old code; frozen settings come from not trusting cross-thread mutations once playback and the UI live on different threads. Theruff.extend-excludeis annotated inpyproject.tomlwith the story that retires each entry, so legacy paths don’t become permanent.
This step invents nothing new. It takes what already worked and reshapes it around clean boundaries. But it is what makes Lumware Capture and Lumware Stage possible — they share a vocabulary (frames, transport, host, codec) because that vocabulary, here, has an explicit and testable meaning.
What comes next
Next post drops to the physical layer: NeoPixel strip, power supply, level shifter, common ground. Six components, the real reason for each — because the software can be perfect and the first LED still flash random colors if the ground isn’t shared.