Skip to content

Detailed project reference ​

Documentation home · Project overview

This page preserves the detailed project reference. Start with the setup guides for installation; features and defaults vary by printer and configuration.

CI

Contributing · User documentation

Print Handy is an automated 3D printing pipeline driven from a Discord channel, for Sovol and other Klipper printers, and Flashforge printers running ZMod. Post a MakerWorld, Printables or Thingiverse link, or a 3MF or STL file, and the bot downloads the model, merges multi plate parts, matches colors to loaded spools in Spoolman, and slices headless with OrcaSlicer. Once someone confirms the bed is clear, it sends the job to the printer through Moonraker, watches it through the camera, and posts the result.

About ​

Print Handy is almost entirely Python. Downloading, plate merging, color matching, slicing, job start, printer monitoring, spool sync, timelapses, and print records are all plain scripts with fixed rules, so they are fast and make no LLM calls.

The Discord side runs one of two ways. As a standalone bot (python3 -m print_handy.bot), Print Handy talks to Discord itself with a bot token: links, files, buttons, slash commands, slicing, print start and printer alerts need no Hermes and no LLM; an OpenAI-compatible model can optionally word replies to free text. See Standalone bot. Or the conversation runs on Hermes Agent using Luna at medium reasoning, with the same handlers loaded as a Hermes plugin. Two optional extras also call a model: the camera watchdog asks a vision model to classify a print frame, and the "print started" post adds a one line roast. Leave services.vision_chat_url empty and neither runs. The roast is its own switch, vision.roast_enabled, on by default whenever an LLM/vision endpoint is available, and it applies on every platform — the Telegram and Matrix bots are pure bots for chat (no [llm] fallback), but their print-started posts carry the roast just like Discord's (completion posts never do). Set vision.roast_enabled to false to turn roasts off while keeping camera checks.

Supported printers:

  • Sovol on stock Klipper, where Print Handy started, plus any other Klipper printer with Moonraker. Single extruder.
  • Flashforge (running ZMod), including IFS multi color slots and the ZMod plugin below.

Pick one with [printer] model (see Settings). The offline core in print_handy/core/ runs with no printer.

How it works ​

In the #3d-printer channel, someone posts a link. The bot replies with a job summary and a button:

Mrs catduck: https://makerworld.com/en/models/931302-holder-for-post-it-notes

Print Handy: Model: Post it Note Holder with Stencils. Filament: Matte Black PLA (~48g). Estimated print time: 1 hour 14 minutes. Target printer: Flashforge (ZMod).

[Slice and print now (bed is clear)]

Print Handy: Bed heating started. Job sent to Moonraker. Finish time estimated for 4:22 PM ET. Don't touch the build plate.

The print starts only when someone clicks the button. On completion, the bot posts stats and writes a JSON print record for the job.

A bed-clear approval starts one print. Starting the same G-code again needs a new confirmation (print-now --reprint). Only one start runs per printer at a time. If Print Handy cannot tell whether a start reached the printer (it stopped during the request, or the printer never confirmed it), it refuses later starts on that printer until the printer reports that exact file. An operator can also check the printer and run python3 -m print_handy.core.model_job reconcile-launch --started or --not-started; without a flag, the command only shows the pending start. Print Handy never resends that start on its own. This launch state is kept in <hermes_home>/state/print_handy_launches.sqlite3.

Features ​

