I’ve got Cosmic Unicorn LED panels round the house showing the weather, the forecast, sunrise and moonrise. None of them knows any of that. The Pico W inside each one never calls a weather API. It just waits to be told there’s a new picture to show, fetches it, and puts it up.
This project has been in my head for years. I’ve restarted it more than once, each time with more complicated ideas. This version finally feels like a system rather than an experiment, and the reason is that it’s simple: a display that does very little, and a small backend that does the thinking.
This is my experience of building it, not the one right way to do it. It took two videos to cover it all. Part 1 was the firmware on the display. Part 2 is everything behind it: the MQTT broker, security, automation and the services that draw the tiles. This post pulls both together.
Videos: Part 1, MQTT + HTTP: The Unusual Architecture Behind My Pico W Display · Part 2, EMQX + HAProxy + Ansible: The IoT Stack Behind My Pico W Display
Code: DrJonEA/DrJonEAHomeDisplay (firmware and display services) · DrJonEA/oracIoTHub (EMQX, HAProxy, TwinThing and the Ansible roles)
The problem: a display that lives in the lounge, not on my desk
I wanted a display in my lounge, not one that keeps coming back to my bench to be reflashed every time I think of something new to show. Visualising future data, things I haven’t even thought of yet, is what stumped me for years. I toyed with HTML parsing and with command protocols. Every idea ended with more and more code on the device.
The answer turned out to be splitting the project into three pieces:

- Firmware: the Cosmic Unicorn itself, running LVGL, an MQTT client and an HTTP client.
- Infrastructure: an MQTT broker as the communication backbone, with authentication, authorisation and TLS endpoints.
- Services: a display manager that takes in source data (mostly Google web services at the moment) and builds the tiles.
And a fourth piece that ties them together: Ansible, so I don’t have to remember how any of it was set up.
Part 1: the firmware
The hardware
The Pimoroni Cosmic Unicorn is a 32×32 RGB LED panel driven by constant current LED drivers, with a Raspberry Pi Pico W on the back. Mine are the original Pico W version; the newer ones use a Pico 2 W. My code works on both, you just build for the Pico 2.
It also has two Qwiic / STEMMA QT connectors on the back, which are I2C. I’ve plugged an AHT10 temperature and humidity sensor into one of them, so each panel has its own local readings.
I’ve covered these panels before on the channel, from my review and setup to playing PNG animations. For this build I flashed the panels with a pogo pin programmer I made, which saved a lot of button pressing over the many versions of the code.
On the software side the firmware uses:
- LVGL 9.5 for the graphics, which I ported to the Cosmic Unicorn
- coreMQTT for control messages
- coreHTTP to pull images
- FreeRTOS Kernel for tasks and sockets
Two tiles, and only two

The original Cosmic Unicorn runs on an RP2040, so there isn’t much RAM to spare. To keep that under control the firmware has just two tiles.
The local tile shows what the panel knows for itself:
- the time, synced with SNTP, with the time zone and daylight saving stored on the device so it can work out local time
- temperature and humidity from the AHT10
- a status row along the top: am I on Wi-Fi, am I connected to the MQTT broker, and a sort of “defcon” for whether everything in the house is OK or something needs checking
It can also hide the temperature and humidity for a while and scroll a message across the middle.

The image tile is just a 32×32 bitmap. In RGB565 that’s 2 KB of RAM. It can be absolutely anything. On top of it I can add an optional clock across the bottom eight pixels, and scroll text above that.
That’s the key idea. Rather than write a new tile in firmware for every new thing I want to show, the server draws it as an image. A new “tile” is a new picture.
MQTT for control, HTTP for the pictures

The display is an MQTT client. It publishes its sensor data, and it takes its instructions as small JSON messages: which layout to show (the local tile or the image tile), add a scrolling text widget, or load a named image.
The topics are a hierarchy, so I can talk to one display, a type of display, or every display at once:

