3D printer: on a real device#
A desktop FDM printer with a HEART on its Raspberry Pi host, beside the firmware and never inside it: the parts, the wiring, the code, how to install it, the factory steps, and what its owner sees.
For illustration only. The hardware below is an assumption, chosen so the steps can be concrete. Your printer, board, firmware and print server will differ: check your own hardware. This product has not been built, and no HEART has watched a real printer. The Python template is tested on Linux (rated 8.9, provisional). The additions on this page (the printer reader, the rule table, the OctoPrint plugin) are new code that has not been written or tested in the templates. Check every step on your own bench, with the printer attended.
| Placement | B: beside the controller. The HEART runs on the printer's Linux host board and only reads what the print server already receives. The mainboard firmware, which owns the heaters, the motors and thermal-runaway protection, is not touched. |
| Start from | The Python template (heart/embeddedstandard/python/v1.2611.3) |
| Claim | HEART Basic, Autonomy Level A1. No commands at all. |
| Try it first | The simulator and the design case study |
1. The device we assume#
| Part | Assumed choice | Notes |
|---|---|---|
| Printer | A common desktop FDM printer with a 32-bit mainboard (an STM32-class chip) running Marlin 2.1, with TMC2209 stepper drivers | Marlin's thermal protection runs on the mainboard. If yours runs Klipper, read Section 8 first. |
| Thermistors | The 100 kΩ NTC thermistors fitted to the hotend and bed | Read by the firmware. HEART sees only the firmware's temperature reports. |
| Run-out sensor | A switch on the filament path, wired to the mainboard | The firmware pauses the job |
| Host | A Raspberry Pi 4 (2 GB) running OctoPrint (the OctoPi image) | The HEART runs here, as a separate service |
| Link | A USB cable from the mainboard to the Pi | /dev/ttyUSB0 or /dev/ttyACM0, opened by OctoPrint only |
| Driver temperature | A DS18B20 1-Wire probe glued to the stepper drivers' heatsink, wired to the Pi | The firmware does not know this value; HEART reads it on the Pi |
| Status light | A red LED on the Pi | Blinks a fault at the printer, with no internet |
| Buzzer | An active 5 V buzzer through an NPN transistor on the Pi | Beeps a fault at the printer |
| Network | The Pi's Wi-Fi | Reports to HEART Hub, outbound only |
Example wiring on the Pi#
| Signal | Pi pin | Notes |
|---|---|---|
| DS18B20 data | GPIO4 (pin 7), with a 4.7 kΩ pull-up to 3.3 V | dtoverlay=w1-gpio in config.txt (/boot/firmware/config.txt on recent images, /boot/config.txt on older ones). GPIO4 is its default pin. |
| Status LED | GPIO17 (pin 11), through a 330 Ω resistor | Driven by HEART's escalation only |
| Buzzer | GPIO27 (pin 13), to the transistor's base through 1 kΩ | Driven by HEART's escalation only |
| Mainboard | USB | Not HEART's. Its user is not in the dialout group, and its service cannot open the port |
The LED and buzzer are on the Pi, not the printer's own screen and beeper. Using the printer's screen would mean sending G-code (M117, M300) into the firmware's command stream. This design gives HEART no path into the firmware at all.
2. Where everything runs#
┌──── mainboard: Marlin ────┐ USB serial ┌──────────── Raspberry Pi: OctoPi ─────────────────────┐
│ heaters and their PID │◄────────────►│ OctoPrint: sends jobs, polls temperatures (M105/M155) │
│ steppers, endstops │ owned by │ heart_lines plugin (read only): copies the firmware's│
│ thermal-runaway protection │ OctoPrint │ halt and run-out lines to a file │
│ run-out pause │ │ heart.py (from the Python template), its own service: │
└────────────────────────────┘ │ reads OctoPrint's API (GET only), the plugin's file, │
│ the DS18B20 and the Wi-Fi state; checks every 5 s; │
│ blinks the LED, beeps; API on 127.0.0.1:43278 │
│ HEART Hub reports ────────────────────────────────┼──► heartcortex.ai
└────────────────────────────────────────────────────────┘ (outbound only)
- The firmware is the safety chain. HEART is the witness. When thermal-runaway protection fires, the heaters are already off before HEART notices. HEART only says so, clearly, to a person.
- No new path into the firmware. HEART reads what OctoPrint already receives. It never opens the serial port, never sends G-code, and never calls an OctoPrint endpoint that changes anything.
- If HEART crashes or is removed, the printer must keep printing and its protection must keep working. That is the test (Section 7).
3. The code#
Copy heart.py from the Python template to /opt/heart/printer/heart.py, then make these changes.
Part of heart.py | What you do |
|---|---|
The four CHANGE lines at the top | 3.1 |
MONITOR | Leave False in the file. The service turns reporting on with HEART_MONITOR_URL (Section 4). |
MONITOR_APPROVALS, LIFECYCLE, BRAIN | Leave off. This HEART changes nothing. |
check_main_function() | Not used: the rule table of 3.3 replaces tend() |
escalate() | 3.4 |
| New: the reader | 3.2 |
3.1 Identity#
SYSTEM_ID = "com.example.printers.mk3" # CHANGE: as in the guide's Card
NAME = "Example desktop printer" # CHANGE
VERSION = "1.0.0" # CHANGE
INSTANCE = "printer-1" # CHANGE: must not identify a person
3.2 Read the printer, read only#
Add a reader thread, started from main(). It polls every 2 seconds and keeps a short history, so a rule can tell a live reading from a frozen one.
import subprocess # add to the imports, for escalate()
OCTOPRINT = os.environ.get("OCTOPRINT_URL", "http://127.0.0.1:5000")
OCTOPRINT_KEY = os.environ.get("OCTOPRINT_KEY", "") # the read-only user's key (Section 4)
FIRMWARE_LOG = "/srv/heart-feed/firmware.log" # written by the plugin of 3.5
DRIVER_PROBE = "/sys/bus/w1/devices/28-xxxxxxxxxxxx/w1_slave" # CHANGE: your probe's ID
def read_printer():
"""Read only: GET requests and files. Never sends G-code, never posts to OctoPrint."""
while True:
# GET /api/printer: temperatures (actual, target) and state flags (printing, paused, error).
# A 409 answer means OctoPrint is not connected to the printer.
# The plugin's file: the newest firmware line, such as "Error:Thermal Runaway, system stopped! Heater_ID: 0",
# or your firmware's run-out message.
# The DS18B20: the "t=" value in w1_slave, in thousandths of a degree.
# /sys/class/net/wlan0/operstate: "up" or not.
# Store them in printer["..."], and the last 30 s of hotend and bed readings in printer["history"].
time.sleep(2)
The reader fills one dictionary, printer, under its own lock. The rules read a copy of it.
3.3 The rules#
Replace the template's tend() with a rule table: the guide's fault table, Step 5. Two rules may share a fault code; each keeps its own alert.
RULES = [ # CHANGE: name, fault code, severity, rule(p) -> plain words while it holds, else None. Your own thresholds.
("halt", "system.main-function-lost", "critical", lambda p: p["halt"] and
f"The firmware has shut the heaters down ({p['halt']}). HEART only reports this: check the hotend before resetting the printer."),
("runout", "system.main-function-lost", "error", lambda p: p["paused"] and p["runout"] and
"Filament has run out: the print is paused. Load a new spool."),
("slow-heat", "system.main-function-degraded", "warning", lambda p: p["heating_s"] > 120
and p["hotend"] < p["hotend_target"] - 5 and "The hotend is heating far more slowly than normal."),
("stuck", "sensors.stuck", "error", lambda p: p["target_on"] and p["unchanged_s"] >= 30 and
"A thermistor reading has not changed at all for 30 s while heating. A live reading always jitters."),
("range", "sensors.out-of-range", "error", lambda p: p["hotend"] is not None and not -10 < p["hotend"] < 300 and
f"The hotend reads {p['hotend']:.0f} Cel, which is impossible: check the thermistor connector."),
("overshoot", "performance.overshoot", "warning", lambda p: p["reached"] and p["hotend"] > p["hotend_target"] + 15 and
"The hotend went more than 15 Cel past its target. Re-tune the PID."),
("driver-hot", "performance.too-hot", "warning", lambda p: (p["driver"] or 0) > 80 and
f"The stepper drivers' heatsink is {p['driver']:.0f} Cel: check their fan."),
("wifi", "connectivity.link-lost", "warning", lambda p: not p["wifi_up"] and "The printer's Wi-Fi link is down."),
]
def tend():
"""Replaces the template's tend(): checks every 5 s; a rule must hold for 5 s; one alert per rule."""
since = {}
while True:
p = printer_snapshot()
with lock:
state["sequence"] += 1
for name, code, severity, rule in RULES:
why = rule(p) or None
since[name] = (since.get(name) or time.time()) if why else None
kind = f"{SYSTEM_ID}.{name}"
open_alert = next((a for a in state["alerts"] if a["status"] != "resolved" and a["kind"] == kind), None)
if why and open_alert is None and time.time() - since[name] >= 5:
alert = {"id": str(uuid.uuid4()), "kind": kind, "code": code, "severity": severity,
"status": "escalated", "detectedAt": now(), "summary": why.split(":")[0], "explanation": why}
state["alerts"] = (state["alerts"] + [alert])[-100:]
escalate(alert)
elif not why and open_alert is not None:
open_alert.update(status="resolved", resolvedAt=now())
sev = {a["severity"] for a in state["alerts"] if a["status"] != "resolved"}
state["status"] = "failing" if "critical" in sev else "degraded" if sev else "healthy"
time.sleep(5)
The Pulse interval in the Card stays PULSE_SEC (30 s); the checks run more often so that a halt is reported within seconds.
Two rules from the guide need checking on your printer:
- Run-out. OctoPrint shows that a job is paused, not why. The rule raises only when the plugin has seen your firmware's own run-out message. Find its exact text on your printer by running a spool out once.
- Layer shift (
actuators.position-mismatch). The guide's rule uses the drivers' stall reports. Marlin does not send these to the host during a print by default, so this table leaves the rule out. Add it only if your firmware reports stalls.
3.4 Tell a person#
def escalate(alert):
"""CHANGE: at the printer first (LED and beep on the Pi, no internet needed), then the owner's phone."""
print(f"HEART escalate: {alert['code']}: {alert['explanation']}", file=sys.stderr)
subprocess.Popen(["/opt/heart/printer/alert-light", alert["severity"]]) # yours: blinks GPIO17, beeps GPIO27
# CHANGE: and a notification to the owner's phone, by the route the print server already uses for "print finished"
alert-light is a small script of yours, using libgpiod's gpioset (its options differ between libgpiod versions: check yours). tend() calls escalate() while holding the lock, so it must return at once: hence Popen, not run.
3.5 The read-only OctoPrint plugin#
OctoPrint owns the serial port, so the firmware's halt and run-out lines reach HEART through a one-file plugin. It copies matching lines to a file and returns every line unchanged. It sends nothing.
# heart_lines.py: copy to the OctoPrint user's ~/.octoprint/plugins/, then restart OctoPrint
import re
KEEP = re.compile(r"^(Error:|//action:)") # CHANGE: the lines your firmware sends for a halt or a run-out
def received(comm, line, *args, **kwargs):
if KEEP.match(line):
with open("/srv/heart-feed/firmware.log", "a") as f:
f.write(line.strip()[:200] + "\n")
return line # unchanged
__plugin_name__ = "HEART lines (read only)"
__plugin_pythoncompat__ = ">=3,<4"
__plugin_hooks__ = {"octoprint.comm.protocol.gcode.received": received}
Rotate the file with logrotate. The firmware lines hold no job names.
4. Build and install#
There is nothing to compile. On the Pi, as root:
1. Check the firmware's own protection, and leave it alone. In your Marlin build, THERMAL_PROTECTION_HOTEND and THERMAL_PROTECTION_BED must be on, and the run-out sensor set up. If they are off, that is a firmware matter to fix first. HEART does not replace them.
2. Give HEART a read-only OctoPrint login. In OctoPrint's access control, make a user in a group that may only read status (OctoPrint 1.4 and later have user groups and permissions: check yours), and make an application key for it.
3. Make the user, the folders and the feed:
useradd --system --home-dir /var/lib/heart-printer --shell /usr/sbin/nologin heart
install -d /opt/heart/printer
install -m 0644 heart.py /opt/heart/printer/heart.py
install -m 0755 alert-light /opt/heart/printer/alert-light
install -d -m 2750 -o pi -g heart /srv/heart-feed # the OctoPrint user writes, heart reads
install -d -m 0750 -g heart /etc/heart
On older OctoPi images the OctoPrint user is pi; use yours.
4. The settings, in /etc/heart/printer.env (mode 0640, group heart):
OCTOPRINT_URL=http://127.0.0.1:5000
OCTOPRINT_KEY=the-read-only-user's-key
HEART_MONITOR_URL=https://heartcortex.ai
HEART_MONITOR_URL is what turns reporting on: the template reports when MONITOR is True or this variable is set. Remove the line to turn it off, or put your own monitor site's address.
5. The service, /etc/systemd/system/heart-printer.service:
[Unit]
Description=HEART for the 3D printer (watch only)
After=network-online.target octoprint.service
Wants=network-online.target
[Service]
ExecStart=/usr/bin/python3 /opt/heart/printer/heart.py
EnvironmentFile=/etc/heart/printer.env
User=heart
Group=heart
SupplementaryGroups=gpio
WorkingDirectory=/var/lib/heart-printer
StateDirectory=heart-printer
Restart=on-failure
RestartSec=5
# HEART must never take resources from the printer.
Nice=10
CPUQuota=10%
MemoryMax=64M
NoNewPrivileges=true
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
# Only the GPIO chip: the printer's serial port cannot be opened from this service.
DevicePolicy=closed
DeviceAllow=/dev/gpiochip0 rw
[Install]
WantedBy=multi-user.target
- It runs as
heart, with no login, not indialout, and writes only to/var/lib/heart-printer. That is whereheart-monitor.json(the device ID, claim code and key, mode 0600) is kept. Restart=on-failure, as the template asks: a clean exit is not restarted./dev/gpiochip0is the header's GPIO chip on a Pi 4. Check yours.
systemctl daemon-reload
systemctl enable --now heart-printer
journalctl -u heart-printer -f
At first start the log shows the HEART Hub line once:
HEART Hub: register this device at https://heartcortex.ai/register?id=3f9c0a71b2d4e865 with claim code AB3CD-EF4GH-JK5LM-NP6QR
Check it on the Pi:
curl http://127.0.0.1:43278/.well-known/heart-card.json
curl http://127.0.0.1:43278/heart/v1/pulse
curl http://127.0.0.1:43278/heart/v1/arrhythmias
The template serves plain HTTP on loopback only. For a phone on the home network, put a TLS proxy in front, with this printer's own certificate.
5. On the production line#
For each printer:
- Flash the mainboard with the release firmware, as now, and run the firmware's own thermal-protection tests. HEART adds nothing to this step.
- Write the Pi's card from your release image. The image must not contain
/var/lib/heart-printer/heart-monitor.json, or every printer would share one identity. - Start it once on the test bench. It makes its device ID, claim code and key. Read them from
journalctl -u heart-printer, and print the label for the printer's frame: the claim code, and a QR code ofhttps://heartcortex.ai/register?id=<ID>. For a product, replace the log line with your label printer (theCHANGEatmonitor_identity()). - Make its own OctoPrint key for the read-only user. Never ship one key in every image.
- Run the bench tests of Section 7 on a sample from each batch.
6. What the owner sees#
- Setup. The maker's setup joins the Pi to the Wi-Fi, as before.
- Registration. The owner scans the QR code on the label, signs in at heartcortex.ai with an emailed code, and types the claim code. The page shows Healthy. It is private until the owner shares it. See the owner's manual.
- Every day. Nothing. Jobs are started and watched in OctoPrint, as before.
- When something goes wrong:
| Real event | The HEART notices | The owner sees |
|---|---|---|
| The hotend thermistor slips out of the block; the firmware cuts the heaters and halts | system.main-function-lost (critical), from the firmware's halt line | The LED blinks and the buzzer beeps; the phone and the page show Failing. The heaters were already off |
| The spool runs out; the firmware pauses and parks | system.main-function-lost (error) | Degraded: "Load a new spool" |
| A heater cartridge weakens | system.main-function-degraded after 2 minutes of slow heating | A warning |
| The bed reading freezes at its target | sensors.stuck after 30 s with no change | An error. The firmware cannot see this one |
| The thermistor connector opens | sensors.out-of-range | An error; the firmware refuses to heat |
| The drivers' fan stops | performance.too-hot above 80 Cel on the heatsink | A warning, before the drivers shut down |
| The Wi-Fi drops | connectivity.link-lost, kept on the Pi | After 90 seconds Not heard from, and one email. The alert shows when the link is back. Printing goes on |
| The printer is switched off | Nothing can be sent | Not heard from after 90 seconds, and one email |
The page never shows a temperature, a job name or a layer: only status and fault codes.
- Changing anything. HEART changes nothing. Heating, moving, pausing and resuming stay in OctoPrint and on the printer's screen, where the owner already does them.
7. Test it on the bench#
Cause every fault for real, on an attended printer, and check that the right fault code appears within a minute, on the API and on the HEART Hub page.
| Fault | How to cause it on the bench | Expect |
|---|---|---|
| Thermal-runaway halt | Power off. Slide the hotend thermistor half out of the block. Power on and heat the hotend, standing by with the power switch | The firmware halts and cuts the heaters (check with an IR thermometer); then system.main-function-lost, critical |
| Thermistor open | Power off, unplug the hotend thermistor, power on | sensors.out-of-range, and system.main-function-lost if the firmware halts |
| Run-out | Cut the filament above the sensor during a print | system.main-function-lost, error |
| Slow heat-up | Remove the silicone sock and blow the part fan at the block during heat-up | system.main-function-degraded, if heating takes over 2 minutes |
| Driver heatsink hot | Warm only the DS18B20 with a hot-air tool on a low setting, past 80 Cel | performance.too-hot |
| Frozen reading | No safe way to freeze a real thermistor. Test this rule with a recorded feed in place of OctoPrint | sensors.stuck |
| Wi-Fi lost | Switch off the access point for 2 minutes | Not heard from on the page, then connectivity.link-lost and Healthy again |
| HEART stopped mid-print | systemctl stop heart-printer during a print | The print goes on, OctoPrint is unaffected, and the page shows Not heard from |
| No changes | curl -X POST http://127.0.0.1:43278/heart/v1/capabilities/x/invoke | 501 not-implemented (the template's answer; the simulator answers 403 capability-not-allowed). Nothing changes |
| No serial port | sudo -u heart cat /dev/ttyACM0 | Permission denied |
| Privacy | Read every message sent to HEART Hub (your own monitor site, on the bench) | Status, sequence, interval and fault codes only. No temperature, job name or layer |
Then run the HEART-Rating client against the Pi, and claim only what passed.
8. The safety and firmware boundary#
| HEART may | HEART may not |
|---|---|
| Read temperatures, targets and state through OctoPrint's API (GET only) | Open the printer's serial port, or send G-code of any kind |
| Read the firmware's halt and run-out lines, copied by a plugin that changes nothing | Write a heater target, a fan speed or a motion command |
| Read its own probe on the drivers' heatsink, and the Pi's Wi-Fi state | Turn off, delay or reset thermal-runaway, heating-watch or MAXTEMP protection |
| Blink its own LED and beep its own buzzer, on the Pi | Pause, resume, cancel or restart a job |
| Report status and fault codes to HEART Hub | Restart the printer after a thermal shutdown |
The service enforces it, not only the code: no dialout group, DevicePolicy=closed, a read-only OctoPrint user, and CPU and memory limits so it can never slow the print server.
If your printer runs Klipper. With Klipper, the heater control loop and its verify_heater check run on the host, in the Klippy process on the same Pi, not on the mainboard. The Pi is then part of the safety chain. HEART can still run there, as above, but the resource limits matter more: HEART must never take CPU or memory from Klippy. Read through Moonraker with GET only (for example /printer/objects/query?webhooks&print_stats&extruder&heater_bed: webhooks shows a shutdown and its message). Never call Moonraker's endpoints that run G-code or stop the printer. If you cannot keep that separation, put HEART on a second board.
What the guide allows later. At A4, with HEART Full, an adopted Playbook could pause a job, because pausing stops motion and starts no heating; it would be adopted as physical, with proof of presence. Resuming always needs a person. Nothing ever resumes or resets after a thermal shutdown.
9. Going further#
- Why, with a confidence (A2). "The hotend reading fell from 215 to 170 Cel in 20 s while the heater ran at full power: the thermistor has likely come loose, confidence high." That needs HEART Connected, with Rhythm events as evidence.
- HEART Full on the Pi.
heartdhas the approval gate, passkey checks and Playbooks. A reader like 3.2 would feed it through its host feed (host_socket, one JSON line per event or value: see its configuration). Its configuration has no HEART Hub section, so the reporting would need code of its own. Its packaged service file sets CPU and memory ceilings, as Section 4 does. - More on the page. HEART Hub lets a device share a few numbers on its page (
figures, HEART Hub Section 5.3), only if the maker or owner turns them on. The Python template does not send them today, so that would be code of your own. Job names never belong there.