Pure-Python driver for the Abstract Foundry LumiCube. Talks the reverse-engineered wire protocol directly over /dev/ttyAMA0 — no Java daemon required.
Project description
pylumicube
Pure-Python driver for the Abstract Foundry LumiCube.
Speaks the reverse-engineered wire protocol directly over /dev/ttyAMA0,
so you don't need the original Java foundry-daemon AppImage to drive
the panel. Protocol details are in PROTOCOL.md.
Status
The reverse-engineered wire protocol is implemented end to end: the LED
matrix is driven directly from Python on real hardware, and upstream
LumiCube community scripts run unchanged via a compatibility shim
(verified on a LumiCube Advanced Kit, firmware shipped with AppImage
2.0.1). The roadmap below tracks progress towards full parity with the
Java foundry-daemon.
Wire & transport
- Link layer — bidirectional PING/PONG handshake honouring the firmware's 256-PONG drain,
INITIALISE/INITIALISED, 16-slot sliding window with retransmits. - Node discovery — passive harvest from
NODE_STATUSbroadcasts, plus a 3-stage dynamic node-ID allocator for cold-boot scenarios. - Module discovery —
GET_PREFERRED_NAMEto pick thecubebase board over thebutton_and_light_sensorboard. - Schema discovery —
ENUMERATE_FIELDSwalker (utilities/snapshot_hardware.py) that decodes in-line sub-dicts and resolves block floors via probing + binary search. SeePROTOCOL.md§4.5.1.
Hardware modules
- LED matrix —
SET_FIELDSwrites covering all 192 LEDs (3 frames). Exposed as thelumicube-ledsCLI andLumiCube.display. - Upstream-script compat shim —
pylumicube.compatrecreates the foundry-daemon globals (cube,display,hsv_colour,noise_*, colour constants, etc.).lumicube-run script.pyexecs a community script in that namespace; display-only scripts (rainbow, rain, binary_clock, conways_game_of_life, autumn_scene, land_grab, lava_lamp, ripples, scrolling_clock — all underscripts/original/) run unchanged. Sensor / audio / screen modules are warn-and-no-op stubs until they land. - Microphone input —
SUBSCRIBE_DEFAULT_FIELDS+PUBLISHED_FIELDStelemetry plumbing, then exposemicrophone.data. - Light sensor — colour, proximity, and gesture readings from the
button_and_light_sensorboard (telemetry-driven, builds on microphone). - Secondary LCD screen — drive the
screenmodule on the cube node. Forces the move from a hardcoded display schema to runtimeENUMERATE_FIELDS+ direct-probe discovery (seePROTOCOL.md§5.1).
Tooling
- FastAPI daemon — replace the Java
foundry-daemonwith a Python REST API (Swagger-documented), shipped as a systemd unit. - Web frontend for the daemon.
Protocol-side open questions are tracked in PROTOCOL.md §7.
Install
Requirements: Python 3.11+ and access to a serial device at
3 Mbaud (/dev/ttyAMA0 on a Pi). Runtime dependencies are pyserial
and opensimplex (the latter only used by compat.noise_2d/3d/4d).
Option A — uv (recommended for development)
uv manages the virtualenv and
lockfile for you. From a fresh clone:
git clone https://github.com/chrislibuilds/pylumicube.git
cd pylumicube
uv sync # creates .venv and installs runtime deps
uv sync --extra dev # adds pytest for the test suite
uv sync --extra extras # adds requests for scripts/digital_clock.py (optional)
Run commands with uv run … so they pick up the project venv without
you activating it:
uv run pytest
uv run lumicube-leds all FF0000
uv run lumicube-run scripts/original/rainbow.py
Option B — classic venv + pip
Works in any 3.11+ Python install. From a fresh clone:
git clone https://github.com/chrislibuilds/pylumicube.git
cd pylumicube
python3 -m venv .venv
source .venv/bin/activate # PowerShell: .venv\Scripts\Activate.ps1
pip install --upgrade pip
pip install -e '.[dev]' # editable install + dev extras (pytest)
pip install -e '.[extras]' # optional: deps for scripts/digital_clock.py
# Or combine the groups:
# pip install -e '.[dev,extras]'
After activate, the lumicube-leds, lumicube-run, and pytest
commands are all on your PATH:
pytest
lumicube-leds all FF0000
lumicube-run scripts/original/rainbow.py
Option C — from PyPI (once released)
pip install pylumicube
Pre-flight on the Raspberry Pi (optional)
A vanilla Raspberry Pi OS 13 (Bookworm/Trixie) install — including the
Lite / headless image without X or Wayland — has nothing using
/dev/ttyAMA0, so you can skip this section and go straight to the CLI.
The optional bits are:
-
Have you ever installed the Abstract Foundry
foundry-daemonAppImage? It holds/dev/ttyAMA0exclusively, so pylumicube will fail withResource busyuntil you stop it:# Preferred: the user-scope service the AppImage installs. export XDG_RUNTIME_DIR=/run/user/$(id -u) systemctl --user stop foundry-daemon.service # Fallback if the user systemd isn't reachable (non-login SSH): pkill -f foundry-daemon
-
Bringing up a brand-new Pi image?
utilities/check_pi_uart.shaudits the boot config (UART enabled, serial console disabled, no daemon installed). Run it once to confirm the OS is ready to talk to the cube.
CLI
Two console scripts ship with the package: lumicube-leds for direct
LED control and lumicube-run for executing upstream community scripts.
Prefix with uv run if you're using uv; activate the venv first if
you're using classic pip.
lumicube-leds — direct LED control
One-shot LED writes that exit when done. Useful for diagnostics or for piping from shell scripts.
# Solid colours
lumicube-leds all FF0000 # whole matrix red
lumicube-leds all 0000FF # whole matrix blue
lumicube-leds off # turn every LED off
# Single LED by 0..191 index
lumicube-leds single 42 00FF00 # LED 42 = green
# Other options
lumicube-leds --port /dev/ttyAMA0 --debug all 0000FF
lumicube-leds --help # full flag list
Exit codes: 0 on success, 1 on any error (no cube found, bad
handshake, port busy, …). Pass --debug to see the link-layer logs.
lumicube-run — run a community script
Recreates the foundry-daemon's pre-populated globals (cube, display,
hsv_colour, colour constants, noise_2d/3d/4d, time/math/random,
etc.) and execs the given script in that namespace, so upstream
community scripts
work unchanged.
# Display-only scripts — fully working today (upstream community
# scripts live under scripts/original/).
lumicube-run scripts/original/rainbow.py
lumicube-run scripts/original/binary_clock.py
lumicube-run scripts/original/lava_lamp.py
lumicube-run scripts/original/scrolling_clock.py # uses the built-in font
# Stop the script with Ctrl-C — the matrix is blanked on exit unless
# you pass --no-clear.
lumicube-run --no-clear scripts/original/rainbow.py
Sensor / audio / screen modules (microphone, speaker, screen,
buttons, light_sensor, imu, env_sensor, pi) are warn-and-no-op
stubs for now — scripts that only poke the LED matrix run end to end;
scripts that read sensors or play sounds will print a one-time
RuntimeWarning per attribute and silently skip those calls.
Native-API scripts
The top-level scripts/ directory ships two native-API examples:
scripts/digital_clock.py— a "from scratch" clock that shows how to compute (x, y) → LED-index yourself and push frames viaDisplay.set_leds.scripts/plasma.py— a 3D plasma / lava-lamp effect using 4D OpenSimplex noise. Ported from the upstream community-scriptscripts/original/lava_lamp.py, but with precomputed surface geometry, vectorised HSV→RGB, and the async display path so the next frame's compute overlaps the previous frame's wire push.
Both are run as plain Python scripts:
uv run python scripts/digital_clock.py
uv run python scripts/plasma.py
# or, in an activated venv:
python scripts/digital_clock.py
python scripts/plasma.py
They also work under lumicube-run — the runner registers its open
cube via pylumicube.compat.get_hosted_cube(), and the scripts pick
that up through open_or_use_hosted(port) instead of opening a second
serial connection:
uv run lumicube-run scripts/digital_clock.py
uv run lumicube-run scripts/plasma.py
To make your own native-API script dual-mode-compatible, replace the
bare with LumiCube(port) as cube: with the helper:
from pylumicube.compat import open_or_use_hosted
with open_or_use_hosted('/dev/ttyAMA0') as cube:
cube.display.fill(0x00FF00)
Standalone, the helper opens and tears down a fresh LumiCube(port).
Hosted by lumicube-run, it yields the runner's cube and leaves
teardown to the runner.
digital_clock.py's optional weather feature uses requests, which is
part of the extras install group (pip install -e '.[extras]' or
uv sync --extra extras). plasma.py only needs opensimplex (a
base-install runtime dependency) plus numpy (already a transitive dep
through opensimplex).
To enable digital_clock's weather feature, copy the example config and edit your OpenWeatherMap API key + city ID:
cp scripts/digital_clock_config.py.example scripts/digital_clock_config.py
$EDITOR scripts/digital_clock_config.py # set OPENWEATHERMAP_API_KEY
scripts/digital_clock_config.py is listed in .gitignore so your key
never lands in version control. Without the config file the clock still
runs — it just skips the weather overlay.
Library
pylumicube is usable as a library two ways: the native API for direct
field-level control, and the compat shim for upstream-style scripting.
Native API
from pylumicube import LumiCube
# The context manager opens the serial port, runs the handshake,
# discovers nodes, and closes everything cleanly on exit.
with LumiCube('/dev/ttyAMA0') as cube:
cube.display.fill(0x00FF00) # whole matrix green
cube.display.set_leds({0: 0xFF0000, 1: 0xFFFFFF}) # by 0..191 index
cube.display.set_brightness(128) # 0..255
Async by default. set_leds, fill, show, and set_brightness
return immediately and push the write on a background thread, so a
frame loop can compute frame N+1 while frame N is still being pushed
over the wire. At most one frame is in flight (cube firmware constraint)
— the next async call blocks until the previous one is ACK'd, so a
script that calls fill faster than the wire can drain gets natural
backpressure. The cube is drained automatically on cube.__exit__.
If you want the old "block until ACK" behaviour for a particular write
(e.g. one-shot CLI scripts that need to know the write committed before
exiting, or to surface errors at the call site), pass await_ack=True:
from pylumicube import LumiCube
with LumiCube() as cube:
cube.display.fill(0xFF0000, await_ack=True) # blocks; raises on error
cube.display.flush(timeout=1.0) # or: drain any async writes
Compat-shim API (upstream-style)
from pylumicube.compat import run_script
# One-shot: open the cube and run a community script in the
# foundry-daemon-compatible namespace.
run_script('scripts/original/binary_clock.py')
Or drive things yourself with the upstream helpers:
from pylumicube import LumiCube
from pylumicube.compat import build_globals
with LumiCube() as cube:
ns = build_globals(cube)
display = ns['display']
display.set_led(0, 0, ns['red']) # (x, y) coords
display.set_3d({(0, 0, 8): ns['cyan']}, show=True) # 3D face coords
display.scroll_text('Hello', ns['cyan']) # uses built-in font
The compat namespace mirrors the upstream daemon's
api.txt:
cube, display, buttons, …, plus hsv_colour, random_colour,
noise_2d/3d/4d, run_async, and the colour constants.
Testing
uv run pytest # with uv
# or
pytest # with an activated venv
The suite is fully offline — no hardware required. It covers COBS,
CRC, framing, FlatDictionary, UAVCAN messageId encoding, an end-to-end
handshake against a fake serial emulator, and the compat shim's
coordinate mappings (pinned to the upstream daemon's formulae so the
shim can't silently drift).
Project layout
src/pylumicube/
constants.py # protocol constants
cobs.py # in-place COBS (matches Java)
crc.py # CRC-16/CCITT-FALSE
framing.py # delimited frame builder + parser
link.py # SerialLink: handshake + sliding window
uavcan.py # messageId encoding/decoding
transport.py # Transport: transferIds, request/response
flat_dictionary.py # FlatDictionary TLV encoder/decoder
metadata.py # FieldSpec + hardcoded display schema
allocator.py # 3-stage dynamic node-ID allocator
node.py # LumiCube top-level API
display.py # Display module helpers
cli.py # lumicube-leds CLI
compat/
runtime.py # upstream-API shim: DisplayShim, stubs, build_globals, run_script
font.py # 5x7 ASCII bitmap font for scroll_text
cli.py # lumicube-run CLI
tests/ # offline pytest suite
scripts/
digital_clock.py # native-API clock example
plasma.py # native-API 3D plasma effect
original/ # the 17 upstream community scripts, run via lumicube-run
utilities/ # on-device debug + bring-up helpers
PROTOCOL.md # canonical protocol spec
CHANGELOG.md # versioned change history
LICENSE # GPL-3.0
Scripts and utilities
scripts/ is split between two kinds of programs:
- Upstream LumiCube community / user scripts under
scripts/original/(e.g.rainbow.py,binary_clock.py,lava_lamp.py,scrolling_clock.py) — written against the foundry-daemon globals. Run withlumicube-run scripts/original/<script.py>. - Native-API examples at the top level (
scripts/digital_clock.py,scripts/plasma.py) — usepylumicube.LumiCubedirectly and are launched as plain Python scripts (python scripts/<script.py>). They also work underlumicube-runviapylumicube.compat.open_or_use_hosted. The clock's optional weather feature depends on the[extras]install group; see the CLI section above.
utilities/ contains helper tools used during bring-up and reverse-
engineering (require a connected cube):
snapshot_hardware.py— walk every node'sENUMERATE_FIELDSschema and print one row per field. Useful as a reference dump.query_key.py <key> [...]— query a single field's metadata by absolute wire key.dump_metadata.py,find_leds.py,probe_set_fields.py— earlier debug helpers from the reverse-engineering work; kept for posterity.check_pi_uart.sh— verify a fresh Raspberry Pi OS image is ready to talk to the LumiCube via/dev/ttyAMA0(correct boot config, no serial console, no daemon installed, etc.).
Compatibility
- Python 3.11 or newer.
- Linux (tested on Raspberry Pi OS Bookworm). Other POSIX platforms
should work if you can open
/dev/ttyAMA0at 3 Mbaud. - LumiCube firmware as shipped with the AppImage 2.0.1 image. Older firmware revisions may use different field-key layouts.
Contributing
Pull requests welcome. Please:
- Keep changes covered by
pytest tests/. - Update
PROTOCOL.mdwhen you discover something new about the wire format. - Note user-visible changes in
CHANGELOG.md.
License
GPL-3.0-or-later. See LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file pylumicube-0.1.3.tar.gz.
File metadata
- Download URL: pylumicube-0.1.3.tar.gz
- Upload date:
- Size: 69.1 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
986c79ab8cf19f28497399a4d0688b8558a9b8c1ced251483f708e60c5f5fac7
|
|
| MD5 |
2c60b3c09f9a315f6dd43ca470d9dd38
|
|
| BLAKE2b-256 |
49a003ff544ad8b6a3eef5726050e15ee31d4d71341496272f3c134f1e26f437
|
Provenance
The following attestation bundles were made for pylumicube-0.1.3.tar.gz:
Publisher:
release.yml on chrislibuilds/pylumicube
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pylumicube-0.1.3.tar.gz -
Subject digest:
986c79ab8cf19f28497399a4d0688b8558a9b8c1ced251483f708e60c5f5fac7 - Sigstore transparency entry: 1679290536
- Sigstore integration time:
-
Permalink:
chrislibuilds/pylumicube@400a7be1d56f217ce5f81d2f85e33efc3784a551 -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/chrislibuilds
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@400a7be1d56f217ce5f81d2f85e33efc3784a551 -
Trigger Event:
push
-
Statement type:
File details
Details for the file pylumicube-0.1.3-py3-none-any.whl.
File metadata
- Download URL: pylumicube-0.1.3-py3-none-any.whl
- Upload date:
- Size: 57.4 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.13
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b43edbf060bb49607e3aecc080312f1f01ab05d3250606464c0bd619fd9177b1
|
|
| MD5 |
1a0a22fb455929c95ec75bf5485e92ed
|
|
| BLAKE2b-256 |
e6288e2b52b95acd99b8a05b734c50d54a70be6253f238a0a5d9875bcbf6abbb
|
Provenance
The following attestation bundles were made for pylumicube-0.1.3-py3-none-any.whl:
Publisher:
release.yml on chrislibuilds/pylumicube
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
pylumicube-0.1.3-py3-none-any.whl -
Subject digest:
b43edbf060bb49607e3aecc080312f1f01ab05d3250606464c0bd619fd9177b1 - Sigstore transparency entry: 1679290624
- Sigstore integration time:
-
Permalink:
chrislibuilds/pylumicube@400a7be1d56f217ce5f81d2f85e33efc3784a551 -
Branch / Tag:
refs/tags/v0.1.3 - Owner: https://github.com/chrislibuilds
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
release.yml@400a7be1d56f217ce5f81d2f85e33efc3784a551 -
Trigger Event:
push
-
Statement type: