Printwatch: an AI that pauses a failing 3D print

A fast AI model checks my 3D printer's camera every minute, and the print is only paused once a stronger model agrees it has failed.

Difficulty
Moderate
Parts cost
About 7 US cents per hour of printing, in AI fees
Build time
An afternoon, then a few prints of watching
Skills
Python, Linux services, Home Assistant

The problem

My printer is an Elegoo Centauri Carbon, and some of my prints run for twelve hours or more. When a print lets go of the bed, the nozzle keeps going and turns a spool of filament into a bird’s nest of plastic string. People call it “spaghetti”. If nobody’s watching, it can do that for hours.

The printer has a camera inside, so I wanted something that looks at it for me and stops the print when it goes wrong. Not just a warning on my phone at 2 am, an actual pause. That’s the scary part: a false alarm pauses a perfectly good print. So most of this guide is about not pausing when it shouldn’t.

How it works

How printwatch decides to pause a print While a print is running, printwatch turns the chamber light on once a minute, takes one photo from the printer's camera and turns the light back off. A fast AI model compares that photo with one from five minutes earlier and scores it from 0 to 100. Only after two scores of 70 or more in a row does a fresh photo go to a stronger model. Only if that model also scores 70 or more is the print paused, over the same connection printwatch reads the printer's status on, and a notification with the photo goes to the phone through Home Assistant. A "no" at any step goes back to watching. 3D printer status socket + chamber camera Every 60 seconds, while printing light on, one photo, light back off Fast model compares it with a photo from 5 min ago scores it 0–100 for "failed" Stronger model only after two 70+ scores in a row fresh photo 6 s later: still 70+? Pause the print over the same socket, then a push to my phone through Home Assistant A "no" at any step goes back to watching. If the stronger model says no, both photos are saved so I can see what fooled it.

printwatch is one Python script that runs as a service on a computer that’s always on. It keeps one connection open to the printer. That connection gives it the printer’s status (printing or not, which layer, temperatures) and also carries the commands it sends back: chamber light on, chamber light off, pause. The printer speaks Elegoo’s own protocol for this, called SDCP, over a websocket (a connection that stays open both ways, instead of one request at a time).

While a print is running, once a minute it turns the chamber light on, waits a couple of seconds for the camera to adjust, grabs one photo and turns the light back off. That photo, plus one from about five minutes earlier and the layer number and temperatures, goes to a fast, cheap model (Claude Haiku 4.5) with one question: has this print failed? It answers with a score out of 100.

One high score does nothing. It takes two scores of 70 or more in a row. Even then, printwatch waits six seconds, takes a fresh photo and asks a stronger model (Claude Sonnet 5). Only if that also comes back 70 or more does it pause the print and send a photo-backed notification to my phone through Home Assistant. If the stronger model disagrees, nothing is paused, and both photos are saved so I can see what fooled the fast one.

What you need

  • An Elegoo Centauri Carbon, or another printer that speaks SDCP and has a camera. Mine was on firmware V1.4.46 when I first mapped it, and V1.4.49 later. I’ve only run this on the Centauri Carbon.
  • A computer that’s always on and on the same network: a home server, a mini PC or a Raspberry Pi. It needs Python 3.10 or newer and the websockets package (pip install websockets).
  • An OpenRouter account and API key. OpenRouter is one account that gives you many AI models, and it reports what each request cost. That’s where my cost numbers come from. Model IDs used:
    • fast model, every check: anthropic/claude-haiku-4.5
    • stronger model, only before a pause: anthropic/claude-sonnet-5
  • Home Assistant with the companion app on your phone, for the notification. You need a long-lived access token (your profile in Home Assistant, Security tab) and the name of your phone’s notify service, for example notify.mobile_app_example_phone.
  • Optional: the Elegoo integration from HACS (danielcherubini/elegoo-homeassistant) puts the printer’s status, camera and pause/resume buttons into Home Assistant. printwatch doesn’t need it, but it’s handy for resuming a print from your phone after a pause.

