
How this one got started
Every so often a project starts with a piece of hardware that somebody else has already given up on. This one started in a pile headed for the trash.
The part that caught my eye was the front panel off a Christie Phoenix Quad-T, which has been end-of-support for a while now. The chassis itself was done, but it has a neat front-panel — a half-rack wide and one rack unit tall textured plate with curved corners, a little window for a 128×32 OLED, a strip of holes for indicator LEDs, and a frosted hexagon dead center.
For a while it just sat on the bench while I tried to decide what it should be. The OLED and the LED board were still intact, which meant whatever I built could show both text and color. The obvious answer, the one I kept coming back to, was a build monitor. At work we’ve got a handful of Azure DevOps pipelines that I care about, and “is anything red right now?” is a question I answer by opening a browser tab and looking at a dashboard. A physical thing on a shelf that just tells me seemed like a better answer.
A couple of decisions fell out of that pretty quickly:
- The center hexagon was too good to waste on a single LED, so I added a 7-pixel NeoPixel Jewel behind it. Seven pixels, seven pipelines. To diffuse it I cut two layers out of a gallon milk jug and stacked them behind the hexagon window — I fully intended to replace that with something proper later, and of course I never did, because it works perfectly.
- It had to be wireless. A device that needs an Ethernet run is a device that lives wherever the Ethernet run is, and I wanted this thing to be able to sit anywhere.
- I have a bunch of small embedded boards lying around, and landed on using a Raspberry Pi Pico W running MicroPython. It’s cheap, tiny, has WiFi built in, and is quick to iterate on.

That’s the first time it really looked like something. I had it cycling through some colors for testing ahead of wiring up the real application, but everything worked and looked pretty sweet (albeit crazy bright which I ended up dialing down in software later).
Building the hardware
Here’s the whole thing on paper:

