Python server for bluetooth-controlled light wall using NeoPixels/NeoMatrix
Read from a gitignored .env via python-decouple:
| Key | Required | Purpose |
|---|---|---|
AUTH_USER |
yes | HTTP basic auth username |
AUTH_PASS |
yes | HTTP basic auth password |
PORT |
yes | Port to serve on |
FINNHUB_KEY |
no | Fallback price source for stock mode |
ALPHAVANTAGE_KEY |
no | Fallback history source for stock mode |
The two stock keys are only consulted if the primary source fails, so stock mode works without them.
/stock shows a 32 day sparkline for one ticker, with the symbol on the top row
of panels and the current price, in whole dollars, on the bottom. The baseline is
where the price sat 32 trading days ago; the filled area between it and the line
is the move since, green above and red below.
Prices show no cents. There is nowhere to put a decimal point -- a 1px dot falls
in the margin between panels, where a strut hides it -- and an implied decimal
misreads: 7448 looks like $7,448 rather than $74.48.
It is a still image. The wall repaints only when the data actually changes, and the server does not even send a frame unless it would look different, so most refreshes touch nothing at all. Nothing fades, pulses or animates -- at this brightness any movement is distracting rather than informative. For the same reason there is no pause control: there is nothing to pause.
Gains are green and losses red, at exact hues 120 and 0 with saturation 100 and low lightness. At this pixel density up-is-green is worth keeping, since it reads instantly where any other pairing has to be learned.
Lightness is the only knob for subduing this. Two things that seem like they should work, and do not:
- Nudging the hues off the primaries. "Emerald" 142 and "crimson" 355 put blue at 39% and 13% of the dominant channel, and the panels showed aquamarine and magenta. Blue bleed matters far more than the numbers suggest because the fill covers most of the display. Saturation 100 at exactly 120 and 0 is what holds the off-channels at zero.
- Desaturating. A dark colour desaturated is just grey -- an amber at
saturation 55, lightness 7 came out
(27, 19, 8)and read as cream. Hue survives darkness; saturation does not.
What made the first version look like Christmas decoration was lightness alone:
at lightness 38 the green was (0, 193, 0), near full output. It is 18 now.
Every colour is a named constant in the palette block above stockChart() in
lightwall.ino -- tuning the relative weights means editing a number there and
reflashing. The stockColors map in static/app.js mirrors it for the preview,
deliberately lighter than the literal values because an LED at close range is far
brighter than the same numbers on a monitor.
Overall brightness is a slider on /stock, not a compile-time constant, because
what looks right in person is far too bright for a webcam -- a camera has roughly
half the dynamic range of an eye, so a wall that is pleasant to sit next to blows
out on camera while auto-exposure drops your face into shadow. Dropping it for a
call and restoring it afterwards should not need two reflashes.
The value rides along in every frame (5-255) and persists to TinyDB. Moving the slider redraws from the cached prices rather than refetching, so it costs no API request. It fires on release rather than on drag, since each change is a serial write and a full repaint.
Note that dimming the wall is only half of the camera problem: the other half is putting some light on your face, so the two are within a few stops of each other. No camera setting fixes a bright wall in a dark room.
Prices come from Yahoo's chart endpoint, which needs no API key but does need a
browser User-Agent header -- without one it answers 429. One call returns both
the daily series and the current price. It is an undocumented endpoint, so
stock.py keeps a PROVIDERS list and falls back to Finnhub plus Alpha Vantage
if it ever changes shape. Note the price is roughly 15 minutes delayed.
A background thread refreshes every minute during market hours and every 15 minutes outside them. It only draws while the wall is actually on stock mode, so selecting another mode parks it rather than fighting for the display. If fetching fails it keeps the last good chart up but flags it, and the wall dims to half brightness rather than presenting stale prices as current.
To iterate on the layout without the hardware:
python3 stock.py NYT
python3 stock.py TSLA PFE GOOGL
The LED strips are powered separately from the Teensy, so the LEDs can be switched off and on while the sketch keeps running. The SK6812s hold their frame in their own registers and come back dark, which matters for the two static modes: they draw only when their data changes, so nothing would ever redraw and the wall would stay dark until the next update arrived.
Both therefore re-latch the existing frame on a slow timer -- see
refreshStaticFrame() in lightwall.ino. It re-sends the buffer the Teensy
already holds without recomputing anything, so the wall restores itself within a
fraction of a second of power returning.
Doing it in the firmware rather than the server also means it holds when nothing is arriving at all: overnight, at the weekend, or any time after the market closes and prices stop moving.
/sprites puts one 8x8 sprite in each of the wall's sixteen panels. This is the
one mode that works with the physical construction rather than around it: every
other mode treats the wooden struts between panels as damage to route around,
whereas here a sprite fits exactly inside one panel and the strut frames it.
Click a panel in the preview to cycle its sprite (shift-click to go back), then
send. Shuffle and reset buttons are there for quickly judging combinations. The
preview draws from the palette and bitmaps the server sends down from
sprites.py, which are the same bytes compiled into the firmware, so it cannot
drift from the wall.
Only the sixteen choices travel over the wire (<sprites,ABCDEFGHIJKLMNOP,155>,
28 characters). Pixels never do -- sixteen sprites of sixty-four pixels is 1024
values, far past what one serial frame can carry -- so the bitmaps live in
lightwall/lightwall/sprites.h and the server just says which goes where.
python3 tools/extract_sprites.py path/to/sheet.png
That writes both ../lightwall/lightwall/sprites.h and sprites.py from one
source, so the two cannot disagree. Edit CHOSEN in the tool to pick different
sprites from the sheet.
Two things the tool has to work around. The sheet is a resampled upscale rather than a clean pixel-doubling, so every logical pixel comes out a slightly different shade -- the heart arrived with 25 colours for its 25 pixels. Colours within a Manhattan distance of 78 are therefore merged onto one shared palette, which drops each sprite to 2-9 colours and happens to suit the wall, since frosted plexiglass loses subtle shading anyway. And cell boundaries are found by thresholding ink profiles, tuned to yield exactly the sheet's 15x8 grid; the tool fails loudly rather than guessing if a different sheet does not match.
Sprites were picked for clear silhouettes with useful negative space. Nearly solid ones were rejected deliberately: the floppy disk fills 62 of its 64 pixels and would read as a bright square through diffusion, not as an object.
- Water effect
- Text mode
- Weather integration
- 3-color gradient
- Horizontal gradient
- Icon/graphic support
- Rotating stock watchlist