Matrix Portal LED Matrix 3D Printer Status Display
2026-06-24 | By Travis Foss
License: Attribution Non-commercial 3D Print Accessories 3D Printer 3D Printing LED Matrix
Overview
If you run more than one 3D printer, you know the problem: you’re in another room, the print finished fifteen minutes ago, and you have no idea. Or worse, something happened, causing the print to pause mid-print, and you missed it entirely.
This project builds a wall-mounted LED matrix display that monitors up to three 3D printers simultaneously and shows their live status at a glance — no phone, no laptop, no app required. A single colored dot per printer tells you everything you need to know from across the room.
The build uses:
• An Adafruit Matrix Portal M4 as the brains and WiFi adapter
• A 64×32 HUB75 RGB LED matrix panel as the display
• Home Assistant as the data aggregation layer
• Three 3D printers that I already had on hand (Two Elegoo Centauri Carbons, and a Bambu Lab A1)
This project was developed iteratively through conversation with an AI assistant, which helped with architecture decisions, CircuitPython code, Home Assistant template syntax, and debugging. It’s a great example of how AI tools can accelerate maker projects.
How It Works
The system has three layers, each with a clear job:
Layer 1 — The Printers
Each printer broadcasts its status over your local WiFi network using its own protocol. The Elegoo Centauri Carbons use SDCP (Smart Device Control Protocol) over WebSockets. The Bambu A1 uses MQTT. Both are common IoT messaging formats — just different “languages” for sending structured data across a network.
Layer 2 — Home Assistant
Home Assistant acts as middleware, or the translator and aggregator. Community-built integrations handle the printer-specific protocols and normalize everything into standardized sensor entities — things like sensor.centauri_carbon_nozzle_temperature or sensor.a1_print_progress. Once the data has been aggregated, Home Assistant exposes a REST API, so any device on your network can query it over plain HTTP.
A Jinja2 template running inside Home Assistant pulls the relevant sensors for all three printers and packages them into a single compact JSON response on demand. This means the Matrix Portal only ever needs to make one HTTP request to get all the data it needs.
Layer 3 — The Matrix Portal
The Adafruit Matrix Portal M4 is a microcontroller board that plugs directly into the back of a HUB75 LED matrix panel. It runs CircuitPython — a lightweight version of Python designed for microcontrollers. Every 20 seconds, it wakes up, sends an HTTP POST to Home Assistant’s template API, parses the JSON response, and redraws the display.
The display only updates when something actually changes — new data arrives, the page cycles to the next printer, or an error dot needs to flash. In between events, the processor just sleeps, which keeps the matrix steady with no flicker.
Parts List
64×32 RGB LED Matrix Panel (HUB75)
Home Assistant Instance-homeassistant.io (runs on a Pi, NUC, etc.) I have it installed on a Raspberry PI 4
Printers I had on hand:
Elegoo Centauri Carbon (2x)
Bambu Lab A1
The Matrix Portal M4 is the original version with an ATSAMD51 M4 processor and a separate ESP32 WiFi co-processor. The newer Matrix Portal S3 would also work and has more memory, but the code in this project was developed and tested on the M4.
Prerequisites and Software
Home Assistant Integrations
You’ll need two community integrations installed via HACS (Home Assistant Community Store):
• elegoo-homeassistant by danielcherubini — provides SDCP WebSocket support for Elegoo printers, including the Centauri Carbon
• Bambu Lab integration — available in HACS; requires LAN Mode enabled in the Bambu Handy app and your device’s local access code from the printer’s LCD screen
Once installed, both integrations create sensor entities for each printer covering status, nozzle temperature, bed temperature, print progress percentage, and remaining print time.
CircuitPython Libraries
The Matrix Portal runs CircuitPython. You’ll need the following libraries from the Adafruit CircuitPython Bundle copied into the /lib folder on your CIRCUITPY drive:
• adafruit_matrixportal/
• adafruit_portalbase/
• adafruit_esp32spi/
• adafruit_requests.mpy
• adafruit_connection_manager.mpy
Download the bundle from circuitpython.org/libraries and match the version to your installed CircuitPython version. You can check your version by connecting to the serial console and pressing Ctrl+C.
Step-by-Step Build Guide
Step 1 — Verify Your HA Sensor Entity IDs
Before writing any code, confirm your exact sensor entity IDs in Home Assistant. Go to Developer Tools → States and search for your printer name. Note the entity IDs for each printer’s:
• Print status (e.g., sensor.centauri_carbon_print_status)
• Nozzle temperature
• Bed temperature
• Print progress / percent complete
• Remaining print time
Entity IDs vary depending on how your integration named the device on first discovery. Write them down — you’ll need them in Step 2.
Step 2 — Build the Home Assistant Template
Home Assistant’s template engine lets you render a Jinja2 template via its REST API and get back any text you define — including JSON. This is the bridge between HA’s sensor data and the Matrix Portal.
Go to Developer Tools → Template and paste in a template like this, substituting your actual entity IDs:
{
"cc1": {
"nozzle": {{ states('sensor.centauri_carbon_nozzle_temperature_2') | float(0) | round(1) }},
"bed": {{ states('sensor.centauri_carbon_bed_temperature_2') | float(0) | round(1) }},
"pct": {{ states('sensor.centauri_carbon_percent_complete_2') | float(0) | round(0) | int }},
"status": "{{ states('sensor.centauri_carbon_print_status_2') }}",
"remaining": "{{ states('sensor.centauri_carbon_remaining_print_time_2') }}"
},
"cc2": {
"nozzle": {{ states('sensor.centauri_carbon_nozzle_temperature_2') | float(0) | round(1) }},
"bed": {{ states('sensor.centauri_carbon_bed_temperature_2') | float(0) | round(1) }},
"pct": {{ states('sensor.centauri_carbon_percent_complete_2') | float(0) | round(0) | int }},
"status": "{{ states('sensor.centauri_carbon_print_status_2') }}",
"remaining": "{{ states('sensor.centauri_carbon_remaining_print_time_2') }}"
},
"bbl": {
"nozzle": {{ states('sensor.a1_nozzle_temperature') | float(0) | round(1) }},
"bed": {{ states('sensor.a1_bed_temperature') | float(0) | round(1) }},
"pct": {{ states('sensor.a1_percent_complete') | float(0) | round(0) | int }},
"status": "{{ states('sensor.a1_print_progress') }}",
"remaining": "{{ states('sensor.a1_remaining_time') }}"
},
}
The right-hand preview pane will show the rendered JSON output with live values. Confirm all three printers are returning data before moving on.
Step 3 — Generate a Long-Lived Access Token
The Matrix Portal needs to authenticate with Home Assistant’s REST API. In HA, go to your Profile page (click your username at the bottom left of the sidebar), scroll to the very bottom, and click Create Token under Long-Lived Access Tokens. Name it something like matrix_portal and copy the token — HA only shows it once.
Step 4 — Configure the Matrix Portal
Create a settings.toml file at the root of your CIRCUITPY drive with your credentials:
CIRCUITPY_WIFI_SSID = "your_wifi_ssid" CIRCUITPY_WIFI_PASSWORD = "your_wifi_password" HA_HOST = "192.168.1.xxx" HA_PORT = "8123" HA_TOKEN = "your_long_lived_token_here"
settings.toml keeps credentials out of your code.py file. Never share or commit your token.
Step 5 — Deploy the Code
Copy code.py to the root of your CIRCUITPY drive alongside settings.toml. The board reboots automatically when files change. You’ll see the WiFi connection screen briefly, then the display will show the current printer states within a few seconds.
If anything goes wrong during startup, connect a serial terminal (Mu editor, Thonny, or PuTTY on the Matrix Portal’s USB COM port) to see debug output printed by the code.
Display Logic
Four Printer States
Every printer is normalized into one of four internal states regardless of what the underlying integration returns:
The complete state is intentionally distinct from idle. After a print finishes, the dot turns blue, and the display shows COMPLETED / REMOVE PRINT until someone clears the bed and the printer transitions back to idle. This prevents accidentally starting a new job on top of a finished one.
Screen Modes
All Idle
When all three printers are idle, a single static screen lists all three with their green dots and IDLE status. No cycling occurs — the display is completely steady.
Cycling Mode
When any printer is printing, has errors, or is complete, the display enters cycling mode. A dynamic slide list is built each loop:
• One dedicated full-screen slide per active (non-idle) printer
• One consolidated idle group slide at the end if any printers are idle
Slides advance every 4 seconds. The idle group slide stacks all idle printer names at the top with NO PRINT below — this way you never lose track of which printers are sitting empty while focusing on the active ones.
Printing Slide
Each active printer’s slide shows:
• Printer name and status dot in the header
• Nozzle temperature (converted from °F to °C for display)
• Bed temperature (also converted)
• Remaining print time
• Progress bar spanning most of the bottom row
• Percentage complete alongside the bar
Technical Notes
Why Home Assistant as Middleware?
The Matrix Portal M4 only has 192KB of SRAM. It doesn’t have enough memory to run WebSocket client libraries (needed for the Centauri) and MQTT client libraries (needed for the Bambu) simultaneously, let alone manage three persistent connections. By routing everything through Home Assistant, the Matrix Portal only needs to make simple HTTP GET/POST requests — something it handles easily with the built-in adafruit_requests library.
This architecture also means that adding a fourth printer in the future only requires updating the HA template. The Matrix Portal code doesn’t need to change at all.
Temperature Conversion
Home Assistant’s Elegoo and Bambu integrations report temperatures in Fahrenheit by default in some configurations. The code converts on the fly:
c = (fahrenheit - 32) * 5 / 9
This gives you the familiar Celsius values that match what you see on the printer’s own touchscreen display.
Dirty-Flag Rendering
Rather than redrawing the display every loop iteration (which causes visible flicker), the code uses a needs_redraw flag. The display is only redrawn when:
• New data arrives from Home Assistant, and it differs from the previous fetch
• The page advances to the next printer slide
• An error dot needs to toggle its flash state
• A print progress percentage changes
Between events, the main loop sleeps for 100ms and does nothing. This keeps the matrix stable and flicker-free.
Custom Pixel Font
The Matrix Portal’s displayio system supports bitmap fonts, but loading font files from disk on the M4 is slow and memory-intensive. Instead, the code includes a hand-built 3×5 pixel font as a Python dictionary — each character is defined as five integers where each bit represents a lit or unlit pixel. This approach is fast, uses minimal memory, and gives complete control over rendering.
Status String Normalization
The Elegoo Centauri Carbon alone has 16 distinct status strings (idle, homing, dropping, printing, lifting, pausing, paused, stopping, stopped, complete, file_checking, recovery, printing_recovery, loading, preheating, leveling). The Bambu adds its own set. The normalize_status() function maps all of these to four internal states, so the display logic stays simple regardless of which printer is reporting.
Customization and Next Steps
A few easy ways to extend this project:
• Add more printers — if they have a Home Assistant integration, add it to the HA template and extend the printers list in code.py. The slide system handles any number automatically.
• Adjust cycle timing — change CYCLE_INTERVAL at the top of code.py. 4 seconds is comfortable for glancing at the display; you might prefer 6–10 seconds if it feels rushed.
• 64×64 panel upgrade — swapping to a 64×64 matrix gives you enough room to show all three printers simultaneously in printing mode without cycling.
• Enclosure — 3D print a wall-mount frame for the matrix panel. The Matrix Portal plugs into the back, so the overall assembly is very compact.
• Sensor Display — This doesn't have to be used just for 3D printers. You can display any sensors or even automation information on the display that has entities. You could create a quick smart home heads-up display for notifications.
Code Reference
The project consists of two files deployed to the CIRCUITPY drive:
• code.py — main CircuitPython application. Handles WiFi, HTTP polling, status normalization, and all display rendering.
• settings.toml — credentials file. WiFi SSID/password, Home Assistant IP, port, and long-lived access token.
Key configurable constants at the top of code.py:
POLL_INTERVAL = 20 # seconds between HA data fetches CYCLE_INTERVAL = 4 # seconds between printer slides FLASH_RATE = 0.4 # seconds per flash toggle for error/paused dot
The full code is as follows:
# code.py — 3D Printer Status Display
# Adafruit Matrix Portal M4 + 64x32 RGB Matrix
# Polls Home Assistant REST API for printer status
# Travis's print farm: CC1, CC2, Bambu A1
import time
import json
import board
import busio
import displayio
import terminalio
import supervisor
from digitalio import DigitalInOut
from adafruit_matrixportal.matrix import Matrix
from adafruit_esp32spi import adafruit_esp32spi
import adafruit_connection_manager
import adafruit_requests
# ── CONFIG ────────────────────────────────────────────────────
import os
WIFI_SSID = os.getenv("CIRCUITPY_WIFI_SSID")
WIFI_PASS = os.getenv("CIRCUITPY_WIFI_PASSWORD")
HA_HOST = os.getenv("HA_HOST")
HA_PORT = os.getenv("HA_PORT")
HA_TOKEN = os.getenv("HA_TOKEN")
POLL_INTERVAL = 20 # seconds between HA fetches
CYCLE_INTERVAL = 4 # seconds between printer slides
FLASH_RATE = 0.4 # seconds per flash toggle for error dot
# Printer display names (keep short — pixel font is tiny)
PRINTER_NAMES = ["CC1", "CC2", "A1 "]
full_names = ["CENTAURI 1", "CENTAURI 2", "BAMBU A1 "]
# Status string normalization
# Maps whatever HA returns → our internal state
def normalize_status(raw):
r = str(raw).lower().strip()
# Active / in-progress states → show as printing (red dot)
if r in (
"printing", "running", "busy", "slicing", # generic
"homing", "dropping", "lifting", # Centauri motion states
"file_checking", "recovery", "printing_recovery", # Centauri recovery
"loading", "preheating", "leveling", # Centauri prep states
):
return "printing"
# Interrupted / needs attention → show as error (flashing yellow dot)
if r in (
"pausing", "paused", # Centauri pause states
"stopping", # Centauri mid-stop
"pause", "error", "failed", # generic
):
return "error"
# Print finished — bed needs clearing before next print
if r in (
"complete", "stopped", # Centauri end states
"finish", "finished", # Bambu end states
):
return "complete"
# Truly idle / not running
if r in (
"idle", "standby", # normal idle
"offline", "unknown", "unavailable",
"none", "0.0", "0", "",
):
return "idle"
# Bambu-specific active stages
if r in ("prepare", "auto_bed_leveling", "heatbed_preheating",
"xyz_calibrate", "nozzle_temperature_calibration",
"initial_estimate", "cleaning_nozzle_tip",
"scanning_bed_surface", "first_layer_inspection",
"downloading"):
return "printing"
# Numeric fallback
try:
code = int(r)
if code == 0:
return "idle"
if 1 <= code <= 3:
return "printing"
if code >= 4:
return "error"
except ValueError:
pass
return "idle"
# ── DISPLAY SETUP ─────────────────────────────────────────────
matrix = Matrix(width=64, height=32, bit_depth=4)
display = matrix.display
display.rotation = 0
# Pixel access for drawing
import rgbmatrix
import framebufferio
import adafruit_pixel_framebuf as pixel_framebuf # not available on M4 — use displayio
# We'll use a raw bitmap + palette approach for full pixel control
from displayio import Bitmap, Palette, TileGrid, Group
# Build a 64x32 bitmap (8-bit palette indices)
bmp = Bitmap(64, 32, 64) # 64 colors max in palette
# Build palette — we'll use specific indices for our color set
# Index 0 = black (background), always
palette = Palette(64)
palette[0] = 0x000000 # black / off
# Status dot colors
palette[1] = 0x00CC22 # green — idle
palette[2] = 0xCC2200 # red — printing
palette[3] = 0xFFCC00 # yellow — error/paused (flashed in software)
palette[4] = 0x000000 # yellow-off (flash off state)
# Text colors
palette[5] = 0x4488FF # blue — title / header
palette[6] = 0xFFFFFF # white — printer name
palette[7] = 0xAAAAAA # grey — labels
palette[8] = 0x447744 # dim green — idle text
palette[9] = 0xFF8800 # orange — nozzle temp
palette[10] = 0xFF4444 # red — bed temp
palette[11] = 0xCCCCCC # light grey — temp values
palette[12] = 0xCC4400 # dark orange — progress bar fill
palette[13] = 0x222222 # dark grey — progress bar background
palette[14] = 0xFFCC00 # yellow — pct text / error text
palette[15] = 0x334466 # dim blue — divider lines
palette[16] = 0x00AA88 # teal — remaining time
palette[17] = 0x666666 # mid grey — mini summary text
palette[18] = 0x555555 # darker grey — page indicator inactive
palette[19] = 0xFFFFFF # white — page indicator active
palette[20] = 0x0088FF # blue — complete dot
root_group = Group()
tg = TileGrid(bmp, pixel_shader=palette, x=0, y=0)
root_group.append(tg)
display.root_group = root_group
# ── TINY PIXEL FONT (3×5) ─────────────────────────────────────
# Each char: list of 5 rows, each row is a 3-bit integer (MSB=left)
FONT = {
' ': [0,0,0,0,0],
'A': [2,5,7,5,5], # 010,101,111,101,101
'B': [6,5,6,5,6],
'C': [3,4,4,4,3],
'D': [6,5,5,5,6],
'E': [7,4,6,4,7],
'F': [7,4,6,4,4],
'G': [3,4,7,5,3],
'H': [5,5,7,5,5],
'I': [7,2,2,2,7],
'J': [1,1,1,5,2],
'K': [5,5,6,5,5],
'L': [4,4,4,4,7],
'M': [5,7,7,5,5],
'N': [5,7,7,5,5],
'O': [2,5,5,5,2],
'P': [6,5,6,4,4],
'Q': [2,5,5,7,3],
'R': [6,5,6,5,5],
'S': [3,4,2,1,6],
'T': [7,2,2,2,2],
'U': [5,5,5,5,2],
'V': [5,5,5,2,2],
'W': [5,5,7,7,5],
'X': [5,5,2,5,5],
'Y': [5,5,2,2,2],
'Z': [7,1,2,4,7],
'0': [2,5,5,5,2],
'1': [2,6,2,2,7],
'2': [2,1,2,4,7],
'3': [6,1,2,1,6],
'4': [5,5,7,1,1],
'5': [7,4,6,1,6],
'6': [3,4,6,5,2],
'7': [7,1,2,2,2],
'8': [2,5,2,5,2],
'9': [2,5,3,1,6],
'.': [0,0,0,0,4],
':': [0,4,0,4,0],
'%': [5,1,2,4,5],
'/': [1,1,2,4,4],
'-': [0,0,7,0,0],
'+': [0,2,7,2,0],
'°': [2,5,2,0,0],
'm': [0,0,5,7,5],
'h': [4,4,6,5,5],
's': [0,0,3,2,6],
'i': [0,2,0,2,2],
'd': [1,1,3,5,3],
'l': [6,2,2,2,7],
'e': [0,2,5,6,3],
'n': [0,0,6,5,5],
'g': [0,3,5,3,1],
'r': [0,0,5,6,4],
'p': [0,0,6,5,6],
'f': [0,3,2,6,2],
'u': [0,0,5,5,3],
't': [2,7,2,2,1],
'a': [0,2,3,5,3],
'c': [0,0,3,4,3],
'k': [4,5,6,5,5],
'o': [0,0,2,5,2],
'w': [0,5,5,7,5],
'x': [0,5,2,5,5],
'z': [0,7,2,4,7],
'b': [4,4,6,5,6],
'y': [0,5,5,3,6],
'v': [0,5,5,2,2],
'j': [0,1,0,1,6],
'q': [0,3,5,3,5],
}
def draw_char(ch, x, y, color_idx):
"""Draw a single 3×5 character at pixel (x,y) using palette index."""
rows = FONT.get(ch.upper(), FONT.get(ch, FONT[' ']))
for row_idx, row_bits in enumerate(rows):
py = y + row_idx
if py < 0 or py >= 32:
continue
for col in range(3):
px_val = (row_bits >> (2 - col)) & 1
px = x + col
if px < 0 or px >= 64:
continue
if px_val:
bmp[px, py] = color_idx
# Don't clear — caller should clear the area first
def draw_text(text, x, y, color_idx):
"""Draw a string. Returns ending x position."""
cx = x
for ch in text:
draw_char(ch, cx, y, color_idx)
cx += 4 # 3px char + 1px gap
return cx
def draw_text_centered(text, y, color_idx, width=64):
total_w = len(text) * 4 - 1
x = max(0, (width - total_w) // 2)
draw_text(text, x, y, color_idx)
def clear_rect(x, y, w, h):
"""Fill a rect with black (index 0)."""
for py in range(y, min(y + h, 32)):
for px in range(x, min(x + w, 64)):
bmp[px, py] = 0
def clear_all():
for i in range(64 * 32):
bmp[i % 64, i // 64] = 0
def draw_hline(y, x0=0, x1=63, color_idx=15):
for x in range(x0, x1 + 1):
if 0 <= y < 32:
bmp[x, y] = color_idx
def draw_dot(x, y, color_idx):
"""Draw a 3×3 filled square dot."""
for dy in range(3):
for dx in range(3):
px2 = x + dx
py2 = y + dy
if 0 <= px2 < 64 and 0 <= py2 < 32:
bmp[px2, py2] = color_idx
def draw_progress_bar(x, y, w, h, pct, fill_idx=12, bg_idx=13):
fill = max(0, min(w, int(w * pct / 100)))
for py in range(y, y + h):
for px in range(x, x + w):
bmp[px, py] = fill_idx if px < x + fill else bg_idx
def dot_color_idx(status):
if status == "printing":
return 2 # red
if status == "error":
return 3 # yellow
if status == "complete":
return 20 # blue
return 1 # green — idle
def format_remaining(raw):
"""Format remaining time string from HA. Returns e.g. '1h23m', '45m', '--'"""
try:
mins = float(str(raw).strip())
if mins <= 0:
return "--"
h = int(mins // 60)
m = int(mins % 60)
if h > 0:
return f"{h}h{m:02d}m"
return f"{m}m"
except (ValueError, TypeError):
return "--"
def format_temp(val):
"""Convert F to C and format for display, e.g. 284°F → '140'"""
try:
f = float(val)
c = (f - 32) * 5 / 9
return str(int(c))
except (ValueError, TypeError):
return "---"
# ── DRAW ROUTINES ─────────────────────────────────────────────
def draw_all_idle(printers):
"""All printers idle — show all 3 on one static screen."""
clear_all()
draw_text("3D PRINTERS", 2, 1, 5) # blue title
draw_hline(8, color_idx=15)
rows = [11, 18, 25]
for i, p in enumerate(printers):
y = rows[i]
status = normalize_status(p.get("status", "idle"))
dot_idx = dot_color_idx(status)
draw_dot(1, y, dot_idx)
draw_text(PRINTER_NAMES[i], 7, y, 6) # white name
if status == "idle":
draw_text("IDLE", 23, y, 8) # dim green
elif status == "complete":
draw_text("DONE", 23, y, 20) # blue
elif status == "error":
draw_text("ERR", 23, y, 14) # yellow
else:
pct = p.get("pct", 0)
draw_text(f"{pct}%", 23, y, 12) # orange-ish
def draw_printer_slide(printer, name, status, flash_state, page_idx):
"""Draw the full-screen slide for one printer while it's cycling."""
clear_all()
# Header row: dot + printer name
dot_idx = dot_color_idx(status)
draw_dot(1, 1, dot_idx)
draw_text(name, 7, 1, 6) # white
draw_hline(7, color_idx=15)
if status == "idle":
# Vertically centered in the space below the header divider (rows 8-23)
draw_text_centered("IDLE", 11, 8) # dim green
draw_text_centered("NO PRINT", 18, 7) # grey
elif status == "complete":
# Print done — prompt user to remove print
draw_text_centered("COMPLETED", 11, 20) # blue
draw_text_centered("REMOVE PRINT", 19, 7) # grey
elif status == "error":
# Temps still shown, PAUSED/ERROR label instead of bar
nozzle = format_temp(printer.get("nozzle", 0))
bed = format_temp(printer.get("bed", 0))
draw_text("NOZ", 1, 9, 9) # orange label
draw_text(f"{nozzle}", 15, 9, 11) # grey value
draw_text("°", 15 + len(nozzle)*4, 9, 11)
draw_text("BED", 1, 16, 10) # red label
draw_text(f"{bed}", 15, 16, 11)
draw_text("°", 15 + len(bed)*4, 16, 11)
draw_hline(23, color_idx=15)
# Show specific interrupted state text
raw_status = str(printer.get("status", "")).lower().strip()
if raw_status == "stopping":
draw_text_centered("STOPPING", 25, 14)
elif raw_status == "pausing":
draw_text_centered("PAUSING", 25, 14)
else:
draw_text_centered("PAUSED", 25, 14) # yellow
else:
# Printing — show nozzle, bed, progress bar, pct, remaining
nozzle = format_temp(printer.get("nozzle", 0))
bed = format_temp(printer.get("bed", 0))
pct = int(printer.get("pct", 0))
rem = format_remaining(printer.get("remaining", 0))
# Nozzle temp — row 9
draw_text("NOZ", 1, 9, 9) # orange
draw_text(f"{nozzle}°", 15, 9, 11)
# Bed temp — row 16
draw_text("BED", 1, 16, 10) # red-ish
draw_text(f"{bed}°", 15, 16, 11)
# Remaining time — right side
draw_text(rem, 38, 9, 16) # teal
draw_hline(23, color_idx=15)
# Progress bar — rows 25-27
draw_progress_bar(1, 25, 44, 3, pct)
# Percentage — right of bar
pct_str = f"{pct}%"
draw_text(pct_str, 47, 25, 14) # yellow
# Page indicator dots — bottom right (shows which printer we're on)
page_x = [57, 59, 61]
for i in range(3):
bmp[page_x[i], 31] = 19 if i == page_idx else 18
def draw_idle_group_slide(idle_indices, printers):
"""One consolidated slide showing all currently-idle printers stacked at top,
with NO PRINT centred in the remaining space below."""
clear_all()
# Pack idle printer names starting at y=1, 8px apart
row_ys = [1, 9, 17]
for slot, idx in enumerate(idle_indices):
y = row_ys[slot]
p = printers[idx]
status = normalize_status(p.get("status", "idle"))
dot_idx = dot_color_idx(status)
draw_dot(1, y, dot_idx)
draw_text(full_names[idx], 7, y, 6) # white printer name
# Divider just below the last idle entry
divider_y = row_ys[len(idle_indices) - 1] + 7
if divider_y < 30:
draw_hline(divider_y, color_idx=15)
# "NO PRINT" centred in the remaining space below the divider
remaining_top = divider_y + 1
remaining_mid = remaining_top + (31 - remaining_top) // 2 - 2
if remaining_mid + 5 <= 31:
draw_text_centered("NO PRINT", remaining_mid, 7) # grey
# ── WIFI + HTTP SETUP ─────────────────────────────────────────
def init_wifi():
esp32_cs = DigitalInOut(board.ESP_CS)
esp32_ready = DigitalInOut(board.ESP_BUSY)
esp32_reset = DigitalInOut(board.ESP_RESET)
spi = busio.SPI(board.SCK, board.MOSI, board.MISO)
esp = adafruit_esp32spi.ESP_SPIcontrol(spi, esp32_cs, esp32_ready, esp32_reset)
pool = adafruit_connection_manager.get_radio_socketpool(esp)
ssl_ctx = adafruit_connection_manager.get_radio_ssl_context(esp)
requests = adafruit_requests.Session(pool, ssl_ctx)
print("Connecting to WiFi...")
while not esp.is_connected:
try:
esp.connect_AP(WIFI_SSID, WIFI_PASS)
except RuntimeError as e:
print(f"WiFi error: {e}, retrying...")
time.sleep(2)
print(f"Connected! IP: {esp.pretty_ip(esp.ip_address)}")
return requests
# Jinja2 template to POST to HA — kept as a plain string
HA_TEMPLATE = """{
"cc1": {
"nozzle": {{ states('sensor.centauri_carbon_nozzle_temperature_2') | float(0) | round(1) }},
"bed": {{ states('sensor.centauri_carbon_bed_temperature_2') | float(0) | round(1) }},
"pct": {{ states('sensor.centauri_carbon_percent_complete_2') | float(0) | round(0) | int }},
"status": "{{ states('sensor.centauri_carbon_print_status_2') }}",
"remaining": "{{ states('sensor.centauri_carbon_remaining_print_time_2') }}"
},
"cc2": {
"nozzle": {{ states('sensor.centauri_carbon_nozzle_temperature') | float(0) | round(1) }},
"bed": {{ states('sensor.centauri_carbon_bed_temperature') | float(0) | round(1) }},
"pct": {{ states('sensor.centauri_carbon_percent_complete') | float(0) | round(0) | int }},
"status": "{{ states('sensor.centauri_carbon_print_status') }}",
"remaining": "{{ states('sensor.centauri_carbon_remaining_print_time') }}"
},
"bbl": {
"nozzle": {{ states('sensor.a1_03919c450701743_nozzle_temperature') | float(0) | round(1) }},
"bed": {{ states('sensor.a1_03919c450701743_bed_temperature') | float(0) | round(1) }},
"pct": {{ states('sensor.a1_03919c450701743_print_progress') | float(0) | round(0) | int }},
"status": "{{ 'complete' if is_state('input_boolean.bambu_a1_print_complete', 'on') else states('sensor.a1_03919c450701743_print_status') }}",
"stage": "{{ states('sensor.a1_03919c450701743_current_stage') }}",
"remaining": "{{ states('sensor.a1_03919c450701743_remaining_time') }}"
}
}"""
def fetch_printer_data(requests_session):
url = f"http://{HA_HOST}:{HA_PORT}/api/template"
headers = {
"Authorization": f"Bearer {HA_TOKEN}",
"Content-Type": "application/json",
}
payload = json.dumps({"template": HA_TEMPLATE})
try:
resp = requests_session.post(url, headers=headers, data=payload, timeout=10)
raw = resp.text
resp.close()
data = json.loads(raw)
# Bambu complete state is handled by the HA template via input_boolean.
# current_stage="idle" override still applies as a fallback.
bbl = data["bbl"]
bbl_stage = str(bbl.get("stage", "")).lower().strip()
if bbl_stage == "idle" and str(bbl.get("status","")).lower() not in ("complete", "finish"):
bbl["status"] = "idle"
return [data["cc1"], data["cc2"], bbl]
except Exception as e:
print(f"Fetch error: {e}")
return None
# ── MAIN LOOP ─────────────────────────────────────────────────
def main():
# Show startup message while connecting
clear_all()
draw_text_centered("CONNECTING", 10, 5)
draw_text_centered("TO WIFI", 17, 7)
requests_session = init_wifi()
# Initial data fetch
printers = [
{"nozzle": 0, "bed": 0, "pct": 0, "status": "idle", "remaining": "0"},
{"nozzle": 0, "bed": 0, "pct": 0, "status": "idle", "remaining": "0"},
{"nozzle": 0, "bed": 0, "pct": 0, "status": "idle", "remaining": "0"},
]
last_fetch = 0
last_cycle = 0
last_flash = 0
current_page = 0
flash_state = True
# Dirty-flag tracking — only redraw when something actually changes
last_drawn_page = -1
last_drawn_statuses = []
last_drawn_pcts = []
last_drawn_flash = None
last_drawn_all_idle = None
needs_redraw = True # always draw once on startup
while True:
now = time.monotonic()
# ── Fetch from HA every POLL_INTERVAL seconds
if now - last_fetch >= POLL_INTERVAL:
result = fetch_printer_data(requests_session)
if result:
if result != printers:
printers = result
needs_redraw = True
last_fetch = now
# ── Compute current state
statuses = [normalize_status(p.get("status", "idle")) for p in printers]
any_error = any(s == "error" for s in statuses)
any_printing = any(s == "printing" for s in statuses)
all_idle = all(s == "idle" for s in statuses)
current_pcts = [p.get("pct", 0) for p in printers]
# ── Build slide list:
# - One slide per printing/error printer (in order: CC1, CC2, A1)
# - One consolidated idle slide at the end if any printers are idle
idle_indices = [i for i, s in enumerate(statuses) if s == "idle"]
active_indices = [i for i, s in enumerate(statuses) if s != "idle"]
# slides is a list of: printer_index (int) or "idle" sentinel
slides = active_indices + (["idle"] if idle_indices else [])
if not slides:
slides = ["idle"] # shouldn't happen but safe fallback
num_slides = len(slides)
# ── Advance page every CYCLE_INTERVAL seconds
if now - last_cycle >= CYCLE_INTERVAL:
new_page = (current_page + 1) % num_slides
if new_page != current_page:
current_page = new_page
needs_redraw = True
last_cycle = now
# Keep current_page in bounds if slide count changed (e.g. print finished)
if current_page >= num_slides:
current_page = 0
needs_redraw = True
# ── Flash toggle — only fires when an error is actually present
# Updates ONLY the 3x3 dot pixels in place — no full redraw
if any_error and now - last_flash >= FLASH_RATE:
flash_state = not flash_state
last_flash = now
# Find which slide is currently showing and update just its dot
if not all_idle:
slide = slides[current_page] if current_page < len(slides) else None
if slide is not None and slide != "idle":
if statuses[slide] == "error":
dot_idx = 3 if flash_state else 0 # yellow or black
draw_dot(1, 1, dot_idx)
elif slide == "idle":
# Idle group slide — update dots for any error printers shown
row_ys = [1, 9, 17]
for slot, idx in enumerate(idle_indices):
if statuses[idx] == "error":
dot_idx = 3 if flash_state else 0
draw_dot(1, row_ys[slot], dot_idx)
elif all_idle:
# All-idle screen — update dots for any error printers
rows = [11, 18, 25]
for i, s in enumerate(statuses):
if s == "error":
dot_idx = 3 if flash_state else 0
draw_dot(1, rows[i], dot_idx)
# ── Redraw if pct changed while printing
if any_printing and current_pcts != last_drawn_pcts:
needs_redraw = True
# ── Skip draw if nothing changed
if not needs_redraw:
time.sleep(0.1)
continue
# ── Redraw ───────────────────────────────────────────────
if all_idle:
draw_all_idle(printers)
else:
slide = slides[current_page]
if slide == "idle":
draw_idle_group_slide(idle_indices, printers)
else:
p = printers[slide]
status = statuses[slide]
name = full_names[slide]
draw_printer_slide(p, name, status, flash_state, slide)
# Record what we just drew
last_drawn_page = current_page
last_drawn_statuses = statuses[:]
last_drawn_pcts = current_pcts[:]
last_drawn_flash = flash_state
last_drawn_all_idle = all_idle
needs_redraw = False
time.sleep(0.05) # brief yield after a draw
main()
About This Project
This project was built by a maker running a three-printer home print farm who wanted a quick-glance status board without pulling out a phone. The entire build — from initial concept to working display — was developed iteratively, with each display issue, status edge case, and layout problem solved one step at a time.
The software stack is entirely free and open source.
All code for this project is available to copy from this article. The HA template can be adapted to any printers supported by Home Assistant integrations — not just Elegoo and Bambu models.