Parts in the panel
- Raspberry Pi Pico W — the brains, and about $6.
- Adafruit SSD1306 128×32 SPI OLED (#661) — this is the stock panel used in the panel’s window. It now runs off the Pico’s 3.3V output.
- Adafruit NeoPixel Stick — 8 WS2812 pixels, though only 4 of them line up with holes in the bezel. The other 4 are wired, powered, and permanently invisible (more on that later).
- An Adafruit NeoPixel Jewel — 7 pixels, and exactly the right size to fill the center hexagon.
- Two silicon diodes — cheap, and the entire reason this works. More below.
- A 220µF (or larger) electrolytic cap — across 5V/GND at the first pixel, to soak up power-on inrush.
- A 5V / 2A external supply — the Pico W pulls 100-150mA with WiFi spikes on top, and 11 NeoPixels at full white can hit ~660mA. 2A leaves comfortable headroom.
The OLED is straightforward SPI — clock, data, chip select, D/C, reset, plus 3.3V and ground straight off the Pico. The NeoPixels are a single data line off GPIO15, daisy chained from the panel’s original board into the Jewel.
Power is where it gets slightly more interesting. Rather than run the Pico off USB and the LEDs off a separate supply, I run one 5V supply and “T” it: one leg to the LED chain, one leg to the Pico’s VSYS pin. That’s pin 39, and it’s the input the onboard regulator is designed to accept external power on. Not VBUS (pin 40) — that one’s tied to the USB rail, and I learned that feeding it can damage the Pi board. Splitting a single supply this way also hands you a shared ground for free, which the NeoPixels very much require. And because there’s a diode between VBUS and VSYS on the Pico, you can leave USB plugged in at the same time while you’re developing without the two supplies fighting which ended up being massively helpful while tweaking the software on this thing.
The diode trick
Here’s the one that I spent a lot of time on and almost gave up over. WS2812 pixels want a logic-high somewhere north of 0.7 × VDD. The Pico’s GPIO is 3.3V. On a true 5V rail, that’s under spec, and the symptom is maddening: the pixels get data — I could see it on my oscilloscope — but they mostly ignore it. Sometimes the first pixel lights, usually nothing, and sometimes it worked fine for a few minutes and then stopped.
The fix that finally worked was two silicon diodes in series in the LED’s +5V feed — not in the data line — dropping the pixel rail to roughly 4.2-4.4V and giving that 3.3V signal actual margin. I tried one diode first, and it almost worked, which is the worst outcome. Forward voltage sags at low current, and this chain spends most of its life dim or idle, so a single ~0.6-0.7V drop kept wandering back out of spec. Two diodes, rock solid ever since. This is apparently common knowledge for smart people, and I saw a lot of posts online about it (after circling in on it by chatting with ChatGPT).
Worth checking your supply too, incidentally — some USB bricks like the one I’m using measure 5.4-5.5V under light load, which makes the margin problem worse than the initial math would suggest.
About that soldering

The pictures above and below show the wired-up mess at the back of the panel, and admittedly it’s messy. Every joint on that Pico is a small monument to enthusiasm exceeding technique — blobby, over-tinned, and held in position by optimism and hot glue. In my defense: the panel’s backing plate is the mounting surface, everything has to sit flat enough to still fit behind a curved bezel, and there was not a lot of room in there to be elegant.

It doesn’t look like much, but it hasn’t dropped a connection yet, so I’ve decided it’s a feature.

I did most of the ugly experimentation using a breadboard — which is where I dialed in that diode business — before committing anything to the panel itself. This picture above was the last part where I was figuring out the diode thing.
3D printing a chassis
The panel is a curved face with nothing behind it, so it needed a body. I designed one in Tinkercad and printed it on my Prusa Core One, which I will take any excuse to mention because I love that printer. Big enclosed build volume, quiet, and it just prints — I’ve stopped thinking about first layers entirely, which is the highest compliment I can pay a 3D printer.

The design is deliberately dumb: an open-backed tray that the panel drops onto face-out, with all the electronics hanging down inside it. The panel is held on with velcro rather than screws, which means getting back to the Pico’s USB port is a matter of peeling the face off.
It took three revisions to get right, which I think is about par:

- Revision 1 was the naive version — a tray, a hole in the bottom for the power connector, and two corner tabs to hold velcro. It worked, and immediately told me what was wrong with it.
- Revision 2 added “diamond” vent holes along the walls so the Pico and the LED chain aren’t sealed in a black plastic box (I noticed it getting warm with the first rev), mounting brackets on the back wall so it can hang, and a lot more flat surface area for the velcro to actually grab.
- Revision 3 was one specific annoyance: the back-wall brackets only worked with the power connector oriented one way. Reworked them so it hangs correctly with the cable exiting either direction.
The STL is in the repo under case-stl/ if you happen to have a Quad-T front panel lying around, which, statistically, you do not.
Building the software
This is the part I actually enjoyed, and the part where the design decisions mattered.
The firmware should be as dumb as possible
The single most important constraint came from the case: updating this thing is a pain. To reflash it I have to peel the panel off the case, find the USB cable, plug into the Pico, push files with mpremote, and put it all back together. That’s a five minute round trip for even a simple change.
In addition to the physical update process, the reality is that this is a simple IoT device that I might repurpose for anything, and instead of writing tedious code specific for one use case on the target, making it a wireless display and allowing external code to control it feels like the more natural fit. In my case I have a homelab Kubernetes cluster providing more than enough infrastructure to provide messaging and application hosting for the Azure build monitor today and whatever else I might want tomorrow.
So the firmware knows nothing about builds, and it doesn’t know what Azure DevOps is. It stores no credentials beyond the WiFi password, makes no outbound calls to anything but the local MQTT broker, and listens on no inbound ports. It is a generic display device: it connects to WiFi, subscribes to one MQTT topic, and renders whatever JSON shows up there. All the interesting logic lives somewhere I can redeploy in thirty seconds.
That MQTT message contract is the whole design, and it looks roughly like this:
{
"display": {
"drawMode": "text2Line",
"textLine1": "EchoCommon",
"textLine2": "Success: 2 Days ago",
"textLine1Align": "left",
"textLine2AutoScroll": true,
"oledBurnInProtectionMode": "invertDisplay",
"oledBurnInProtectionInterval": 60
},
"leds": [
{ "r": 0, "g": 255, "b": 0 },
{ "r": 255, "g": 0, "b": 0, "displayMode": "blinkThruBlack", "blinkDuration": 0.75 }
],
"transitionDuration": 2.5,
"transitionType": "smooth",
"pixelBrightness": 1.0
}
The goal was high-level but rich — one message should be able to express an entire visual state, and the device should own everything about how that state gets rendered over time. Some of what it can do:
- Text, one or two lines, each independently left-aligned or centered, and each able to ping-pong scroll if it’s wider than the screen (with a dwell at each end, because scrolling that instantly reverses looks nervous).
- Raw pixels — you can also just hand it 512 bytes of hex and paint the 128×32 framebuffer yourself.
- Per-pixel color as a simple array. Position 0 is the first externally visible LED.
- Transitions —
immediate,smooth(blend from current to target), orthruBlack(fade down, then up into the new color), with a duration. - Per-pixel blinking —
blinkfor hard on/off, orblinkThruBlackfor a gentle pulse, which is what “this pipeline is running right now” uses. - Global brightness, as a single multiplier applied at the moment of writing to hardware.
- OLED burn-in mitigation, because this display is on 24/7 and may showing nearly the same thing which can get burned into the display permanently. The protocol supports configuration to automatically can invert the panel colors, nudge the text around DVD-logo style, or do nothing.
A few details are worth calling out that evolved organically while I was working on this project. First, the burn-in inversion is motion aware — it only flips once the screen has held genuinely static content for a while, and any new content immediately un-inverts. Since the build monitor rotates through builds anyway, this just looked annoying and served no purpose. The one caveat is that if for some reason the application driving display messages stops updating the panel, the panel will protect itself automatically by using the invertion technique. Second call out is for the hidden LEDs: the panel’s board has 8 pixels but only 4 are visible, so the firmware keeps a map from logical position to physical position and never exposes the hidden ones over MQTT at all. Everything upstream sees a clean chain of 11 addressable pixels and has no idea there’s anything else on the wire. Had I thought about this before hot glueing everything together I would have popped the NeoPixel strip out and drilled 4 extra holes, but it’s simply not worth it to me anymore (but since it’s software configurable if I make another one down the road it’s a simple code change to re-enable it).
The device also publishes its last applied state back to a retained state topic, plus an online/offline status topic wired up as an MQTT Last Will — so anything that cares can tell at a glance whether the panel is actually alive.
Getting real build data to it

With the panel reduced to “renders JSON from a topic”, the rest is a plumbing problem:
- Azure DevOps fires a
run.statechangedservice hook whenever a build changes state. - n8n catches that webhook and republishes it onto my MQTT broker. n8n is already running in my homelab and is very good at exactly this sort of glue, so it saved me from writing and deploying my own webhook receiver just to turn one POST into one MQTT message.
- A small Go service (
azureBuildMonitor, containerized and running in my k8s cluster) subscribes to that topic, keeps track of the world, and publishes display commands to the panel.
The Go service is where all the real behavior lives, and it works on two timescales at once. It does a full refresh every hour — one API call that fetches the latest 4 builds for every pipeline and overwrites its in-memory view with the authoritative answer. That’s the drift-correction backstop. Then, for responsiveness, every webhook event patches that view in place immediately.
The webhook payload alone turned out not to be trustworthy enough to use directly, which was a fun little discovery: it carries no branch name, and its pipelineId field is actually the run ID rather than the build definition ID that everything else is keyed on. So each event triggers exactly one “get build by ID” call to resolve the truth, and that result is what lands in the store. Events make it fast; the hourly sweep makes it correct.
What you actually see
Every 5 seconds the service publishes a fresh command, and that’s what drives the little show:
- The 7 hexagon pixels are the pipelines — one each, in a fixed order. Each one always shows its own true status color, so the hexagon as a whole is an at-a-glance health check of everything at once. Green is good, red is failed, blue means running (and pulses through black so it’s obviously live), gray is cancelled.
- The pipeline being highlighted right now is shown at full brightness while the other six are scaled down to about a quarter. It cycles.
- The OLED shows the highlighted pipeline’s name on line 1, and cycles line 2 between its status (“Success: 2 Days ago”) and its branch. Once a pipeline has shown all its messages, the highlight moves to the next pipeline and the hexagon follows along.
- The 4-pixel strip on the right is the highlighted pipeline’s last four builds, newest first. Three green and a red tells you something quite different from four reds, and it’s the fastest possible answer to “is this a blip or a trend?”
Colors and blink timings are all configurable per status, and — this detail matters more than it sounds like it should — configurable separately for the hexagon and the strip. The hexagon sits behind two layers of milk jug and the strip pixels don’t, so the same RGB triple reads as two completely different colors depending on which group it lands on.
Two bugs that weren’t where I thought they were
The panel would connect to MQTT and then get its socket killed immediately, with the broker logging Bad socket read/write and the client reporting a CONNACK “identifier rejected”. I spent an embarrassing amount of time on client IDs. The actual cause: umqtt’s default keepalive is 0, and this particular mosquitto build accepts the CONNECT and then hangs up. Send a real keepalive and ping on it, and everything’s fine.
Worse — and later — well-formed clients started getting rejected with strange, inconsistent errors again. This time it genuinely wasn’t my code. My broker’s Kubernetes Service had externalTrafficPolicy: Cluster, which let MetalLB’s L2 speaker answer ARP from a node that wasn’t the one running the mosquitto pod, forcing an extra kube-proxy SNAT hop that corrupted connections from at least one client. One word changed to Local and it’s been quiet ever since. I’d already rewritten a working MQTT library twice by then, chasing a bug that lived three layers below it.

So where are we, and what’s next?

This one landed a lot better than I expected. Most of my projects are fun first and useful second, if at all — this one is genuinely, boringly useful. It sits on a shelf above my little cluster of boards, and every morning I glance at it on my way past and know instantly whether anything needs my attention. No tab, no dashboard, no login.
The unexpected part is what that’s done to my behavior. When build health is a thing sitting in my peripheral vision, a red pixel is a small irritation that I want to make go away, and I fix things the same day instead of discovering them on Thursday. I did not build this expecting to become a more disciplined engineer, and yet.
Next up, most likely: moving off my local broker onto a cloud-hosted message queue, so the panel doesn’t need to live on the same network as the service driving it. Then I can hang it on my cubicle wall at the office, which was sort of the point all along. Or I’ll just build a second one — assuming I can find another junk front panel, which is honestly the hard part.
Everything’s up on GitHub if you want a closer look — firmware, the Go service, the tester CLI, and the case STL:
github.com/dsmithson/quad-t-mqtt-display
If you run into problems or have ideas, filing an issue on the repo is by far the best way to reach me — I have a bad habit of not checking blog comments for months at a time.









































