Intake and slicing

  • MakerWorld, Printables and Thingiverse links, or uploaded 3MF and STL files. MakerWorld links need an account token; Thingiverse links need an App Token; Printables links and uploads do not. MyMiniFactory links need an API key and are inventory-only: the bot lists the model's files and then stops with the download gap named — it cannot download from MyMiniFactory yet.
  • Print time and grams from the model page in the first reply.
  • Multi plate models merged onto one bed when they fit; plates that would run longer than layout.max_merged_hours stay separate. Merging first tries the established rectangle packing; when that fails and at least one part is genuinely non-rectangular, the merge planner retries with the parts' actual convex hulls (translation only, at their authored orientation, with the same spacing measured hull-to-hull instead of rectangle-to-rectangle), so multi-plate sources that split only because of rectangular over-estimation — two diagonal or triangular parts, for example — can still merge onto one plate. Under that hull fallback rectangle-only sources keep exactly their previous merge verdicts and placements, and a hull placement that cannot be found within a bounded search falls back to the previous split outcome. When rectangle and hull packing both fail, the merge planner makes one more attempt with per-object yaw: a part that fits the usable bed only when turned in the bed plane is packed by its hull rotated to the same recorded yaw the full-size split evaluation chooses for such a part (recorded as yaw_by_object in the merge plan), while parts that fit as authored keep their authored orientation, so a source that split only because one part needed a turn can now merge. Slicing realizes each recorded turn through the exporters' per-object fit channels, composed with the plan's recorded shifts (a turned part's shift is measured from its rotated bounds), and proves the merged output landed at each turned part's rotated bounds plus its shift before accepting it; a merge plan that records a turn for an unknown object, a non-finite or out-of-range angle, or a turn without the shifts that realize it is refused rather than sliced as authored. A part that fits the bed at no checked yaw still blocks the merge, eligibility matches the hull fallback (single-build-item parts only), and every merge verdict that succeeds without a turn is unchanged.
  • Oversized models split across plates, tracked as one plan.
  • Full-size fit is evaluated per part before any shrink is offered: each authored object is checked at 100% against the usable bed and the printer's height limit, including dimension-preserving in-plane yaw. The footprint judged at each yaw is the part's actual mesh, not its bounding rectangle: the envelope is computed from the rotated mesh vertices (the true in-plane mesh extrema), so a non-rectangular part is credited with the size it really occupies when turned; for a rectangular part the result is identical to the rectangle check. When the selected printer model declares a printable-area exclusion (for example the Bambu Lab P1S front-left exclusion), a part counts as fitting only when a placement of its footprint exists inside the usable area that avoids the exclusion, and that placement is reported; in a full-size split plan such a part gets its own plate with the exact placement recorded (placement_by_object), intake names the placed part and its position, and slicing lands that plate at the recorded placement — a plan that records a placement for a shared plate, an unknown object or an out-of-range position is refused rather than sliced centered into the exclusion. A part that fits no plate on its own is named as blocking, with the measured limit it failed; separate plates and smaller inter-object spacing are never presented as a cure for such a part, and supports are discussed only as overhang help, never as a size fix. A part that fits only when turned in the bed plane has the exact turn recorded in the plan (yaw_by_object); intake names the turned part and its angle. It is offered its own plate, unless a shared placement proved over the hulls rotated to their recorded turns lets it share a plate instead — the shared placement is tried first, because it fits more parts per plate, and the solo turned plate remains the fallback. Slicing applies a solo recorded turn to that plate alone; on a shared plate it applies each member's turn per object, composed with the plate's recorded shifts, and re-reads the derived plate to prove every turned member landed at its rotated bounds plus its shift. A plan that records a turn for a shared plate without the shifts that realize it is refused rather than sliced as authored. Shared split plates are packed by bounding rectangles first; only when that cannot bin a set of parts together does the planner retry the set by their actual convex hulls (translation only, at authored orientation), recording the proved per-object translations as shifts_by_plate in the split plan. Intake names such parts as rearranged on their shared plate, and slicing applies exactly the recorded shifts — a plan whose shifts name an unknown object, do not name exactly their plate's objects, are malformed or non-finite, belong to a solo plate, or mix with a placement for the same plate is refused rather than sliced as authored, and the derived plate is re-read to prove it landed at the recorded shifts before it is sliced. A part that fits no plate as authored or at any bed-plane yaw is evaluated last for a face change: a 90° turn about X or Y that lands it flat on another authored face, composed with a bed turn of the rotated footprint — a cardinal turn (0° or 90°) when one fits, else the best diagonal turn from the same bounded 1° yaw sweep used for in-plane turns. When such a candidate is proved — flat landing per build item, rotated height within the limit, footprint inside the usable area including the once-only brim budget — a full-size split plan records the full rotation, its new up-axis, and the supports/brim measurement taken with the same surface heuristics as the single-plate rotation offers in reorientation_by_object; intake names the reoriented part, its face change and whether it needs supports (choosing the split is the approval for those supports). The part is offered its own plate, unless a shared placement proved over the members' footprints in their recorded orientations (authored, turned or reoriented hulls) lets it share a plate instead — the shared placement is tried first, after the authored packing and the shared-turn attempts, because it fits more parts per plate, and the solo reoriented plate remains the fallback. On a bed with a declared exclusion a candidate is proved only when a placement of the reoriented footprint also avoids the exclusion, and that placement is recorded for the part like any placed part's. Slicing applies exactly the recorded rotation: to the whole plate for a solo part (with a recorded placement for that same part composed into the same fit), or per object composed with the plate's recorded shifts on a shared plate, and re-reads the derived plate to prove every reoriented member landed at its rotated extents on the bed. The recorded supports decision reaches the slicer for that plate: on a solo plate it is the part's own decision; on a shared plate a member that needs supports turns them on for the plate, members with records govern when every member has one, and a part with no record of its own keeps the default. A plan that records a reorientation for an unknown object, mixes it with a turn for the same object, records it for a shared plate without the shifts that realize it, or carries a matrix that is not exactly the recorded face change composed with its bed turn is refused rather than sliced as authored. The recorded brim recommendation is advisory only and never changes the job's brim policy. Reorientation remains conservative: no offer when the supports measurement cannot be made, and arbitrary tilts, upside-down flips and poses that do not land flat are never offered. Among proved candidates for a part, cardinal-turn candidates rank ahead of diagonal-turn ones — a diagonal turn only ever adds a fit, it never replaces a verdict a cardinal plan already achieved — and within each class the choice is ranked by fit quality — most bed margin, then no-supports over supports, then larger measured bed contact — so the recorded plan is always the best proved fit, never merely the first candidate found. On a bed with a declared exclusion, a part that would otherwise get a solo recorded placement may instead share such a hull-packed plate when the proved shared placement also avoids the exclusion; the shared placement then supersedes the solo one (a plate never carries both), and without a proved shared placement the solo placement behaviour is unchanged. Two clearances are kept distinct and each is applied once: layout.merge_edge_margin_mm is bed-edge clearance (usable area = bed − 2 × margin per axis) and layout.merge_spacing_mm is the gap between objects sharing a plate, which changes neither a part's footprint nor the printable area. When the job carries a brim policy, the fit offer also accounts for the brim: the brim/feature budget (orca_adapter.required_feature_edge — the explicit brim width plus the resolved travel sweep) shrinks the usable area exactly once in the per-part fit check and in full-size split planning, placements keep the brimmed footprint clear of any bed exclusion, and a part that fits only without its brim is named as blocking with the brim budget as the reason instead of being offered a split plan it cannot use; with no brim policy, fit verdicts are unchanged. The other generated features (skirt, supports, prime tower and purge structures) are slice-dependent and remain accounted for by the slicer capacity checks and the G-code out-of-bounds guard, so a geometry fit plan is an offer to slice, not a print-ready assurance.
  • Copy counts offered only where the G-code will actually contain the copies.
  • Each slice records a versioned recipe with its G-code: source file, printer model, orientation and scale, copies, supports and brim, split assignments, filament and nozzle, Orca profile files and slicer command. A slice made for one recipe is never reused for another.
  • Headless OrcaSlicer with versioned machine, process, and filament templates; Bambu Studio as a fallback slicer with its network cut.
  • Readable part names: generic CAD names like "Cylinder" become a shape, size, plate, and color description.

