I was at Action (the European equivalent of a dollar store for nerds) when I spotted a B.K. Light LED Pixel Board — a 32×32 RGB LED matrix with Bluetooth, for about 15 euros. The kind of thing you buy first and figure out later.
The official Android app works fine. You type text, pick colors, done.
BOOORIIING
I run a home server with Home Assistant, monitoring, bots — the whole nine yards. Surely I could drive this thing from Python.
Spoiler: I could. But the journey was more interesting than the destination.
Step 1: Finding the device (harder than expected)
The LED matrix uses Bluetooth Low Energy (BLE). My NUC has a Bluetooth adapter, so I started scanning:
bluetoothctl scan on
Dozens of devices showed up. None called “iPIXEL” or “LED” — just cryptic MAC addresses. The problem? My phone’s app was still connected, and BLE devices only advertise when they’re not paired.
After force-closing the Android app, a new device appeared:
LED_BLE_f3f75468 (0D:C5:F3:F7:54:68)
Got you.
Step 2: The Home Assistant detour
I first tried the ha-ipixel-color integration for Home Assistant. It was already installed on my setup and had all the right entities — text.display, light.text_color, select.mode, the works.
I sent “hello” via the HA REST API:
curl -X POST -H "Authorization: Bearer $TOKEN" \
-d '{"entity_id":"text.display","value":"hello"}' \
http://nuc:8123/api/services/text/set_value
It worked! For about 2 seconds. Then the display went back to showing “ciao” (from the phone app). The problem: the Android app, HA, and the CLI were all fighting for the same BLE connection. BLE only allows one active connection at a time.
Lesson learned: pick one master and stick with it.
Step 3: Going direct with pypixelcolor
I removed the HA integration and went with pypixelcolor — a Python library and CLI that speaks the iPIXEL protocol directly.
uv tool install pypixelcolor
pypixelcolor --scan
# Found 1 device(s):
# - LED_BLE_f3f75468 (0D:C5:F3:F7:54:68)
First text send:
pypixelcolor -a 0D:C5:F3:F7:54:68 -c send_text "hello"
It worked… sort of. The 32×32 display showed “he”, then “ll”, then “o” in separate frames. The default font was too large for the tiny matrix.

Step 4: The Pillow approach
Instead of fighting with built-in fonts, I generated a 32×32 PNG image with Pillow and sent that:
from PIL import Image, ImageDraw, ImageFont
img = Image.new("RGB", (32, 32), (0, 0, 0))
draw = ImageDraw.Draw(img)
font = ImageFont.load_default(size=14)
text = "hello"
bbox = draw.textbbox((0, 0), text, font=font)
x = (32 - (bbox[2] - bbox[0])) // 2
y = (32 - (bbox[3] - bbox[1])) // 2
draw.text((x, y), text, fill=(0, 255, 0), font=font)
img.save("/tmp/pixel_text.png")
The image approach gave us full control over rendering — but the orientation was wrong. The text came out rotated 90°:

After some trial and error with set_orientation (values 0 through 3), orientation 2 was the winner:
pypixelcolor -a 0D:C5:F3:F7:54:68 -c set_orientation 2 -c send_image /tmp/pixel_text.png
One frame. Centered. Perfect control over every pixel.

Step 5: The moment of nerd insanity
Here’s where it gets unhinged.
I run Claude (an AI coding agent) directly on my NUC via the terminal. Claude was doing all of this — scanning for BLE devices, sending text, fixing orientations. But every time it sent something to the display, it had to ask me: “What do you see?”
I’d walk to the shelf, look at the display, walk back, and type the answer. Then I thought:
“There is a webcam connected to this machine. Can Claude use it? 🤪”
Yes. An Elgato Facecam, pointing right at the shelf where the LED matrix sits.
So Claude started doing this:
# Take a photo
ffmpeg -f v4l2 -video_size 1920x1080 -i /dev/video0 \
-frames:v 1 -update 1 -y webcam_check.jpg
# Crop to the shelf area
ffmpeg -i webcam_check.jpg -vf "crop=600:400:600:200" \
-update 1 -y webcam_crop.jpg
Then it would look at the photo itself to verify the display was showing the right thing. A complete feedback loop: AI sends text to LED matrix via BLE, takes a webcam photo, analyzes the image, adjusts if needed.


The first few attempts were hilarious. The webcam crop was off, and instead of the LED display, Claude got a photo of… my bald head. (“Ti ho beccato davanti alla webcam!” it said. Roughly: “Caught you in front of the webcam!”)
The final result
I now have a simple pixel command on my NUC:
pixel "Hello World" # white text
pixel "Revenue: 42" --color green # green text
pixel --clock 3 # clock mode
pixel --brightness 80 # adjust brightness
pixel --check # webcam verification
Under the hood, it generates a 32×32 Pillow image with auto-sizing text and sends it via pypixelcolor. The --check flag takes a webcam photo so I (or Claude) can verify the display without getting up.
What I learned
- BLE is a jealous protocol. One connection at a time. If your phone app is connected, nothing else can talk to the device.
- Don’t fight the font rendering. The
send_textcommand works, but for a 32×32 matrix, generating your own image gives you total control. - AI agents are better with eyes. Giving Claude access to the webcam turned a “try and ask” loop into a fully autonomous feedback cycle. It could send, verify, and fix — without me leaving my chair.
- The best projects start with bad impulse buys. A $15 LED matrix from a discount store, driven by a Python script, verified by an AI looking through a webcam. This is what peak home automation looks like. Or peak insanity. Possibly both.
The LED matrix is a B.K. Light Pixel Board (iPIXEL Color compatible), controlled via pypixelcolor. The AI agent is Claude Code running on an Intel NUC. The webcam is an Elgato Facecam. The camel figurine is from Egypt and has no role in the automation pipeline, though I haven’t ruled it out.