Maker.io main logo

Matrix Portal LED Matrix 3D Printer Status Display

94

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

Adafruit Matrix Portal M4

64×32 RGB LED Matrix Panel (HUB75)

USB-C Power Supply (5V 3A+)

USB-C Data Cable

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:

Copy Code
{
  "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:

Copy Code
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:

image 

Idle DisplayPrint in progress display

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:

Copy Code
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:

Copy Code
# 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.

 

Mfr Part # 4745
ADAFRUIT MATRIX PORTAL - CIRCUIT
Adafruit Industries LLC
Mfr Part # 2277
64X32 RGB LED MATRIX - 5MM PITCH
Adafruit Industries LLC
Mfr Part # SC0445
AC/DC WALL MNT ADAPTER 5.1V 15W
Raspberry Pi
Mfr Part # DH-20M50053
USB AM TO USB C, USB 2.0 - 2 MET
Cvilux USA
Mfr Part # SC0194(9)
SBC 1.5GHZ 4 CORE 4GB PI 4 MOD B
Raspberry Pi
$100.00
View More Details
Add all DigiKey Parts to Cart
Have questions or comments? Continue the conversation on TechForum, DigiKey's online community and technical resource.