Filament and spools

  • Colors matched to loaded IFS slots from Spoolman. PLA+ is accepted in a PLA slot.
  • Filament estimates split across parts that share a spool, never multiplied.
  • /spool lists, changes, creates, and syncs spools without moving the printer. Bare or incomplete forms open a guided button flow (#475): known Spoolman filament types are offered first (their colour/hex come from the database; hex is never asked), the very first question already previews the spool number the new spool will be (#512 — write it on the spool before loading), and changing a slot asks the old spool's fate (shelf / archive / depleted / delete, with Archive as depleted recommended when the old spool has 0 g left). The brand question is skipped unless spool.prompt_brand is on. Anchored plain phrases ("add new filament", "swap the filament", …) start the same flow with no LLM involved; the complete typed forms stay one-shot.
  • The Discord insertion prompt (a spool changed on the printer's own screen) and the paused spool question answer "New spool" with the same conversational walkthrough (#512) instead of the old five-field text modal: material quick-select (PLA, Silk PLA, PLA+, PETG, TPU, ABS, Other), colour/finish picker (known colours for the material first), starting-weight buttons (1000 / 500 / 250 g or custom), the replaced spool's fate when the slot is occupied, and one confirmation — nothing is created before it. When any label-reading route is available (#514), the first step also offers Use a photo of the label: the operator sends one photo of the spool's box or sticker as the next message, and the label is read by the first route that can — its own barcode/QR code (zxing-cpp, local), the shared vision call (when an endpoint is configured), or local OCR (tesseract, matched against the known filament vocabulary) — extracting brand, material/sub-type, colour/blend, net weight and any printed label temperatures. The values pre-fill the walkthrough at the same confirmation, which names the route that read them (Edit details walks the normal steps; an unusable photo refuses with the reason and falls back to them). Label temperatures are recorded on the Spoolman filament, so the usual precedence holds for every material, and the photo itself is never stored.
  • /print-colors <JOB.json> opens the color selection flow for an archived job. Preparation only: it slices nothing, uploads nothing and starts nothing, and the path must be an archived JOB.json.
  • A swapped spool can finish a job sliced for the old one, by explicit choice.
  • PRINT_HANDY Klipper macro confirms ZMod can fall back to a matching backup slot.

Printing

  • Nothing starts without a bed clear button press.
  • Multi plate plans: /print-next offers the exact next plate, and finishing a plate advances the plan.
  • /print-again reprints an archived job exactly, with no re-slice. A job sliced before slice recipes were recorded is refused until it is re-sliced once.
  • /print-stop and /print-resume each send at most one Moonraker command behind a confirm button.
  • /print-reboot uses the safe ZMod reboot flow.
  • Printer models: flashforge.ad5x, flashforge.ad5m, flashforge.ad5m_pro, flashforge.creator5, flashforge.creator5_pro, sovol.sv06, klipper.generic, octoprint.marlin, bambulab.a1, bambulab.p1s (offline only, see Bambu Lab printers). Each job records the model it was sliced for and refuses another.

Monitoring

  • Event driven Moonraker listener: start, pause, error, and completion posts with stats.
  • Camera watchdog checks layer 2, layer 5, and periodic frames for empty bed, spaghetti, a detached part, or a stalled print. Stall sensitivity is adjustable from 1 to 5.
  • Stop and pause alerts carry Resume and Cancel buttons.
  • Klipper report posted when a print is cancelled.
  • Timelapse per print, or one combined video for a multi plate plan, sized to fit Discord.
  • Print event markers (#317): the listener records timestamped markers — start, camera-watchdog alert, pause, resume, cancel, error, and completion with its outcome (success / failed / cancelled) — to <state_dir>/print_markers/<job>.json, one file per print keyed by G-code filename, written atomically like the plan timelapse stores. Each marker carries the wall-clock time and, when known, the print-elapsed offset; print_handy/hermes/print_markers.py also provides the duration-proportional mapping from an elapsed offset into a rendered timelapse's time, which the future blooper-reel builder will use. Recording is fail-safe — a storage error is logged and skipped, never breaking the listener or a post. Developers adding a new listener domain (#299) should feed its normalised events into print_markers.record() so every domain's prints get the same record.
  • Optional vertical short (#317): with timelapse.vertical_short on, the completion post also carries a 9:16 (720x1280) version of the timelapse from print_handy/hermes/vertical_short.py — blurred-background pad framing by default or a centre crop, an optional burned-in overlay (print title, printer, filament, elapsed time, outcome), and a one-second hold on the final frame. It is an extra attachment, never a replacement: it is clamped to the configured platform's upload limit, and any failure to build it is logged and skipped so the completion post always goes out with the timelapse itself.

Live camera streaming (#311) starts with a delivery-agnostic core in print_handy/live_stream.py: it resolves one normalised camera source from Moonraker's webcams API — the same Moonraker path whether the household runs Fluidd, Mainsail or neither, since both are frontends over Moonraker's webcam registry — with a stream URL derived from the existing snapshot URL as fallback, from OctoPrint's webcam settings, or as a snapshot-only source, and it plans a codec-aware ffmpeg encode (H.264: VAAPI, NVENC, QSV, then CPU libx264; VP8: VAAPI, then CPU libvpx; NVENC never serves VP8) from an injected hardware probe. Stage 3 adds the Discord surface: the [live_stream] settings, the /print-live command and its viewing-session state machine (same module), and the Activity core in print_handy/live_activity.py — because Discord's bot API cannot send video, the live view is an Embedded App (Activity) in the voice channel whose page plays the go2rtc MSE-over-WebSocket feed behind a server-side OAuth allowlist check. Stage 4 adds the deployment wiring: the supervised Activity server (python3 -m print_handy.live_activity, gated by print_handy.live_gate and mirrored in the container healthcheck), the Activity server's own authenticated proxy of the feed to go2rtc on loopback (one public origin, one Portal URL mapping), go2rtc starting for live viewing as well as timelapse capture under the same one-upstream rule, the auto-start-on-print trigger and empty-timeout enforcement over the shared session record, the Web UI catalog's Live camera group, and the Compose/Unraid surfaces (live settings, the two live secrets, the Discord application ID, and the profile-gated cloudflared sidecar for the named tunnel). The operator guide is Discord bot setup §7. The Stage 0 live Discord spike has not run, so the feature is offline-verified only — nothing about it is claimed live-verified.

Records and upkeep

  • Every finished job written as a JSON print record and to the job archive.
  • Nightly catch up against Moonraker job history so nothing is missed.
  • /print-maintenance guided checklist and reminders by print hours and days.
  • /print-coldpull runs an operator confirmed cold pull at configured temperatures.
  • /nozzle change (#513) walks a physical nozzle change: pick the diameter (0.2/0.4/0.6/0.8 mm) and material (brass, hardened steel, stainless steel, tungsten carbide, diamond), confirm heating in person, and — on Moonraker/Klipper printers only — it heats to 250 °C for the hot loosen, waits for the Old Nozzle Removed & New Nozzle Threaded and hot-torque confirmations, turns the heater off and waits for a live readback below 50 °C. Other connections get manual-heat instructions and the same step confirmations; nothing is sent to them. Completing the walk records the new nozzle in printer_hardware.json with the previous calibration marked historical only, then offers Z-offset (--sync-z), bed mesh leveling and a pressure advance / flow check. Cancelling after heating turns the heater off and records nothing.
  • /print-lookup searches the root knowledge, firmware and printer registries for commands, macros, errors, symptoms and printer facts. Read-only; it sends nothing.
  • /print-macros lists the macros the configured Moonraker printer actually defines, read live from /printer/objects/list and classified against the registry (see printer macros). The listing is read-only. /print-macros run <name> [KEY=VALUE ...] runs one live-discovered macro behind a confirmation that shows the exact script line, refusing internal (_-prefixed) names, unknown names, and any run while a print is running or paused. Non-Moonraker printers get a plain explanation.
  • /print-live [start | stop | status] runs the live camera viewing session (Discord only; see live camera Activity). Start posts the Activity pointer for the one configured voice channel; the bot sends no video itself and records nothing. Telegram and Matrix have no live command.
  • /print-troubleshoot walks a guided physical troubleshooting flow (first layer, clog, heat creep, bed level, ringing) one question at a time. Advice only; it never commands the printer.
  • /print-help answers with what Print Handy can do and the commands it registers. Read-only.

Settings ​

config/home.yaml holds the shared defaults and picks the printer. Printer-specific defaults (bed, height, nozzle, slots, margins, prime tower and macros) come from the selected model and can be overridden in YAML. Put machine-specific overrides in the gitignored config/local.yaml; YAML is preferred for new configuration. Existing config/home.toml and config/local.toml remain supported during migration. When both formats are present, YAML wins; TOML is used when the corresponding YAML file is absent. PRINT_HANDY_CONFIG=/path/to/settings.yaml selects one YAML file, and .toml paths remain supported. Environment variables (PRINT_HANDY__SECTION__KEY and the existing legacy names) override file settings.

Settings merge in this order: printer-model defaults, home configuration, the selected local or explicit configuration, then environment. A typo or invalid value stops startup and identifies the setting. Python 3.11 or newer is required. Changes require restarting the relevant process; settings are loaded once at process startup. See YAML configuration and TOML migration for schema, precedence, safety limits and migration guidance.

yaml
# config/local.yaml example
services:
  moonraker_url: "http://192.168.x.x:7125"
  spoolman_url: "http://192.168.x.x:7912"
visual_watch:
  enabled: false
  sensitivity: 2

Printer models ​

One package per brand under print_handy/printers/, one file per printer inside it: flashforge/ad5x.py, sovol/sv06.py, klipper/generic.py. A model file holds the printer's defaults, its adapter and the name of its Orca template folder (print_handy/templates/orca/<folder>). Its user-adjustable keys are printer.name, printer.firmware, printer.start_macro, printer.end_macro, printer.bed_mm, printer.max_print_height_mm, printer.nozzle_diameter_mm, printer.ifs_slots, layout.single_copy_edge_mm, layout.multi_copy_edge_mm, layout.merge_edge_margin_mm, layout.merge_spacing_mm, prime_tower.size_mm, prime_tower.part_clearance_mm, prime_tower.edge_mm, prime_tower.merge_slack_mm, cold_pull profile names and values, and orca.templates. config/home.yaml picks one with printer.model: flashforge.ad5x; settings merge in this order, later wins: model defaults, home configuration, selected local or explicit configuration, environment.

Adding a printer is one new file in its brand package plus its templates; nothing in core/ changes. The AD5X and ZMod code lives in print_handy/printers/flashforge/zmod/, and base.py lists what core still shares with it.

Upgrading from [printer] family: replace it in config/local.toml with model = "flashforge.ad5x" (or sovol.sv06, klipper.generic). A leftover family stops the import and names the key.

Camera stall sensitivity ​

visual_watch.enabled independently enables camera monitoring; it defaults to true, preserving the historical behavior for existing TOML users. visual_watch.sensitivity sets how readily the camera watchdog posts a "stalled layers" alert, from 1 (quietest) to 5 (most alerts). Default sensitivity is 3.

LevelFrame counts as unchanged if diff belowStall alert afterPeriodic camera checkVision "ok" cancels a stall
11.58 layers30 minyes
22.05 layers20 minyes
33.03 layers15 minno
44.03 layers10 minno
55.02 layers7.5 minno

Empty bed, spaghetti, detached part, uncertain, and vision failure alerts fire at every level.

MakerWorld account token ​

MakerWorld links need a MakerWorld (Bambu Lab) account token. It is a file, not a setting or environment variable: Print Handy reads ~/.config/bambu/token in the home directory of the account that runs it (in Docker, /data/.config/bambu/token; see Docker) and sends it as an Authorization: Bearer header on the MakerWorld model lookup and on the file download. Printables links and uploaded files need no token.

To get the token, sign in at makerworld.com in a browser and copy the value of the site cookie named token from the browser's developer tools. Store it as the only content of the file, readable only by that account, and never commit or share it:

bash
mkdir -p ~/.config/bambu && chmod 700 ~/.config/bambu
(umask 077 && cat > ~/.config/bambu/token)   # paste the cookie value, press Enter, then Ctrl-D
chmod 600 ~/.config/bambu/token

Without the file, MakerWorld intake stops before the model summary with a message naming the file; an empty file stops it too. Print Handy does not check the token when it reads it. Tokens expire: when MakerWorld downloads start failing, sign in again and replace the file. Observed by the reporter on 25 Oct 2026 (#262): with an invalid token, the MakerWorld model lookup still answered and the summary was posted, and the download was then refused with HTTP 403 ("Please log in to download models."). A wrong or expired token can therefore show up only at download time, after the model summary.

Thingiverse App Token ​

Thingiverse links use Thingiverse's official REST API (api.thingiverse.com) and need a free Thingiverse App Token. It is a file, not a setting or environment variable: Print Handy reads ~/.config/thingiverse/token in the home directory of the account that runs it (in Docker, /data/.config/thingiverse/token; see Docker — the same bind-mounted dot-config directory holds both token files) and sends it as an Authorization: Bearer header when it looks up a thing, lists its files and downloads the chosen file. The token is read on each request and is never logged.

To get the token, sign in at thingiverse.com and create an app/token at https://www.thingiverse.com/developers under your Thingiverse account. Store the App Token as the only content of the file, readable only by that account, and never commit or share it:

bash
mkdir -p ~/.config/thingiverse && chmod 700 ~/.config/thingiverse
(umask 077 && cat > ~/.config/thingiverse/token)   # paste the App Token, press Enter, then Ctrl-D
chmod 600 ~/.config/thingiverse/token

Without the file, Thingiverse intake stops before the model summary with a message naming the file and where to create the token; an empty file stops it too. Only .stl and .3mf files from a thing are selectable sources — a "download all" zip and other file types are not — and, as with the other sites, the chosen geometry is resliced locally; baked site G-code is never used. If Thingiverse answers with a 401/403 or a Cloudflare robot check (challenge), intake reports that plainly and stops; Print Handy never tries to solve or bypass a robot check. Open thingiverse.com in a browser and pass any check there, or check/replace the token, then send the link again.

MyMiniFactory API key ​

MyMiniFactory links use MyMiniFactory's official API v2 (www.myminifactory.com/api/v2) and need an API key from the operator's MyMiniFactory account. It is a file, not a setting or environment variable: Print Handy reads ~/.config/myminifactory/token in the home directory of the account that runs it (in Docker, /data/.config/myminifactory/token; see Docker — the same bind-mounted dot-config directory holds all the token files) and sends it as the key query parameter when it looks up an object. The key is read on each request and is never logged.

bash
mkdir -p ~/.config/myminifactory && chmod 700 ~/.config/myminifactory
(umask 077 && cat > ~/.config/myminifactory/token)   # paste the API key, press Enter, then Ctrl-D
chmod 600 ~/.config/myminifactory/token

MyMiniFactory support is inventory-only. With the key, pasting a MyMiniFactory object link lists the model's .stl/.3mf files — and then intake stops. Print Handy cannot download files from MyMiniFactory: under MyMiniFactory's own API guidelines, downloading an object inside an app requires the person downloading to be logged in to their MyMiniFactory account (OAuth), and that login route is not built yet. The refusal names this gap, lists the files the inventory found, and states that nothing was downloaded or sliced. The workaround is to open the model page in a browser, download the file there, and upload or drop the file in the chat instead. Without the key file, MyMiniFactory intake stops with a message naming the file; an empty file stops it too. If MyMiniFactory answers with a 401/403, intake reports that plainly (key missing, invalid or expired, or the site challenging the connection) and stops; Print Handy never tries to solve or bypass a robot check.

All settings ​

SectionKeyDefaultWhat it does
printername, firmwarefrom the modelShown in job summaries
nozzle_typefrom the model or unsetOptional nozzle material; use a type supported by the selected printer model
modelflashforge.ad5xPrinter model: flashforge.ad5x, flashforge.ad5m, flashforge.ad5m_pro, flashforge.creator5, flashforge.creator5_pro, sovol.sv06, klipper.generic, octoprint.marlin, bambulab.a1, bambulab.p1s. Sets every default below
start_macro, end_macroPRINT_START, PRINT_ENDStart and end macros for stock Klipper models
bed_mm[220, 220]Usable print area
max_print_height_mm220Tallest allowed print
nozzle_diameter_mm0.4Slicing refuses any other nozzle
ifs_slots4Filament slots; 1 for a single extruder
tool_count1Independent toolheads on a tool-changer model (Creator 5 / 5 Pro: 4); slicer tool i is toolhead i, never an IFS slot
layoutsingle_copy_edge_mm, multi_copy_edge_mm1, 3Distance from the bed edge
merge_edge_margin_mm, merge_spacing_mm8, 10Gaps when merging plates
max_merged_hours12Longer merged plates stay separate
prime_towersize_mm, part_clearance_mm, edge_mm, merge_slack_mm45, 8, 10, 5Prime tower size and clearances
orcatemplatesthe model's folder under print_handy/templates/orcaOrca machine, process, and filament JSON
commandFlathub OrcaSlicerHow to start the Orca CLI
bambucommandFlathub Bambu Studio, no networkFallback slicer
pathshermes_home~/.hermes/profiles/3dprintingHermes profile the bot runs in
archive, records_vault, cache~/3d-prints, ~/Documents/3D Printing Records, ~/.hermes/cacheJob archive, print records, cache
hermes_bin, hermes_agent, hermes_restart_helper, ffmpegTool locations
servicesmoonraker_url, moonraker_ws_url, camera_snapshot_urlemptyPrinter and camera; websocket is derived when empty
spoolman_url, openviking_urlemptySpoolman and optional memory store
vision_chat_urlemptyOpenAI compatible endpoint for vision checks and roasts; empty disables both
stock_api_port8898Flashforge stock API for IFS presence
discordchat_id, approval_user_idemptyChannel and approver IDs
upload_max_bytes9500000Largest upload before video is shrunk
post_print_quiet_s7200Retired (#524): the bot no longer resets chat sessions; kept so existing configs load
daily_reset_hour, timezone4, America/New_Yorkdaily_reset_hour retired with it (#524); timezone is the display zone
bot_namePrint HandyName the bot uses in the roast persona and the rendered profile templates (SOUL.md, locale strings). Set it to anything, for example your old name
question_timeout_s1800How long a Discord question waits for a button press
safety_confirm_timeout_s600How long a safety confirmation (stop, resume, reboot, cold-pull go-ahead) waits
upload_wait_s3600How long an upload-wait freeze stays active
cold_pull_outcome_s1800How long a cold-pull outcome prompt stays open
token_envDISCORD_BOT_TOKENStandalone bot: environment variable holding the bot token
allowed_user_idsemptyStandalone bot: optional whitelist of Discord user IDs, comma or space separated; empty lets everyone in the print channel use the bot
denied_user_idsemptyStandalone bot: optional deny list of Discord user IDs; a denied user is refused even when whitelisted or the approver
botmodehermesstandalone runs python3 -m print_handy.bot with no Hermes; see Standalone bot
health_port, health_stale_s0, 120Standalone bot: GET /healthz port (0 off) and heartbeat age limit
sync_commandstrueStandalone bot: register slash commands at startup
llmchat_url, model, api_key_env, timeout_sempty, empty, LLM_API_KEY, 20Optional OpenAI-compatible model for standalone free-text replies; never starts anything itself
designenabled, chat_url, model, api_key_env, timeout_s, repair_attemptsunset (auto), empty, empty, empty, 180, 3/print-design (alias /design) AI design (#388); enabled unset = auto-on when an endpoint is configured, false forces off; empty fields inherit llm; the endpoint wins in Hermes mode too, and the profile's auxiliary.print_design block is the fallback when no endpoint is configured
listenertick_s600Listener housekeeping interval
ifs_presence_poll_s15IFS spool presence poll
spool_nag_s600Reminder interval for an unanswered spool question
start_claim_ttl_s1800How long a start button stays claimed
visionmodel, roast_modelgemini-latest, luna-latestModels for camera checks and roasts
roast_enabledtrueThe print-post roast switch: on by default whenever an LLM/vision endpoint is available, on every platform including Telegram; false turns roasts off, camera checks unaffected
visual_watchenabled, sensitivitytrue, 3Camera monitoring switch; sensitivity from 1 to 5
timelapsearchive_host, archive_dir, ssh_keyemptyRemote timelapse copy; empty host disables it
render_wait_s, render_poll_s900, 10Wait for the printer to render the timelapse
max_bytes, local_retention_days, plan_clip_retention_days200 MiB, 1, 14Size cap and retention
vertical_short, vertical_short_mode, vertical_short_overlayfalse, pad, trueOptional 9:16 vertical short posted alongside each timelapse (#317); mode is the blurred-pad or centre-crop framing, overlay burns in the title/printer/filament/time text
logginglevel, format, fileINFO, text, emptyLog verbosity (DEBUG, INFO, WARNING, ERROR), text or json lines, and an optional rotating log file
maintenancequick_hours, full_hours, full_days25, 75, 30When maintenance reminders come due
nozzle_temp_offsetssupported nozzle typesteel types 5.0, others 0.0Temperature compensation from −10 to +15 °C for a known nozzle type; the per-type default applies generically to every filament baseline, and a value here overrides that type's default
material_tempsPLA, PETG, ABS, ASA, TPU, PA, PC (nozzle, bed)see config/home.yamlConfigurable fallback temperatures in °C; explicit Spoolman filament settings take precedence. Named variants adjust the fallback (#512): Silk PLA +10 °C and 0.7× outer-wall speed, PLA+ / Matte PLA / *-CF / *-GF +5 °C (PA-CF +10 °C), Matte PLA 0.9× outer-wall speed
spoolprompt_brandfalseAsk for brand/vendor in the new-spool walkthrough (#512); off by default, brand is skipped
cold_pullprofile namepla_trial = [220, 90], petg = [245, 100]Hot and cold temperatures in °C; profile names are configurable

Install and update ​

See docs/INSTALL.md to install into a Hermes profile, or docs/STANDALONE.md and scripts/install-standalone.sh to run the standalone bot with no Hermes.

Update only from a commit that is green in CI. On the machine running Print Handy:

bash
scripts/home-update.sh          # fast-forward to origin/main
scripts/home-update.sh <tag>    # or a release tag

The script refuses to run if tracked files were edited by hand. It runs the offline tests and the doctor after the update, and resets to the previous commit if either fails. config/local.yaml and legacy config/local.toml are never touched. A listener restart command can go in config/after-update.sh (gitignored), which runs after a good update.

Check the setup any time with python3 -m print_handy.doctor. It also checks Moonraker and Spoolman when they are set in [services], and warns about Print Handy state files stranded outside the profile state folder. The update and install scripts remove those strays when the profile holds the real copy (or the stray is empty); files in other profiles are only reported; python3 -m print_handy.state_files [--dry-run] [--remove] does the same by hand.

Run with Docker ​

Prerequisites: Docker Engine with the Docker Compose plugin, and Git. From the repository root, copy .env.example to .env and set PRINT_HANDY__SERVICES__MOONRAKER_URL to your printer's Moonraker URL. Set other scalar settings with PRINT_HANDY__SECTION__KEY variables in .env; config/docker.yaml supplies Docker defaults. Lists such as orca.command belong in a config file, not environment variables. To replace the full config, mount YAML and set PRINT_HANDY_CONFIG to its container path. Existing TOML mounts remain compatible by mounting at /app/config/docker.toml and setting PRINT_HANDY_CONFIG=/app/config/docker.toml in .env.

Compose stores persistent data in ./data/hermes, ./data/archive, ./data/records, and ./data/cache; the included Spoolman uses ./data/spoolman. Create these directories before starting. Spoolman is included in the Compose stack and published on no host port; Print Handy has no published host port and connects to Moonraker and Spoolman over the Compose network.

bash
cp .env.example .env
mkdir -p data/hermes data/archive data/records data/cache data/spoolman
docker compose up -d
docker compose logs -f print-handy

To update a checkout, pull the desired source revision and rebuild/recreate the services with docker compose up -d --build. Stop the stack with docker compose down; the bind-mounted data directories remain. See Docker details for the full configuration notes.

Running tests ​

From the repository root, CI runs this on every push and pull request:

bash
python3 -m pip install -r requirements-test.txt
python3 -m pytest tests

A second CI job installs the Flathub OrcaSlicer, slices small test models with the templates, and runs the slice guards on the real G-code. Run it locally with PRINT_HANDY_REAL_SLICER=1 python3 -m pytest -s tests/slicer.

Discord flow corpus ​

tests/corpus/cases/ holds generated, redistributable 3MF projects and STL meshes run end to end through the Discord intake with fake button clicks. Nothing contacts a printer. Each case runs twice: through the Hermes plugin hook, and through the standalone bot on a fake discord.py client (tests/corpus/fake_discord.py), where real buttons resolve the core prompt registry. Every PR runs the corpus with fake Orca; a separate real-slicer CI job runs the same cases with Flathub Orca.

CoverageCasesBehavior asserted
Native STL intake and color selectionstl-single-partSource kind and 30 × 30 × 20 mm bounds, one G-code, support off
Fit and operator-selected shrinkmust-shrink, stl-must-shrink3MF and STL trigger fit prompt, scale range, one G-code
STL overhang and tall narrow geometrystl-needs-supports, stl-thin-footprintSource bounds, default support-off behavior, successful guarded slice
Named parts and single-plate multicolorparts-one-plateExact part names/colors, merge mode, three tool slots, one G-code
Multiplate merge and split planningplates-multicolor, split-planSource plate count, merged colors/tool slots, split mode and expected G-code counts
Supports-on geometry and baseline 3MFneeds-supports, single-partSupport-enabled profile plus support G-code, ordinary single-part intake

All ten current models are generated locally by tests/corpus/make_models.py, have CC0 provenance in their manifests, and are under 5 MB. A future hand-authored regression fixture is also allowed when its redistribution license is documented and the public size limit is met; generator checks pin the built-in fixtures without requiring every public case to be generated. The corpus contains no private or license-restricted source files. Current scenario gaps include painted multicolor meshes, partial-plate selection, non-English part-name translation, rotation-only fit, archived-job reprints, and a near-5 MB timeout case. Generated STL covers shrink-to-fit but not split planning; split planning remains covered by 3MF.

To add a model that broke Discord: make tests/corpus/cases/<name>/ with one supported model file (.3mf or .stl; model.3mf wins if a folder has more than one) and a case.toml listing the buttons pressed and what you expect. Only include models you may redistribute: your own work, CC0, CC BY, CC BY-SA, MIT, Apache (unversioned, 1.0, 1.1, or 2.0), or public domain. The license field must be an exact recognized label. Its only accepted suffix is : generated by <path>, no third party geometry, for generated fixtures. Other qualifiers, restrictions, and ambiguous labels are rejected. Record the exact provenance in license = "...". Public models must be 5 MB or smaller. An unanswered question fails the test and prints its buttons, so copy them in. tests/corpus/make_models.py writes the built-in models; see any existing case.toml for the format.

bash
python3 -m pytest -q tests/corpus                                # fake Orca
PRINT_HANDY_REAL_SLICER=1 python3 -m pytest -q -s tests/corpus   # real Orca, slices under .slicer-work/

Local private corpus ​

Personal or non-redistributable models must stay outside the checkout. Create one folder per case under a private directory, with the model and a case.toml copied from a public example. The private corpus is selected only when explicitly configured:

bash
export PRINT_HANDY_PRIVATE_CORPUS="$HOME/.local/share/print-handy/private-cases"
python3 -m pytest -q tests/corpus
unset PRINT_HANDY_PRIVATE_CORPUS

Private cases use the same offline flow and skip only the public redistribution-license check. The configured directory must exist outside the repository and contain at least one case. Private-corpus opt-in fails when CI=true; never set it in CI. The private model is not added to the checkout, public diff, or CI artifacts. Real-slicer output is temporary local scratch under the ignored .slicer-work/ directory; do not commit or upload it.

ZMod plugin and spool matching ​

This section is AD5X-only: the macro and the spool matching below describe the IFS printer. The AD5M and AD5M Pro run the same ZMod firmware family and Print Handy supports them as single-extruder models, but they have no IFS and no spool matching; the Creator 5 and Creator 5 Pro are toolhead machines whose spools bind per toolhead rather than per IFS slot, which this macro's slot model does not cover; see ZMod printers for all five ZMod models and their differences.

ZMod switches to a backup spool mid-print only when another slot holds the exact same color and material. The print-handy.cfg macro lets you check that before a job starts:

gcode
PRINT_HANDY SLOT=1

If slot 1 and slot 3 are both black PETG, it confirms:

PRINT_HANDY: black / PETG also on slot 3. ZMod can switch. This macro does not.

PRINT_HANDY COLOR=black MATERIAL=PETG checks all four slots without assuming an active toolhead.

To install, run this on the printer console:

gcode
ENABLE_PLUGIN name=print-handy

Then add your slot assignments to mod_data/user.cfg in a [gcode_macro _PRINT_HANDY_VARS] block and run FIRMWARE_RESTART. Moonraker update manager entry, in mod_data/user.moonraker.conf:

ini
[update_manager print-handy]
type: git_repo
path: /root/printer_data/config/mod_data/plugins/print-handy
origin: https://github.com/catduckgnaf/print-handy.git
primary_branch: main
managed_services: klipper
channel: dev
is_system_service: False

On Telegram, a ZMod household can also run moonraker-telegram-bot — the bot ZMod itself uses — alongside Print Handy's Telegram bot for live printer control, notifications and timelapse: see Telegram bot setup for the division of labour, the two-token rule and how to keep only one bot posting progress notifications.

Repository structure ​

  • config/home.yaml: default settings. Legacy TOML remains supported during migration. See Settings.
  • print_handy/settings.py: loads and validates settings.
  • print_handy/doctor.py: checks that a machine can run Print Handy.
  • print_handy/core/: offline 3MF and STL pipeline. No printer connection.
  • print_handy/hermes/: Moonraker listener, Discord posts, spool sync, print control, and records.
  • print_handy/printers/: one package per brand, one file per printer model; see base.py for what each one owns.
  • print_handy/templates/orca/<model folder>/: Orca machine, process, and filament JSON per printer.
  • plugins/print-feedback/: the Discord command handlers and interaction guards, loaded by the Hermes gateway or by the standalone bot.
  • print_handy/bot/: standalone bot. platform.py is the chat-platform interface (#164: send, send_clarify, answer_typed, close_prompt, is_allowed_id, per-platform limits on the adapter); adapter.py is its Discord implementation over discord.py, telegram.py/telegram_rest.py its Telegram implementation over the standard-library Bot API (long polling, inline keyboards) and matrix.py/matrix_rest.py its Matrix implementation over the standard-library Client-Server API (/sync long polling with a persisted next_batch token, media repo uploads/downloads, numbered-list prompts — Matrix has no buttons — and auto-join of the print room on invite), selected by [bot] platform (default discord). The Telegram and Matrix adapters are pure bots (uses_llm = False): BotCore never calls the LLM fallback for them. Plus the router, health check and optional LLM; systemd/ holds its units.
  • hermes/: profile templates and skills installed by scripts/install-hermes.sh.
  • scripts/: install-hermes.sh, install-standalone.sh, home-update.sh, and migrate-names.sh (one-shot, see docs/RENAME-MIGRATION.md).
  • print-handy.cfg, install.sh, uninstall.sh, update.sh: ZMod plugin macro and lifecycle scripts. The cfg file name must match the plugin name (print-handy) or ZMod refuses to enable the plugin (#289).
  • tests/: offline suite, real slicer checks, the Discord flow corpus (tests/corpus/), and the Klipper macro tests.
  • docs/: install guide and AD5X integration specs.

Spool / IFS source of truth ​

Spoolman is authoritative for spool identity, filament material/color, and location (IFS Slot N versus Shelf). printer_hardware.json caches verified slot-to-Spoolman-ID bindings; it is not authoritative when Spoolman is available. Spoolman availability is never a gate, though (#235): when Spoolman is unreachable, errors, or disagrees with the record, the hardware check, G-code verification and slicing warn and continue on the recorded spool state (material, density and temperatures in printer_hardware.json) instead of refusing work. Since #387 that is structural rather than per-callsite discipline: every Spoolman HTTP call (lookup, spool assignment, usage reporting, active-spool set, inventory, health probe) goes through the one client, print_handy/core/spoolman_client.py — short timeout, every transport or HTTP error caught and logged as a WARNING — and a contract test fails CI if a Spoolman API path is paired with a direct HTTP transport anywhere else. The print-start active-spool sync is best-effort too: when Spoolman does not answer, or the Moonraker spool-pointer write fails, the launch proceeds on the slot's recorded data, the user sees the single line "Spoolman unavailable; spool tracking skipped for this job.", and the usage that could not be reported is recorded as pending (spoolman_usage_pending in the job record at start, and in the completion record when Spoolman is down at completion) for later reconciliation; it is never retried in a way that blocks the job. A live Spoolman answer that the spool is archived or a different material than the slot record still refuses a launch: that is Spoolman data contradicting the operator's record, not an outage. The Spoolman address itself comes from services.spoolman_url in settings (including the PRINT_HANDY__SERVICES__SPOOLMAN_URL environment variable) whenever that is set; the URL cached in printer_hardware.json is only a fallback, so a stale cached address cannot redirect lookups to a decommissioned host. A single-extruder printer (one slot) with no ifs_slots record offers its configured spool as slot 1 in the Discord color questions (#263); when that spool has no Spoolman binding (spoolman_id: null) the selection carries none, whether or not a Spoolman URL is set. Multi-slot printers and the four-bay stock Flashforge still need their recorded slot map and a Spoolman ID per slot. Physical feeder slots are 1-based (1–4); G-code tools are zero-based (T0–T3), and mappings explicitly translate between them. On a multi-tool printer (Creator 5 / 5 Pro) the same recorded map is per toolhead instead — Spoolman locations are Toolhead N — and the Discord color questions follow suit (#346): one question per source color asking which toolhead prints it, toolhead labels instead of slot labels, a toolhead already assigned a different color is not offered again, and more distinct colors than toolheads is refused (one color per toolhead; printing everything in one color on one toolhead stays available). The picks slice through the model's four-extruder templates as the identity tool mapping, and summaries name Toolhead N, never a slot. G-code/job snapshots are provenance, not permission to override newer Spoolman facts. Normalize screen colors to six-digit RGB; never infer missing color from another spool or slot. Correct missing/invalid Spoolman color before binding. Screen updates are immediate when idle or persisted pending while printing; deferred/failed updates require explicit sync after printing. Moonraker active spool is usage accounting only: single-slot launch repoints it; multi-slot launch leaves it unchanged, so usage can be approximate. Selected physical slot determines identity/readiness, not the global accounting pointer.

Documentation content is copied from catduckgnaf/print-handy — that repo's docs/ directory is the source of truth.