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
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
websocketspackage (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
- fast model, every check:
- 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
- Give the printer a fixed address in your router.
- Run
find_printer.pyand copy theMainboardIDout of the reply. - Make the settings file, with
PW_PAUSE=0for now. - Copy
printwatch.pyto~/printwatch/on the always-on computer andpip install websockets. - Run
python3 printwatch.py --test-push. Your phone should get “printwatch armed”. - Start a print, then run
python3 printwatch.py --once. It reads the status, takes one lit photo (saved asframes/last.jpg) and prints the fast model’s score. Open the photo: it should be properly lit, not a dark chamber. - Install the service:
sudo systemctl daemon-reload, thensudo systemctl enable --now printwatch. - Watch a few prints with
PW_PAUSE=0. Readlog.jsonlafterwards (below). - When you trust it, set
PW_PAUSE=1andsudo 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-pushjust 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_errorlines. 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_*.jpgis what it paused on.frames/rejected_*_first.jpgand_confirm.jpgare 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.