The top level name and the device name are configurable, and each device name has to be unique so I can target it. Inside the firmware a display router subscribes to those three topics and hands the messages to a tile agent, which parses the JSON and updates the LVGL widgets.
I did toy with sending the image data over MQTT. I chose not to, because I wanted to keep the JSON control messages separate from the image payload. So the MQTT message says “load the image called X”, and the display builds the URL and pulls it with an HTTP GET. If that works, the payload goes straight to the widget manager and onto the screen.
There’s a nice side effect. The same announcement can go to different displays and each can fetch the format it needs. All of these are 32×32, but a bigger or differently shaped display could pull a different image from the same message. Using MQTT also means I can multicast to every panel in the house at once.
It’s a combination of protocols that looks a bit unusual, but it works really well.
Configuration over USB, not in the binary
Each display needs Wi-Fi credentials, MQTT credentials, the MQTT server and the HTTP server. My usual approach is to feed these in through environment variables and CMake, so they never end up in the repo. That’s fine for one device, but here I wanted one binary on every display, configured afterwards.
So the tile agent keeps reading standard I/O, which is the USB serial port, in the background. Send it a JSON configuration string and it parses it and saves it to flash as non-volatile storage. Plug in a new panel, send it its settings, done. That turned out to matter a lot in Part 2.
Part 2: the infrastructure
When I started this project I thought the firmware was the project. On its own, though, my display could only show what it measured itself. Without the infrastructure and services behind it there was no outside data and no security, just a fairly dull clock and thermometer.

A local broker: EMQX in Docker
When I first built the firmware I was using Flespi as my MQTT broker. There are lots of SaaS brokers to choose from, including AWS and ThingsBoard, which I’ve looked at recently. This time I went local. I could have used Mosquitto, but I chose EMQX, which I’ve used before. It’s highly scalable, widely used across the industry, and easily deployed with Docker, so I can drop it into a test environment or onto my NAS.
The newer EMQX releases bring the community and commercial versions together into a single codebase under a Business Source Licence, which is fine for personal, home use. Check the licence terms for your own use.
In Docker Compose I always pin the version rather than using latest. A feature change in a new release can mean you redeploy and suddenly nothing works. Everything else is set with environment variables, including the authentication backend:
emqx1: image: emqx/emqx:4.3.11 environment: - "EMQX_LOADED_PLUGINS=emqx_recon,emqx_retainer,emqx_management,emqx_dashboard,emqx_auth_mysql" - "EMQX_MQTT__ALLOW_ANONYMOUS=false" - "EMQX_auth__mysql__server=${OracDBServ}:${OracDBPort}" - "EMQX_auth__mysql__database=${OracDBSchema}" - "EMQX_ACL_NOMATCH=deny" ...
No anonymous clients, and anything not explicitly allowed is denied.
MySQL for authentication and access control
The database for authentication, authorisation and state could have gone in Docker too, but my NAS already gives me a MySQL server I use across all my environments, so I used that. There’s a user table with each device’s credentials and an ACL table saying which topics each client can publish and subscribe to. Even the authentication query is passed in as configuration, so you could authenticate against a different table structure if you wanted.
The permissions are written as small policy files that Ansible loads. A standard “thing” can subscribe to the group topic, publish to the log topic, and do anything under its own client ID. A display adds the display topics and its own sensor topic:
[ { "topic": "drjonea/display/#", "action": "sub", "access": "allow" }, { "topic": "drjonea/sensor/cosmic/$CLIENTID", "action": "pub", "access": "allow" }]
HAProxy terminating TLS
HAProxy sits in front of everything. It decides which ports are exposed from the Docker network and terminates TLS, so encrypted traffic comes into the proxy and is relayed on to the service behind it. The broker stays simple and the certificates live in one place. It’s a common pattern in larger deployments, and it suited my setup well.
The configuration is really just front ends and back ends: the EMQX admin console on the web front end, and MQTT on the other.
frontend mqtt bind :1883 bind :8883 ssl crt /usr/local/etc/certs/mqtt-certs.pem mode tcp default_backend mqttbackend mqtt mode tcp server server1 emqx1:1883 check
I’ll be honest: not everything is encrypted end to end yet. The TLS endpoints are there, and getting every display on to them is next (more on that below). Certificates are a whole video in themselves. If you’d like that one, tell me in the comments.
Ansible: so I don’t have to remember
I’m crossing a lot of components here, and I’m not always an expert on Docker. Sometimes I have to scratch my head to remember how I set something up. So all of it lives in Ansible roles in the oracIoTHub repo.
If you’ve not met it, Ansible is open source, agentless IT automation. Reusable roles are run from a playbook, which knows that a particular host gets particular roles. The playbook pulls its variables from YAML files, some of them encrypted vault files, and hands them to the roles. My roles:
- deploy the Docker environment, EMQX and HAProxy
- create the MySQL tables, and purge old roles and permissions so I start clean
- create a new device in the database with the standard permissions
- and then provision the device itself