Wiring

There are no wires. Everything goes over the network. The Centauri Carbon has four doors open:

Port What it is What printwatch uses it for
3030, websocket at /websocket SDCP: status and commands status every 30 s, light on and off, pause
3031, /video the chamber camera, as an MJPEG stream (a constant run of JPEG photos) one photo per check
UDP 3000 discovery finding the printer and its mainboard ID, once
80 the printer’s own web page not used

Two things to know before you start:

  • The printer doesn’t answer ping. If you’re checking whether it’s there, open its web page in a browser instead. And when it’s switched off, every port is closed. That’s just “off”, not “broken”.
  • Give it a fixed address in your router. Mine was on automatic (DHCP) and moved to a different address once. Anything pointing at the old one just stops.

Every SDCP command has to carry the printer’s mainboard ID. You get it once from the discovery reply. Send the text M99999 as a broadcast to UDP port 3000 and the printer answers with a block of JSON that includes its MainboardID:

# find_printer.py: run once, copy the MainboardID out of the reply
import socket

s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
s.settimeout(3)
s.sendto(b"M99999", ("255.255.255.255", 3000))
data, addr = s.recvfrom(4096)
print("printer at", addr[0])
print(data.decode())

Firmware and config

There’s no firmware. It’s one script, one settings file and one service file.

The settings file

Secrets live here, not in the code. Mine is ~/.config/printwatch/env. Make it readable only by you (chmod 600) and fill in your own values:

PRINTER_IP=your-printer-ip
PRINTER_MAINBOARD_ID=the-id-from-find_printer
OPENROUTER_API_KEY=your-openrouter-key
HA_URL=http://homeassistant.local:8123
HA_TOKEN=your-long-lived-access-token
HA_NOTIFY=mobile_app_example_phone
# 1 = pause on a confirmed failure, 0 = only tell me
PW_PAUSE=0

Start with PW_PAUSE=0. It does everything except the pause, so you can watch a few prints and see what it would have done.

The script

This is the core of what runs on my server, tidied up for reuse. The camera address is http://PRINTER_IP:3031/video, built from your settings file.

#!/usr/bin/env python3
"""printwatch: pause a 3D print when an AI sees it failing.

Written for an Elegoo Centauri Carbon (it speaks Elegoo's SDCP protocol).
Every minute while a print runs: chamber light on, one camera photo, light back
how it was, and a fast model scores the photo for "has this failed?".
Two high scores in a row -> a fresh photo goes to a stronger model -> only if
it agrees is the print paused and a notification sent through Home Assistant.

  python3 printwatch.py              run forever
  python3 printwatch.py --once       score one photo now, print the result, exit
  python3 printwatch.py --test-push  send a test notification, exit
"""
import asyncio, base64, json, os, sys, time, uuid, urllib.request
from collections import deque
from pathlib import Path

import websockets  # pip install websockets

# ---------- settings (secrets come from the environment, never the code) ----------
PRINTER_IP   = os.environ["PRINTER_IP"]            # give the printer a fixed address in your router
MAINBOARD_ID = os.environ["PRINTER_MAINBOARD_ID"]  # from the printer's discovery reply
API_KEY      = os.environ["OPENROUTER_API_KEY"]
HA_URL       = os.environ["HA_URL"]                # e.g. http://homeassistant.local:8123
HA_TOKEN     = os.environ["HA_TOKEN"]              # a Home Assistant long-lived access token
HA_NOTIFY    = os.environ.get("HA_NOTIFY", "mobile_app_example_phone")

CAMERA_URL = f"http://{PRINTER_IP}:3031/video"      # MJPEG stream
SDCP_URL   = f"ws://{PRINTER_IP}:3030/websocket"    # status AND commands, one connection

