{"id":4130,"date":"2026-08-13T07:34:17","date_gmt":"2026-08-13T14:34:17","guid":{"rendered":"https:\/\/www.knightware.net\/?p=4130"},"modified":"2026-08-13T12:19:55","modified_gmt":"2026-08-13T19:19:55","slug":"building-a-build-monitor-out-of-a-salvaged-quad-t-front-panel","status":"publish","type":"post","link":"https:\/\/www.knightware.net\/?p=4130","title":{"rendered":"Building an Azure DevOps Build Pipeline Monitor from Salvage"},"content":{"rendered":"\n<h2 class=\"wp-block-heading\">How this one got started<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The part that caught my eye was the front panel off a Christie <a href=\"https:\/\/www.christiedigital.com\/products\/end-of-support\/EOS-controllers\/phoenix-quad-t\/\">Phoenix Quad-T<\/a>, which has been end-of-support for a while now. The chassis itself was done, but it has a neat front-panel \u2014 a half-rack wide and one rack unit tall textured plate with curved corners, a little window for a 128&#215;32 OLED, a strip of holes for indicator LEDs, and a frosted hexagon dead center.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">For a while it just sat on the bench while I tried to decide what it should <em>be<\/em>. 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&#8217;ve got a handful of Azure DevOps pipelines that I care about, and &#8220;is anything red right now?&#8221; is a question I answer by opening a browser tab and looking at a dashboard. A physical thing on a shelf that just <em>tells<\/em> me seemed like a better answer.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">A couple of decisions fell out of that pretty quickly:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li>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 \u2014 I fully intended to replace that with something proper later, and of course I never did, because it works perfectly.<\/li>\n\n\n\n<li>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.<\/li>\n\n\n\n<li>I have a bunch of small embedded boards lying around, and landed on using a Raspberry Pi Pico W running MicroPython. It&#8217;s cheap, tiny, has WiFi built in, and is quick to iterate on.<\/li>\n<\/ul>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1920\" height=\"1440\" src=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-front.jpg\" alt=\"Early bring-up on the bench\" class=\"wp-image-4132\" srcset=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-front.jpg 1920w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-front-300x225.jpg 300w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-front-1024x768.jpg 1024w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-front-768x576.jpg 768w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-front-1536x1152.jpg 1536w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-front-624x468.jpg 624w\" sizes=\"auto, (max-width: 1920px) 100vw, 1920px\" \/><figcaption class=\"wp-element-caption\">Early bring-up on the dining room table<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">That&#8217;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).<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Building the hardware<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;s the whole thing on paper:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"2560\" height=\"1646\" src=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-wiring-scaled.png\" alt=\"Wiring diagram\" class=\"wp-image-4133\" srcset=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-wiring-scaled.png 2560w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-wiring-300x193.png 300w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-wiring-1024x658.png 1024w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-wiring-768x494.png 768w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-wiring-1536x987.png 1536w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-wiring-2048x1317.png 2048w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-wiring-624x401.png 624w\" sizes=\"auto, (max-width: 2560px) 100vw, 2560px\" \/><figcaption class=\"wp-element-caption\">Wiring diagram<\/figcaption><\/figure>\n\n\n\n<h3 class=\"wp-block-heading\">Parts in the panel<\/h3>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Raspberry Pi Pico W<\/strong> \u2014 the brains, and about $6.<\/li>\n\n\n\n<li><strong><a href=\"https:\/\/www.adafruit.com\/product\/661\" data-type=\"link\" data-id=\"https:\/\/www.adafruit.com\/product\/661\">Adafruit SSD1306 128&#215;32 SPI OLED (#661)<\/a><\/strong> \u2014 this is the stock panel used in the panel&#8217;s window. It now runs off the Pico&#8217;s 3.3V output.<\/li>\n\n\n\n<li><strong><a href=\"https:\/\/www.adafruit.com\/product\/1426\" data-type=\"link\" data-id=\"https:\/\/www.adafruit.com\/product\/1426\">Adafruit NeoPixel Stick<\/a><\/strong> \u2014 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).<\/li>\n\n\n\n<li><strong>An <a href=\"https:\/\/www.adafruit.com\/product\/2226\">Adafruit NeoPixel Jewel<\/a><\/strong> \u2014 7 pixels, and exactly the right size to fill the center hexagon.<\/li>\n\n\n\n<li><strong>Two silicon diodes<\/strong> \u2014 cheap, and the entire reason this works. More below.<\/li>\n\n\n\n<li><strong>A 220\u00b5F (or larger) electrolytic cap<\/strong> \u2014 across 5V\/GND at the first pixel, to soak up power-on inrush.<\/li>\n\n\n\n<li><strong>A 5V \/ 2A external supply<\/strong> \u2014 the Pico W pulls 100-150mA with WiFi spikes on top, and 11 NeoPixels at full white can hit ~660mA. 2A leaves comfortable headroom.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The OLED is straightforward SPI \u2014 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&#8217;s original board into the Jewel.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 &#8220;T&#8221; it: one leg to the LED chain, one leg to the Pico&#8217;s <strong>VSYS<\/strong> pin. That&#8217;s pin 39, and it&#8217;s the input the onboard regulator is designed to accept external power on. Not VBUS (pin 40) \u2014 that one&#8217;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&#8217;s a diode between VBUS and VSYS on the Pico, you can leave USB plugged in at the same time while you&#8217;re developing without the two supplies fighting which ended up being massively helpful while tweaking the software on this thing.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The diode trick<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Here&#8217;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 \u00d7 VDD. The Pico&#8217;s GPIO is 3.3V. On a true 5V rail, that&#8217;s <em>under<\/em> spec, and the symptom is maddening: the pixels get data \u2014 I could see it on my oscilloscope \u2014 but they mostly ignore it. Sometimes the first pixel lights, usually nothing, and sometimes it worked fine for a few minutes and then stopped.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The fix that finally worked was <strong>two silicon diodes in series in the LED&#8217;s +5V feed<\/strong> \u2014 not in the data line \u2014 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 <em>almost<\/em> 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).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Worth checking your supply too, incidentally \u2014 some USB bricks like the one I&#8217;m using measure 5.4-5.5V under light load, which makes the margin problem worse than the initial math would suggest.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">About that soldering<\/h3>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1920\" height=\"1148\" src=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembly-mess.jpg\" alt=\"The board on the bench, mid-assembly\" class=\"wp-image-4134\" srcset=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembly-mess.jpg 1920w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembly-mess-300x179.jpg 300w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembly-mess-1024x612.jpg 1024w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembly-mess-768x459.jpg 768w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembly-mess-1536x918.jpg 1536w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembly-mess-624x373.jpg 624w\" sizes=\"auto, (max-width: 1920px) 100vw, 1920px\" \/><figcaption class=\"wp-element-caption\">The board on the bench, mid-assembly<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">The pictures above and below show the wired-up mess at the back of the panel, and admittedly it&#8217;s messy.  Every joint on that Pico is a small monument to enthusiasm exceeding technique \u2014 blobby, over-tinned, and held in position by optimism and hot glue. In my defense: the panel&#8217;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.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1920\" height=\"1168\" src=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-back-of-board.jpg\" alt=\"The back of the assembled panel\" class=\"wp-image-4135\" srcset=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-back-of-board.jpg 1920w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-back-of-board-300x183.jpg 300w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-back-of-board-1024x623.jpg 1024w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-back-of-board-768x467.jpg 768w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-back-of-board-1536x934.jpg 1536w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-back-of-board-624x380.jpg 624w\" sizes=\"auto, (max-width: 1920px) 100vw, 1920px\" \/><figcaption class=\"wp-element-caption\">The back of the assembled panel<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">It doesn&#8217;t look like much, but it hasn&#8217;t dropped a connection yet, so I&#8217;ve decided it&#8217;s a feature.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1920\" height=\"1440\" src=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-back.jpg\" alt=\"Breadboard testing with a spare display\" class=\"wp-image-4136\" srcset=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-back.jpg 1920w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-back-300x225.jpg 300w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-back-1024x768.jpg 1024w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-back-768x576.jpg 768w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-back-1536x1152.jpg 1536w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-back-624x468.jpg 624w\" sizes=\"auto, (max-width: 1920px) 100vw, 1920px\" \/><figcaption class=\"wp-element-caption\">Breadboard testing with a spare (unrelated) display<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">I did most of the ugly experimentation using a breadboard \u2014 which is where I dialed in that diode business \u2014 before committing anything to the panel itself.  This picture above was the last part where I was figuring out the diode thing.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">3D printing a chassis<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">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 \u2014 I&#8217;ve stopped thinking about first layers entirely, which is the highest compliment I can pay a 3D printer.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1345\" height=\"475\" src=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-quad-t-front-panel-casing.png\" alt=\"The case design in Tinkercad\" class=\"wp-image-4137\" srcset=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-quad-t-front-panel-casing.png 1345w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-quad-t-front-panel-casing-300x106.png 300w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-quad-t-front-panel-casing-1024x362.png 1024w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-quad-t-front-panel-casing-768x271.png 768w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-quad-t-front-panel-casing-624x220.png 624w\" sizes=\"auto, (max-width: 1345px) 100vw, 1345px\" \/><figcaption class=\"wp-element-caption\">The case design in Tinkercad<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">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&#8217;s USB port is a matter of peeling the face off.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">It took three revisions to get right, which I think is about par:<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1920\" height=\"903\" src=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-case-revisions.jpg\" alt=\"Three printed revisions with notes\" class=\"wp-image-4138\" srcset=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-case-revisions.jpg 1920w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-case-revisions-300x141.jpg 300w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-case-revisions-1024x482.jpg 1024w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-case-revisions-768x361.jpg 768w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-case-revisions-1536x722.jpg 1536w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-case-revisions-624x293.jpg 624w\" sizes=\"auto, (max-width: 1920px) 100vw, 1920px\" \/><figcaption class=\"wp-element-caption\">Three printed revisions with notes<\/figcaption><\/figure>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Revision 1<\/strong> was the naive version \u2014 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.<\/li>\n\n\n\n<li><strong>Revision 2<\/strong> added &#8220;diamond&#8221; vent holes along the walls so the Pico and the LED chain aren&#8217;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.<\/li>\n\n\n\n<li><strong>Revision 3<\/strong> 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.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">The STL is in the repo under <code>case-stl\/<\/code> if you happen to have a Quad-T front panel lying around, which, statistically, you do not.<\/p>\n\n\n\n<h2 class=\"wp-block-heading\">Building the software<\/h2>\n\n\n\n<p class=\"wp-block-paragraph\">This is the part I actually enjoyed, and the part where the design decisions mattered.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">The firmware should be as dumb as possible<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The single most important constraint came from the case: <strong>updating this thing is a pain.<\/strong> To reflash it I have to peel the panel off the case, find the USB cable, plug into the Pico, push files with <code>mpremote<\/code>, and put it all back together. That&#8217;s a five minute round trip for even a simple change.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">So the firmware knows <em>nothing<\/em> about builds, and it doesn&#8217;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 <strong>generic display device<\/strong>: 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">That MQTT message contract is the whole design, and it looks roughly like this:<\/p>\n\n\n\n<pre class=\"wp-block-code\"><code>{\n  \"display\": {\n    \"drawMode\": \"text2Line\",\n    \"textLine1\": \"EchoCommon\",\n    \"textLine2\": \"Success: 2 Days ago\",\n    \"textLine1Align\": \"left\",\n    \"textLine2AutoScroll\": true,\n    \"oledBurnInProtectionMode\": \"invertDisplay\",\n    \"oledBurnInProtectionInterval\": 60\n  },\n  \"leds\": &#91;\n    { \"r\": 0, \"g\": 255, \"b\": 0 },\n    { \"r\": 255, \"g\": 0, \"b\": 0, \"displayMode\": \"blinkThruBlack\", \"blinkDuration\": 0.75 }\n  ],\n  \"transitionDuration\": 2.5,\n  \"transitionType\": \"smooth\",\n  \"pixelBrightness\": 1.0\n}<\/code><\/pre>\n\n\n\n<p class=\"wp-block-paragraph\">The goal was high-level but rich \u2014 one message should be able to express an entire visual state, and the device should own everything about <em>how<\/em> that state gets rendered over time. Some of what it can do:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>Text<\/strong>, one or two lines, each independently left-aligned or centered, and each able to ping-pong scroll if it&#8217;s wider than the screen (with a dwell at each end, because scrolling that instantly reverses looks nervous).<\/li>\n\n\n\n<li><strong>Raw pixels<\/strong> \u2014 you can also just hand it 512 bytes of hex and paint the 128&#215;32 framebuffer yourself.<\/li>\n\n\n\n<li><strong>Per-pixel color<\/strong> as a simple array. Position 0 is the first externally visible LED.<\/li>\n\n\n\n<li><strong>Transitions<\/strong> \u2014 <code>immediate<\/code>, <code>smooth<\/code> (blend from current to target), or <code>thruBlack<\/code> (fade down, then up into the new color), with a duration.<\/li>\n\n\n\n<li><strong>Per-pixel blinking<\/strong> \u2014 <code>blink<\/code> for hard on\/off, or <code>blinkThruBlack<\/code> for a gentle pulse, which is what &#8220;this pipeline is running right now&#8221; uses.<\/li>\n\n\n\n<li><strong>Global brightness<\/strong>, as a single multiplier applied at the moment of writing to hardware.<\/li>\n\n\n\n<li><strong>OLED burn-in mitigation<\/strong>, 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.<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">A few details are worth calling out that evolved organically while I was working on this project. First, the burn-in inversion is <em>motion aware<\/em> \u2014 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&#8217;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&#8217;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&#8217;s simply not worth it to me anymore (but since it&#8217;s software configurable if I make another one down the road it&#8217;s a simple code change to re-enable it).<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The device also publishes its last applied state back to a retained <code>state<\/code> topic, plus an <code>online<\/code>\/<code>offline<\/code> <code>status<\/code> topic wired up as an MQTT Last Will \u2014 so anything that cares can tell at a glance whether the panel is actually alive.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Getting real build data to it<\/h3>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"2560\" height=\"1426\" src=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-data-flow-scaled.png\" alt=\"How a build result reaches the panel\" class=\"wp-image-4139\" srcset=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-data-flow-scaled.png 2560w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-data-flow-300x167.png 300w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-data-flow-1024x571.png 1024w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-data-flow-768x428.png 768w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-data-flow-1536x856.png 1536w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-data-flow-2048x1141.png 2048w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-diagram-data-flow-624x348.png 624w\" sizes=\"auto, (max-width: 2560px) 100vw, 2560px\" \/><figcaption class=\"wp-element-caption\">How a build result reaches the panel<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">With the panel reduced to &#8220;renders JSON from a topic&#8221;, the rest is a plumbing problem:<\/p>\n\n\n\n<ol class=\"wp-block-list\">\n<li><strong>Azure DevOps<\/strong> fires a <code>run.statechanged<\/code> service hook whenever a build changes state.<\/li>\n\n\n\n<li><strong>n8n<\/strong> 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.<\/li>\n\n\n\n<li><strong>A small Go service<\/strong> (<code>azureBuildMonitor<\/code>, containerized and running in my k8s cluster) subscribes to that topic, keeps track of the world, and publishes display commands to the panel.<\/li>\n<\/ol>\n\n\n\n<p class=\"wp-block-paragraph\">The Go service is where all the real behavior lives, and it works on two timescales at once. It does a <strong>full refresh every hour<\/strong> \u2014 one API call that fetches the latest 4 builds for every pipeline and overwrites its in-memory view with the authoritative answer. That&#8217;s the drift-correction backstop. Then, for responsiveness, every webhook event <strong>patches<\/strong> that view in place immediately.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">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 <code>pipelineId<\/code> field is actually the <em>run<\/em> ID rather than the build definition ID that everything else is keyed on. So each event triggers exactly one &#8220;get build by ID&#8221; call to resolve the truth, and that result is what lands in the store. Events make it fast; the hourly sweep makes it correct.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">What you actually see<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">Every 5 seconds the service publishes a fresh command, and that&#8217;s what drives the little show:<\/p>\n\n\n\n<ul class=\"wp-block-list\">\n<li><strong>The 7 hexagon pixels<\/strong> are the pipelines \u2014 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&#8217;s obviously <em>live<\/em>), gray is cancelled.<\/li>\n\n\n\n<li><strong>The pipeline being highlighted right now<\/strong> is shown at full brightness while the other six are scaled down to about a quarter. It cycles.<\/li>\n\n\n\n<li><strong>The OLED<\/strong> shows the highlighted pipeline&#8217;s name on line 1, and cycles line 2 between its status (&#8220;Success: 2 Days ago&#8221;) and its branch. Once a pipeline has shown all its messages, the highlight moves to the next pipeline and the hexagon follows along.<\/li>\n\n\n\n<li><strong>The 4-pixel strip<\/strong> on the right is the highlighted pipeline&#8217;s last four builds, newest first. Three green and a red tells you something quite different from four reds, and it&#8217;s the fastest possible answer to &#8220;is this a blip or a trend?&#8221;<\/li>\n<\/ul>\n\n\n\n<p class=\"wp-block-paragraph\">Colors and blink timings are all configurable per status, and \u2014 this detail matters more than it sounds like it should \u2014 configurable <em>separately<\/em> for the hexagon and the strip. The hexagon sits behind two layers of milk jug and the strip pixels don&#8217;t, so the same RGB triple reads as two completely different colors depending on which group it lands on.<\/p>\n\n\n\n<h3 class=\"wp-block-heading\">Two bugs that weren&#8217;t where I thought they were<\/h3>\n\n\n\n<p class=\"wp-block-paragraph\">The panel would connect to MQTT and then get its socket killed immediately, with the broker logging <code>Bad socket read\/write<\/code> and the client reporting a CONNACK &#8220;identifier rejected&#8221;. I spent an embarrassing amount of time on client IDs. The actual cause: umqtt&#8217;s default keepalive is <code>0<\/code>, and this particular mosquitto build accepts the CONNECT and then hangs up. Send a real keepalive and ping on it, and everything&#8217;s fine.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Worse \u2014 and later \u2014 well-formed clients started getting rejected with strange, inconsistent errors again. This time it genuinely wasn&#8217;t my code. My broker&#8217;s Kubernetes Service had <code>externalTrafficPolicy: Cluster<\/code>, which let MetalLB&#8217;s L2 speaker answer ARP from a node that wasn&#8217;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 <code>Local<\/code> and it&#8217;s been quiet ever since. I&#8217;d already rewritten a working MQTT library twice by then, chasing a bug that lived three layers below it.<\/p>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1920\" height=\"575\" src=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-oled.jpg\" alt=\"MQTT Connected\" class=\"wp-image-4140\" srcset=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-oled.jpg 1920w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-oled-300x90.jpg 300w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-oled-1024x307.jpg 1024w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-oled-768x230.jpg 768w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-oled-1536x460.jpg 1536w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-testing-oled-624x187.jpg 624w\" sizes=\"auto, (max-width: 1920px) 100vw, 1920px\" \/><figcaption class=\"wp-element-caption\">MQTT Connected<\/figcaption><\/figure>\n\n\n\n<h2 class=\"wp-block-heading\">So where are we, and what&#8217;s next?<\/h2>\n\n\n\n<figure class=\"wp-block-image size-large\"><img loading=\"lazy\" decoding=\"async\" width=\"1920\" height=\"2560\" src=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembled-unit-running-2-scaled.jpg\" alt=\"The panel in its natural habitat\" class=\"wp-image-4141\" srcset=\"https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembled-unit-running-2-scaled.jpg 1920w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembled-unit-running-2-225x300.jpg 225w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembled-unit-running-2-768x1024.jpg 768w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembled-unit-running-2-1152x1536.jpg 1152w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembled-unit-running-2-1536x2048.jpg 1536w, https:\/\/www.knightware.net\/wp-content\/uploads\/2026\/08\/quad-t-assembled-unit-running-2-624x832.jpg 624w\" sizes=\"auto, (max-width: 1920px) 100vw, 1920px\" \/><figcaption class=\"wp-element-caption\">The panel in its natural habitat<\/figcaption><\/figure>\n\n\n\n<p class=\"wp-block-paragraph\">This one landed a lot better than I expected. Most of my projects are fun first and useful second, if at all \u2014 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">The unexpected part is what that&#8217;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 <em>make go away<\/em>, 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.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Next up, most likely: moving off my local broker onto a cloud-hosted message queue, so the panel doesn&#8217;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&#8217;ll just build a second one \u2014 assuming I can find another junk front panel, which is honestly the hard part.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\">Everything&#8217;s up on GitHub if you want a closer look \u2014 firmware, the Go service, the tester CLI, and the case STL:<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><strong><a href=\"https:\/\/github.com\/dsmithson\/quad-t-mqtt-display\">github.com\/dsmithson\/quad-t-mqtt-display<\/a><\/strong><\/p>\n\n\n\n<p class=\"wp-block-paragraph\">If you run into problems or have ideas, filing an issue on the repo is by far the best way to reach me \u2014 I have a bad habit of not checking blog comments for months at a time.<\/p>\n\n\n\n<p class=\"wp-block-paragraph\"><\/p>\n","protected":false},"excerpt":{"rendered":"<p>A Christie Quad-T front panel rescued from the trash, rebuilt around a Raspberry Pi Pico W, and turned into an always-on Azure DevOps build monitor.<\/p>\n","protected":false},"author":1,"featured_media":4131,"comment_status":"open","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"footnotes":""},"categories":[1],"tags":[],"class_list":["post-4130","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-uncategorized"],"_links":{"self":[{"href":"https:\/\/www.knightware.net\/index.php?rest_route=\/wp\/v2\/posts\/4130","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/www.knightware.net\/index.php?rest_route=\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/www.knightware.net\/index.php?rest_route=\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/www.knightware.net\/index.php?rest_route=\/wp\/v2\/users\/1"}],"replies":[{"embeddable":true,"href":"https:\/\/www.knightware.net\/index.php?rest_route=%2Fwp%2Fv2%2Fcomments&post=4130"}],"version-history":[{"count":6,"href":"https:\/\/www.knightware.net\/index.php?rest_route=\/wp\/v2\/posts\/4130\/revisions"}],"predecessor-version":[{"id":4147,"href":"https:\/\/www.knightware.net\/index.php?rest_route=\/wp\/v2\/posts\/4130\/revisions\/4147"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/www.knightware.net\/index.php?rest_route=\/wp\/v2\/media\/4131"}],"wp:attachment":[{"href":"https:\/\/www.knightware.net\/index.php?rest_route=%2Fwp%2Fv2%2Fmedia&parent=4130"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/www.knightware.net\/index.php?rest_route=%2Fwp%2Fv2%2Fcategories&post=4130"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/www.knightware.net\/index.php?rest_route=%2Fwp%2Fv2%2Ftags&post=4130"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}