Skip to content

← Back to list

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.

2 min read

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.

Diptych: left, the initial monolithic main.py; right, the current lumware/scripts/ tree with core, protocol, transport, importers, server, cli + orchestrator.

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_counter comes 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. The ruff.extend-exclude is annotated in pyproject.toml with 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.