That last one is my favourite. Remember the USB configuration from Part 1? The display role creates the device’s credentials and access controls, then runs a small Python script that finds the Pico on USB serial (on my Mac or on Linux) and sends it the JSON configuration, which the firmware saves to flash. New panel for the wall: plug it in, run the playbook.
TwinThing: honest scope
There’s also a digital twin service in the stack. It’s called TwinThing; I originally wrote it for my lightsaber project and I keep reusing it. It registers devices so I know which are live, has groups so I can ping a whole group and have them all answer, and keeps each device’s state in the database so I can query it even when a device is offline.
I must admit this display isn’t really using it yet. It’s a deep dive of its own, so if you want the digital twins video, say so in the comments.
Part 2: the services
The clever bit is the display manager. It’s a Python app with a scheduler: it fetches data from its sources on a schedule and caches it, draws the tiles, then announces over MQTT that a tile is ready, which makes the displays switch to it. Because the manager does the web calls once, every display in the house shares them.

The sources are:
- local time and time zone, from the host operating system, so the displays follow daylight saving here in the UK without me touching them
- weather now and the forecast, from Google web services
- solar and lunar: sunrise, sunset and moonrise
The manager simplifies each response into a small structure that’s ready to draw from, then uses Pillow to draw a 32×32 image. The weather tile changes colour with the temperature, has colour bars along the bottom and a rain bar down the right, and some text on top.

The solar tile shows sunrise, sunset and, if the moon isn’t up yet, when it will rise. It draws a night effect when it’s dark, and the sun and moon where they are on their arcs across the sky.

The images go to the displays as RGB565 with the byte order swapped to suit the panel. I’m also planning a low blue mode late in the evening, so the displays don’t keep me up at night.
If some of this looks familiar, it’s the same set of services I used for the touchscreen smart clock I built for Dan Makes Things in last year’s YouTube Secret Santa. I’ve just reused the ideas.
And the best part: whatever I can dream up, I can add as a new service, announce it and push it to the displays, with no firmware change.
Old and new

The whole project replaces a much simpler clock in my living room: a Raspberry Pi driving some I2C LED display blocks, built probably ten years ago. I’m amazed it has survived and kept working for so long. The new panel is way cooler, though.
What I’m not using (yet)
As with all projects, there’s some waste in here. The digital twin isn’t really being used. Neither is the sensor data the displays send back. I’m not even using the scrolling text feature I spent several hours perfecting. These may come in yet, and I’m working on more services.
What’s next
- Docker on the NAS. Once I’ve done more testing, the whole stack moves onto my NAS as Docker containers.
- TLS all the way to the display. Part 1 was sponsored by wolfSSL, and their lightweight TLS library is how I plan to wrap the display’s MQTT and HTTP traffic properly. It’s small enough for a microcontroller with kilobytes of RAM and still supports TLS 1.3, and they also have wolfMQTT if you want secure messaging built into the client. With HAProxy already terminating TLS, the device side is the missing piece. If you’re on Mbed TLS today, I wrote about why I’d plan a move to wolfSSL.
- Videos you can ask for: TLS certificates for the proxy, and digital twins with TwinThing. Tell me which you’d watch first.
If you’d like to build something similar, the two repos above have the firmware, the services and the Ansible roles. What would you put on a 32×32 tile? Let me know in the comments on the video. I read every one.
Get my videos first
Channel members see every new video before it goes public. Membership also helps me keep making these deep dives and get my droid BπBO to Open Sauce. If you’d like to join, you can sign up here.
Part 1 of this project was sponsored by wolfSSL, and this post contains a wolfSSL partner link. It costs you nothing extra and helps keep the channel running. The Pimoroni link is a plain shop link, not an affiliate one.
