HEART Embedded Systems logoHEART

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.

PlacementB: 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 fromThe Python template (heart/embeddedstandard/python/v1.2611.3)
ClaimHEART Basic, Autonomy Level A1. No commands at all.
Try it firstThe simulator and the design case study

1. The device we assume#

PartAssumed choiceNotes
PrinterA common desktop FDM printer with a 32-bit mainboard (an STM32-class chip) running Marlin 2.1, with TMC2209 stepper driversMarlin's thermal protection runs on the mainboard. If yours runs Klipper, read Section 8 first.
ThermistorsThe 100 kΩ NTC thermistors fitted to the hotend and bedRead by the firmware. HEART sees only the firmware's temperature reports.
Run-out sensorA switch on the filament path, wired to the mainboardThe firmware pauses the job
HostA Raspberry Pi 4 (2 GB) running OctoPrint (the OctoPi image)The HEART runs here, as a separate service
LinkA USB cable from the mainboard to the Pi/dev/ttyUSB0 or /dev/ttyACM0, opened by OctoPrint only
Driver temperatureA DS18B20 1-Wire probe glued to the stepper drivers' heatsink, wired to the PiThe firmware does not know this value; HEART reads it on the Pi
Status lightA red LED on the PiBlinks a fault at the printer, with no internet
BuzzerAn active 5 V buzzer through an NPN transistor on the PiBeeps a fault at the printer
NetworkThe Pi's Wi-FiReports to HEART Hub, outbound only

Example wiring on the Pi#

SignalPi pinNotes
DS18B20 dataGPIO4 (pin 7), with a 4.7 kΩ pull-up to 3.3 Vdtoverlay=w1-gpio in config.txt (/boot/firmware/config.txt on recent images, /boot/config.txt on older ones). GPIO4 is its default pin.
Status LEDGPIO17 (pin 11), through a 330 Ω resistorDriven by HEART's escalation only
BuzzerGPIO27 (pin 13), to the transistor's base through 1 kΩDriven by HEART's escalation only
MainboardUSBNot 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.pyWhat you do
The four CHANGE lines at the top3.1
MONITORLeave False in the file. The service turns reporting on with HEART_MONITOR_URL (Section 4).
MONITOR_APPROVALS, LIFECYCLE, BRAINLeave off. This HEART changes nothing.
check_main_function()Not used: the rule table of 3.3 replaces tend()
escalate()3.4
New: the reader3.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 in dialout, and writes only to /var/lib/heart-printer. That is where heart-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/gpiochip0 is 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:

  1. Flash the mainboard with the release firmware, as now, and run the firmware's own thermal-protection tests. HEART adds nothing to this step.
  2. 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.
  3. 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 of https://heartcortex.ai/register?id=<ID>. For a product, replace the log line with your label printer (the CHANGE at monitor_identity()).
  4. Make its own OctoPrint key for the read-only user. Never ship one key in every image.
  5. Run the bench tests of Section 7 on a sample from each batch.

6. What the owner sees#

  1. Setup. The maker's setup joins the Pi to the Wi-Fi, as before.
  2. 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.
  3. Every day. Nothing. Jobs are started and watched in OctoPrint, as before.
  4. When something goes wrong:
Real eventThe HEART noticesThe owner sees
The hotend thermistor slips out of the block; the firmware cuts the heaters and haltssystem.main-function-lost (critical), from the firmware's halt lineThe 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 parkssystem.main-function-lost (error)Degraded: "Load a new spool"
A heater cartridge weakenssystem.main-function-degraded after 2 minutes of slow heatingA warning
The bed reading freezes at its targetsensors.stuck after 30 s with no changeAn error. The firmware cannot see this one
The thermistor connector openssensors.out-of-rangeAn error; the firmware refuses to heat
The drivers' fan stopsperformance.too-hot above 80 Cel on the heatsinkA warning, before the drivers shut down
The Wi-Fi dropsconnectivity.link-lost, kept on the PiAfter 90 seconds Not heard from, and one email. The alert shows when the link is back. Printing goes on
The printer is switched offNothing can be sentNot 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.

  1. 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.

FaultHow to cause it on the benchExpect
Thermal-runaway haltPower off. Slide the hotend thermistor half out of the block. Power on and heat the hotend, standing by with the power switchThe firmware halts and cuts the heaters (check with an IR thermometer); then system.main-function-lost, critical
Thermistor openPower off, unplug the hotend thermistor, power onsensors.out-of-range, and system.main-function-lost if the firmware halts
Run-outCut the filament above the sensor during a printsystem.main-function-lost, error
Slow heat-upRemove the silicone sock and blow the part fan at the block during heat-upsystem.main-function-degraded, if heating takes over 2 minutes
Driver heatsink hotWarm only the DS18B20 with a hot-air tool on a low setting, past 80 Celperformance.too-hot
Frozen readingNo safe way to freeze a real thermistor. Test this rule with a recorded feed in place of OctoPrintsensors.stuck
Wi-Fi lostSwitch off the access point for 2 minutesNot heard from on the page, then connectivity.link-lost and Healthy again
HEART stopped mid-printsystemctl stop heart-printer during a printThe print goes on, OctoPrint is unaffected, and the page shows Not heard from
No changescurl -X POST http://127.0.0.1:43278/heart/v1/capabilities/x/invoke501 not-implemented (the template's answer; the simulator answers 403 capability-not-allowed). Nothing changes
No serial portsudo -u heart cat /dev/ttyACM0Permission denied
PrivacyRead 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 mayHEART 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 nothingWrite a heater target, a fan speed or a motion command
Read its own probe on the drivers' heatsink, and the Pi's Wi-Fi stateTurn off, delay or reset thermal-runaway, heating-watch or MAXTEMP protection
Blink its own LED and beep its own buzzer, on the PiPause, resume, cancel or restart a job
Report status and fault codes to HEART HubRestart 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. heartd has 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.