FAST_MODEL   = os.environ.get("PW_MODEL", "anthropic/claude-haiku-4.5")        # every check
STRONG_MODEL = os.environ.get("PW_CONFIRM_MODEL", "anthropic/claude-sonnet-5")  # only before a pause
PAUSE_ON_ALERT = os.environ.get("PW_PAUSE", "1") == "1"   # 0 = notify only

INTERVAL      = 60    # seconds between checks
REF_AGE       = 300   # the comparison photo is about this old (seconds)
ALERT_SCORE   = 70    # out of 100
CONSECUTIVE   = 2     # high scores in a row before the stronger model is asked
CONFIRM_DELAY = 6     # seconds to wait before the fresh confirm photo
REALERT_EVERY = 900   # at most one alert every 15 minutes
LIGHT_SETTLE  = 2.5   # seconds for the camera to adjust after the light comes on
NOT_PRINTING  = {0, 6, 8, 9}  # PrintInfo.Status: idle, paused, stopped, complete

HERE = Path(__file__).resolve().parent
LOG = HERE / "log.jsonl"
FRAMES = HERE / "frames"; FRAMES.mkdir(exist_ok=True)
STATE = {"ws": None, "status": None, "status_at": 0.0, "pending": {}}


def log(**kw):
    kw["ts"] = time.strftime("%Y-%m-%d %H:%M:%S")
    print(json.dumps(kw), flush=True)
    with LOG.open("a") as f:
        f.write(json.dumps(kw) + "\n")


# ---------- camera ----------
def grab_frame(timeout=8, skip=3) -> bytes:
    """One JPEG from the MJPEG stream, skipping the first few (they can be stale)."""
    with urllib.request.urlopen(CAMERA_URL, timeout=timeout) as r:
        buf = b""; seen = 0
        while len(buf) < 2_000_000:
            chunk = r.read(8192)
            if not chunk:
                break
            buf += chunk
            while True:
                s = buf.find(b"\xff\xd8")            # JPEG start
                e = buf.find(b"\xff\xd9", s + 2)      # JPEG end
                if s < 0 or e < 0:
                    break
                frame = buf[s:e + 2]; buf = buf[e + 2:]; seen += 1
                if seen > skip:
                    return frame
    raise RuntimeError("no JPEG frame in stream")


# ---------- printer commands ----------
# Sent over the status reader's connection. The printer refuses extra
# websocket clients once the slicer and phone app are connected.
def sdcp_message(cmd: int, data: dict, request_id: str) -> str:
    return json.dumps({"Id": uuid.uuid4().hex, "Topic": f"sdcp/request/{MAINBOARD_ID}",
                       "Data": {"Cmd": cmd, "Data": data, "RequestID": request_id,
                                "MainboardID": MAINBOARD_ID, "TimeStamp": int(time.time()), "From": 1}})


async def sdcp_cmd(cmd: int, data: dict, timeout=8) -> dict:
    ws = STATE["ws"]
    if ws is None:
        raise ConnectionError("printer socket not connected")
    rid = uuid.uuid4().hex
    fut = asyncio.get_running_loop().create_future()
    STATE["pending"][rid] = fut
    try:
        await ws.send(sdcp_message(cmd, data, rid))
        return await asyncio.wait_for(fut, timeout)
    finally:
        STATE["pending"].pop(rid, None)


async def set_light(on: bool) -> dict:
    return await sdcp_cmd(403, {"LightStatus": {"SecondLight": 1 if on else 0, "RgbLight": [0, 0, 0]}})


async def pause_print() -> dict:
    return await sdcp_cmd(129, {})   # the printer answers {"Ack": 0} when it has paused


async def grab_frame_lit(st: dict) -> tuple[bytes, bool]:
    """Photo with the chamber light on, then put the light back how we found it."""
    was_on = bool(st.get("LightStatus", {}).get("SecondLight", 1))
    if was_on:
        return await asyncio.to_thread(grab_frame), True
    await set_light(True)
    try:
        await asyncio.sleep(LIGHT_SETTLE)
        return await asyncio.to_thread(grab_frame), False
    finally:
        try:
            await set_light(False)
        except Exception as e:
            log(event="light_restore_error", err=str(e)[:120])


# ---------- the AI check ----------
PROMPT = """You are monitoring a live FDM 3D print (Elegoo Centauri Carbon, PLA) for failures.
Print: {filename}
Layer {layer} of {total} ({pct}% done). Nozzle {noz}C, bed {bed}C.
Image 1 is the CURRENT camera frame. Image 2 (if present) is the same view about {ref_age} minutes earlier.

Look for: spaghetti / stringy extruded mess, part detached or shifted on the bed, blob or "boogers" around the nozzle, layer shift, no extrusion (print stopped growing while layer count rises), print knocked over, nozzle dragging.
Early layers (1-5) legitimately look like flat outlines or thin lines — that is NOT a failure. The bed may look dusty; ignore that. Motion blur or smearing from the moving print head is a camera artefact, NOT a failure. Camera is low-res and the chamber may be dim; only score high if you actually see failure evidence.

Reply with ONLY a JSON object: {{"score": 0-100 (probability the print has failed), "issue": "none|spaghetti|detached|blob|layer_shift|no_extrusion|other", "note": "one short sentence"}}"""


def jpeg_part(jpg: bytes) -> dict:
    return {"type": "image_url", "image_url": {"url": "data:image/jpeg;base64," + base64.b64encode(jpg).decode()}}


def score_frame(cur: bytes, ref: bytes | None, st: dict, model: str) -> dict:
    pi = st.get("PrintInfo", {})
    content = [jpeg_part(cur)] + ([jpeg_part(ref)] if ref else [])
    content.append({"type": "text", "text": PROMPT.format(
        filename=pi.get("Filename", "?"), layer=pi.get("CurrentLayer", "?"), total=pi.get("TotalLayer", "?"),
        pct=pi.get("Progress", "?"), noz=round(st.get("TempOfNozzle", 0)), bed=round(st.get("TempOfHotbed", 0)),
        ref_age=REF_AGE // 60)})
    body = {"model": model, "max_tokens": 150, "temperature": 0,
            "messages": [{"role": "user", "content": content}]}
    req = urllib.request.Request("https://openrouter.ai/api/v1/chat/completions",
                                 data=json.dumps(body).encode(),
                                 headers={"Authorization": "Bearer " + API_KEY, "Content-Type": "application/json"})
    d = json.load(urllib.request.urlopen(req, timeout=90))
    txt = d["choices"][0]["message"]["content"].strip()
    try:
        out = json.loads(txt[txt.find("{"): txt.rfind("}") + 1])
    except Exception:
        out = {"score": 0, "issue": "parse_error", "note": txt[:120]}
    out["cost"] = d.get("usage", {}).get("cost")   # US$, as reported by OpenRouter
    return out


# ---------- phone notification through Home Assistant ----------
def notify(title: str, message: str):
    body = {"title": title, "message": message, "data": {"ttl": 0, "priority": "high"}}
    req = urllib.request.Request(f"{HA_URL}/api/services/notify/{HA_NOTIFY}", data=json.dumps(body).encode(),
                                 headers={"Authorization": "Bearer " + HA_TOKEN, "Content-Type": "application/json"})
    urllib.request.urlopen(req, timeout=10).read()


# ---------- printer status: one connection, kept open forever ----------
async def status_reader():
    while True:
        try:
            async with websockets.connect(SDCP_URL, ping_interval=20) as ws:
                STATE["ws"] = ws

                async def poll():
                    while True:
                        await ws.send(sdcp_message(0, {}, uuid.uuid4().hex))   # Cmd 0 = "send me your status"
                        await asyncio.sleep(30)
                task = asyncio.create_task(poll())
                try:
                    async for raw in ws:
                        msg = json.loads(raw)
                        if "Status" in msg:
                            STATE["status"] = msg["Status"]; STATE["status_at"] = time.time()
                        elif msg.get("Topic", "").startswith("sdcp/response"):
                            fut = STATE["pending"].get(msg.get("Data", {}).get("RequestID"))
                            if fut and not fut.done():
                                fut.set_result(msg["Data"].get("Data", {}))
                finally:
                    task.cancel(); STATE["ws"] = None
        except Exception as e:
            STATE["ws"] = None
            log(event="sdcp_disconnect", err=str(e)[:120])
            await asyncio.sleep(15)


def is_printing(st: dict | None) -> bool:
    if not st:
        return False
    pi = st.get("PrintInfo", {})
    return pi.get("TotalLayer", 0) > 0 and pi.get("Status") not in NOT_PRINTING


# ---------- the main loop ----------
async def watcher():
    frames: deque = deque(maxlen=REF_AGE // INTERVAL + 2)   # recent photos, for the "5 minutes ago" view
    high_streak = 0; last_alert = 0.0; task_id = None
    while True:
        await asyncio.sleep(INTERVAL)
        st = STATE["status"] if time.time() - STATE["status_at"] < 120 else None
        if not is_printing(st):
            high_streak = 0; frames.clear(); continue
        pi = st["PrintInfo"]
        if pi.get("TaskId") != task_id:          # a new print: forget the old photos
            task_id = pi.get("TaskId"); frames.clear(); high_streak = 0

        try:
            cur, _ = await grab_frame_lit(st)
        except Exception as e:
            log(event="frame_error", err=str(e)[:120]); continue
        now = time.time()
        ref = next((f for t, f in frames if now - t >= REF_AGE * 0.8), None)
        frames.append((now, cur))

        # Stage 1: the fast model, every minute.
        try:
            res = await asyncio.to_thread(score_frame, cur, ref, st, FAST_MODEL)
        except Exception as e:
            log(event="vision_error", err=str(e)[:160]); continue
        score = int(res.get("score") or 0)
        log(event="check", layer=pi.get("CurrentLayer"), total=pi.get("TotalLayer"), score=score,
            issue=res.get("issue"), note=res.get("note"), cost=res.get("cost"))
        high_streak = high_streak + 1 if score >= ALERT_SCORE else 0
        if high_streak < CONSECUTIVE or now - last_alert < REALERT_EVERY:
            continue

        # Stage 2: a fresh photo a few seconds later, judged by the stronger model.
        stamp = time.strftime("%Y%m%d-%H%M%S")
        msg = f"{res.get('issue')} ({score}%) at layer {pi.get('CurrentLayer')}/{pi.get('TotalLayer')}: {res.get('note')}"
        try:
            await asyncio.sleep(CONFIRM_DELAY)
            st2 = STATE["status"] or st
            if not is_printing(st2):
                log(event="confirm_skipped", reason="no longer printing"); high_streak = 0; continue
            frame2, _ = await grab_frame_lit(st2)
            res2 = await asyncio.to_thread(score_frame, frame2, ref, st2, STRONG_MODEL)
            score2 = int(res2.get("score") or 0)
            log(event="confirm_check", score=score2, issue=res2.get("issue"), note=res2.get("note"),
                cost=res2.get("cost"), fast_score=score)
            if score2 < ALERT_SCORE:
                (FRAMES / f"rejected_{stamp}_first.jpg").write_bytes(cur)
                (FRAMES / f"rejected_{stamp}_confirm.jpg").write_bytes(frame2)
                log(event="confirm_rejected", fast_score=score, strong_score=score2)
                high_streak = 0; continue
            cur = frame2
            msg += f"\nConfirmed on a fresh photo ({score2}%)"
        except Exception as e:
            # Couldn't confirm: fail safe and act on the two high scores we already have.
            log(event="confirm_error", err=str(e)[:160])
            msg += "\n(confirm check failed, acted on the first two checks)"

        # Act: save the photo, pause, tell the phone.
        (FRAMES / f"alert_{stamp}_L{pi.get('CurrentLayer')}.jpg").write_bytes(cur)
        paused = None
        if PAUSE_ON_ALERT:
            try:
                ack = await pause_print(); paused = ack.get("Ack") == 0
                log(event="pause", ack=ack)
            except Exception as e:
                paused = False; log(event="pause_error", err=str(e)[:120])
        title = ("PRINT PAUSED" if paused else
                 "Print problem? (pause FAILED)" if paused is False else "Print problem?")
        last_alert = now
        try:
            notify(title, msg); log(event="alert", title=title, msg=msg)
        except Exception as e:
            log(event="notify_error", err=str(e)[:120])


async def main():
    if "--test-push" in sys.argv:
        notify("printwatch armed", f"Checking every {INTERVAL}s. Auto-pause {'on' if PAUSE_ON_ALERT else 'off'}.")
        print("sent"); return
    reader = asyncio.create_task(status_reader())
    if "--once" in sys.argv:
        for _ in range(40):
            if STATE["status"]:
                break
            await asyncio.sleep(0.25)
        st = STATE["status"] or {}
        print("printing:", is_printing(st))
        cur, _ = await grab_frame_lit(st)
        (FRAMES / "last.jpg").write_bytes(cur)
        print(json.dumps(score_frame(cur, None, st, FAST_MODEL), indent=1))
        reader.cancel(); return
    log(event="start", fast=FAST_MODEL, strong=STRONG_MODEL, pause=PAUSE_ON_ALERT)
    await asyncio.gather(reader, watcher())


if __name__ == "__main__":
    asyncio.run(main())

A few things in there that matter more than they look:

  • Commands go over the status connection. pause_print() sends SDCP command 129 down the same websocket the status comes in on, and waits for the printer to answer "Ack": 0. Don’t open a second connection to send commands. See the lessons.
  • It skips the first three photos from the camera stream. They can be stale ones the printer had buffered.
  • If the confirm step itself breaks (the stronger model times out, say), it fails safe and acts on the two high scores it already has. A missed spaghetti costs more than a paused good print.
  • Light on only for the photo. If the chamber light was already on, it’s left alone. If it was off, it goes back off after the photo.

The service

So it starts on boot and restarts if it falls over. Save as /etc/systemd/system/printwatch.service and change YOUR_USER to your login:

[Unit]
Description=printwatch - 3D print failure detection
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=YOUR_USER
WorkingDirectory=/home/YOUR_USER/printwatch
EnvironmentFile=/home/YOUR_USER/.config/printwatch/env
ExecStart=/usr/bin/python3 -u /home/YOUR_USER/printwatch/printwatch.py
Restart=always
RestartSec=20s

[Install]
WantedBy=multi-user.target

What it costs

Only the AI calls cost anything, and only while a print is running. From my log over the first three weeks: 1,981 checks cost US$2.23, about a tenth of a US cent each. At one a minute that’s about 7 US cents per hour of printing. My busiest day, with about sixteen hours of printing, cost US$1.08. The stronger model costs about 0.2 US cents a go, and it only runs when the fast one is worried twice in a row.

Step by step

  1. Give the printer a fixed address in your router.
  2. Run find_printer.py and copy the MainboardID out of the reply.
  3. Make the settings file, with PW_PAUSE=0 for now.
  4. Copy printwatch.py to ~/printwatch/ on the always-on computer and pip install websockets.
  5. Run python3 printwatch.py --test-push. Your phone should get “printwatch armed”.
  6. Start a print, then run python3 printwatch.py --once. It reads the status, takes one lit photo (saved as frames/last.jpg) and prints the fast model’s score. Open the photo: it should be properly lit, not a dark chamber.
  7. Install the service: sudo systemctl daemon-reload, then sudo systemctl enable --now printwatch.
  8. Watch a few prints with PW_PAUSE=0. Read log.jsonl afterwards (below).
  9. When you trust it, set PW_PAUSE=1 and sudo systemctl restart printwatch. Don’t restart it in the middle of a long print you care about. It forgets its recent photos, so it has no five-minute-old view to compare against for the next few minutes. I wait for the print to finish before I change anything.

Testing it

  • --test-push just hangs? Check the token isn’t empty. My Home Assistant sat there with no reply at all, not even an “unauthorised”, when the token variable was blank. I spent twenty minutes thinking the server couldn’t reach Home Assistant.
  • Read the log after every print. Each check is one line in log.jsonl: layer, score, the issue it thinks it sees, a one-line note and the cost. A healthy print is a column of low scores. Mine are almost all in the 0 to 19 range.
  • Look for frame_error lines. Each one is a minute where it couldn’t get a photo, so it wasn’t watching. A few is normal. Hundreds in a row means it’s blind (see the lessons).
  • Look at the saved photos. frames/alert_*.jpg is what it paused on. frames/rejected_*_first.jpg and _confirm.jpg are the pairs where the stronger model overruled the fast one.
  • The pause has worked for real once. The printer answered "Ack": 0, the print sat paused, and I resumed it two minutes later. I’ve never forced a pause just to test it. The first real one did that job, and it turned out to be a false alarm, which is the next section.

Lessons learnt

The first auto-pause was a false alarm. Six days in, it paused a thirteen-hour ASA print at layer 182 of 707. The fast model scored 95, twice in a row: first “no extrusion”, then “detached”. The part was fine. Both photos had been taken while the print head was moving, and the motion smeared the frame. I resumed it and it finished. Two fixes came out of that: the prompt now says plainly that motion blur is not a failure, and nothing pauses until the stronger model has looked at a fresh photo and agreed.

Why two stages, in numbers. Over 1,981 checks, the fast model gave 16 high scores, all of them 85 or over. Fourteen were one-offs: the next check, a minute later, was low again. “Two in a row” quietly threw those away. The one pair that got through was the false pause above. That’s the gap the stronger model fills: it only runs when the fast one is worried twice, so it costs next to nothing, and it judges a different photo, so a smeared frame can’t fool both. The fast model alone is cheap enough to run every minute. The stronger one on every check would cost about twice as much, spent on checks that are almost all boring.

A bigger model wasn’t better. For five days I ran a second, much larger open model (Qwen2.5-VL-72B) alongside, scoring the same photos but never allowed to act. Over 1,234 paired photos it would have paused one print 24 times. It kept calling the ASA part “detached”, only ever answered 0 or 90, and hit the provider’s rate limit 120 times. The fast Claude model would have paused once, the false one above. Run any new model in shadow like that before you let it touch a print.

The printer slams the door on a third connection. With the slicer and the phone app connected, the printer answers a third websocket with an HTTP 500 error. The obvious design is a separate connection for sending the pause, and that would fail at exactly the moment it matters. Everything printwatch sends goes down the one connection it already holds.

It went blind and didn’t tell me. For most of four days in October, nearly every photo request came back with an HTTP 500 error: about 2,400 failed grabs against about 165 good checks. printwatch logged each one and carried on, so there was no alert, and for most of those prints nothing was watching. It started with the first print after I pointed two more things at the same camera (the Home Assistant integration and a second detector to compare against), and that second detector kept reporting the whole time. I haven’t proven the cause yet. Lesson: if your watcher can’t see, it should say so. Have it notify you after a handful of failed photos in a row.

What has it caught? Honestly, nothing real yet. In three weeks I haven’t had a genuine failure while it was watching, so I can’t tell you how well it catches real spaghetti. What I can tell you is that it stayed quiet through every good print except one, and that one taught me to add the second stage. If you build this, start with PW_PAUSE=0 and let it earn the pause.

The camera needs light. The chamber is often dark, and a dark photo is a useless photo. Turning the light on for each check, waiting 2.5 seconds for the camera to adjust, and throwing away the first three buffered frames is what got me properly lit photos.