Skip to content

feat(printer): macro passthrough and per-model alias commands - #721

Merged
alex-mextner merged 41 commits into
mainfrom
feature/printer-macros-aliases
Nov 5, 2026
Merged

alex-mextner merged 41 commits into
mainfrom
feature/printer-macros-aliases

Conversation

@alex-mextner

@alex-mextner alex-mextner commented Nov 4, 2026 •

Copy link
Copy Markdown
Owner

Refs #718

What

  • 3d printer -n NAME macro list [--offline|--dry-run] [--all] [--json] — discovers the macros a Klipper printer defines (Moonraker /printer/objects/list + /printer/gcode/help); --offline uses registry data only.
  • 3d printer -n NAME macro run MACRO [KEY=VALUE ...] [--yes] [--dry-run] [--offline] — checked passthrough of ONE macro through /printer/gcode/script; prints the exact line sent.
  • 3d printer -n NAME aliases [--json] and thin alias subcommands: home, level, qgl, z-tilt, pause, resume, preheat, cooldown, load, unload, and U1-only level-heated, clean-nozzle, park, idle-position, purifier. Table lib/data/printer-aliases.yaml layered adapter -> model -> the printer's own printer-aliases.yaml (user, then project). An alias a model lacks says so and lists what it has.

Safety (all enforced before anything is sent)

typed tokens (identifier names, one-token values: no newline, ;, quotes, braces); M112/EMERGENCY_STOP never; access: full only (read-only/offline refuse, also on --dry-run); command must be defined on the printer; --yes for macros outside trusted_macros and dangerous ones; idle printer for motion/heating (pause/resume/cancel style exempt); creality-stock has no G-code channel and refuses. No raw gcode SCRIPT (documented why in docs/commands/printer.md).

Lint gate (owner decision for #707: refactor, no ratchet)

Tests

tests/test_printer_macros.py (70) and tests/e2e/test_printer_macros_workflows.py (4) against a loopback fake Moonraker built from the real u1/k1se fleet entries; no real address is reachable.

…settings and in 3MF plate pictures (Refs #722)

The Orca CLI writes no thumbnails. Every code path that runs a slicer now asks it for 48x48/96x96/300x300 PNG (project/machine preset, PrusaSlicer flags) and draws what is missing from the toolpaths before the G-code is hashed or moved: 3d slice, orca_cli (3d prep, calibration), 3d retarget --gcode, 3d gcode-resume. A G-code without a thumbnails spec requires the three default sizes. A 3MF gets its missing Metadata/plate_N.png. tests/test_thumbnail_guard.py fails a module that runs a slicer without the layer. The legacy over-limit functions of the touched files were split into helpers (behavior-preserving) so the whole-file lint gate passes (#707). Known gap: the U1 plate generator, #724.
…ht names the fix and has --fix (Refs #722)

3d printer upload and start add the thumbnails FILE lacks in place before the snapshot is hashed (atomic, mtime kept, the slice-warnings record follows the new SHA-256, old and new hash printed) instead of sending a G-code that preflight refuses. print-preflight's thumbnails FAIL names the exact 3d gcode-thumbnails add command for the original file; --fix runs it. End-to-end tests run bin/3d against a fake slicer and a fake Moonraker server (no real printer). The legacy over-limit functions of printer.py, print_preflight.py and printer_ops/preflight.py were split into helpers (behavior-preserving) so the whole-file lint gate passes (#707).
alex-mextner and others added 2 commits November 5, 2026 00:01
…dict that survives light and registration drift

The idle U1 at Z 200 (15 % of the plate in view) read "objects on the bed 14.3 %" on an empty clean
plate: the ROI there is mostly the machine's back wall and the window behind the printer, the
window light had changed, a cable had moved and the picture sat a pixel or two off the October
reference.

- difference(): a pixel matching the reference within shift_px (3) is unchanged; changed regions are
  opened with an open_px (5) square. classify(): below low_view (0.5) of the bed in view only a region
  of occupied_blob_low_px (1200) or occupied_min says occupied, a smaller change is unknown and names
  `bed check` and the reference age. The U1 frames of parts stay 2700-3900 px regions, a 16 px part on
  the K1 SE stays occupied.
- evidence keeps the untouched camera frame beside the annotated one (.raw.jpg), pruned together.
- `3d printer bed check NAME [--expect empty|part] [--no-move] [--json]`: the idle bed is moved in Z only
  (the guarded move of bed_view) to where the camera sees the plate, a fresh frame is judged and the bed
  put back; result empty | objects | part-present | part-gone | out-of-view | unknown | busy, the changed
  fraction, annotated + raw frame, exit 1 unless a definite answer that matches --expect.
- `bed reference --capture --best-view` takes the empty-bed reference from the best view (bed_view picker:
  need_reference=False).
- complexity helpers extracted from classify, _run_monitor, capture_reference, main (bed_state.py) and
  _ack, clear_ack (printer_ops/bed.py) so the staged files pass the 15/20 lint gate; behaviour unchanged.
- fixtures: the Oct-16 reference and the two false-positive frames rebuilt from the annotated evidence
  (overlay inverted, outline/caption inpainted); the old classifier reads both occupied.

Refs #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t came off the plate; `start --watch`

2026-11-04: a part came off the plate without a trace and the print kept running. The always-on
watchers are read-only by design; this is the guard an agent starts for ONE job right after
`3d printer start` and that acts on it.

- bed_part.py: pixel half. Changed share of the plate region against an empty-plate baseline taken at
  the start of the job (homography-warped to the current Z with a camera calibration), how much of the
  learnt footprint is still changed (overlap) and how much changed beyond it (outside). `blind`, never
  "gone", when the plate is out of view or the baseline cannot stand.
- jobwatch.py: pure judge. Footprint armed from layer 3 on two readings; part_gone / part_moved /
  spaghetti with 2-of-4 confirmation; signals from the always-on MoonrakerDetector (runout, heater loss,
  bed off, exception, G-code error, homing lost) and the printer's own noodle verdicts (p >= 0.8);
  `--on-fail auto|pause|cancel` (auto: cancel a gone part or a camera finding in layers <= 3, else pause).
- jobwatch_actions.py: save frames + finding.json, PAUSE/CANCEL of the bound job only (re-read, same
  file, once, verified), `tg call me --say` (russian), klippy lines via `3d printer evidence`, `tg --photo`.
  Every step runs even if an earlier one failed. No G-code, no axis, no resume, no start.
- jobwatch_sources.py / jobwatch_run.py: live Moonraker + camera source, fixture scenario source with a
  virtual clock (`--fixture`, `--dry-run`, `--tg-dry-run`), checkpoints L1@25/50/100 % and 25/50/100 %,
  one watch per printer, `--yes` required (not for dry-run/fixture). Exit 0 / 3 lost print / 1.
- jobwatch_launch.py + `printer start --watch` (opt-in: it holds cancel authority) or a NOT WATCHED line
  with the command and the estimated duration.
- printer.py: run() is a dispatch table, _list_row/_cmd_list/_start_snapshot split into helpers so the
  staged file passes the 15/20 lint gate; behaviour unchanged.
- docs/commands/printer.md: Job watch, bed check; lib-map entry.
- tests: fixture frames (empty, part, dark room, partial, spaghetti) and a fake Moonraker: a detached part
  cancels + calls + saves evidence; a lighting change on an empty plate or with the part standing does not.

Refs #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
alex-mextner and others added 9 commits November 5, 2026 00:41
…ation (chained baseline)

The job watch of a `watch.bed.roi` printer (K1 SE) judged the part only within z_tol (2 mm) of the
baseline's Z, so the camera went blind after ~10 layers. bed_part.py now follows the plate instead:
every healthy reading saves the affine transform that carries the empty first-layer baseline onto that
frame (--save-track, next to the footprint); the next reading starts from it (--track) and refines it
with ECC on the plate pixels only, without the part's footprint neighbourhood. Layer to layer the bed
moves ~1 px, so the chain never jumps, and every step is anchored to the true empty baseline (no
accumulating error). The footprint is carried along, the baseline is warped onto the frame and the
compared region is the plate as it moved. A vanished part reads as overlap ~0 whatever the Z.
Beyond z_tol a plate that cannot be followed (no texture, correlation < 0.6, a jump > 12 px since the
last healthy reading) stays `blind`, never `gone`.

Tests (frames of the real K1 SE bed with a synthetic shift+shrink drift): a healthy part over 30
layers/6 mm stays present, a detached part at layer 30 cancels, a lighting change at layer 30 is no
finding, a camera jump beyond what can be followed is blind (empty or with the part), the pixel tool
chains small steps to a 20 px drift that no single step could follow. Documented in the Job watch
section, bed_part.py's docstring and the lib map.

Refs #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… `watch.bed.light` config

A frame of a dark chamber is not comparable to the reference. printer_ops/bed_light.py finds the
light among Moonraker's objects (`led` / `neopixel` / `dotstar` via SET_LED, `output_pin` via SET_PIN,
by name: light, lamp, led, chamber), reads its state with a read-only objects/query, and a
LightSession switches it on when it is off and puts it back (also after a failure). It never touches
the light while a job prints or is paused (except `printing_ok`, the job watch's frame, and then only
when the light was off), never on a read-only printer, and a missing light, an unreadable object list
or a failed switch only say so in the LightReport.

`watch.bed.light: {object, on_gcode, off_gcode, settle_s, enabled}` is validated in printer_ops/bed.py;
a gcode macro must be SET_LED / SET_PIN or listed in trusted_macros, like clear_ack.

Refs #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… job watch is taken with the light on

`bed check` / `bed status` (status_view) switch the printer's light on before the bed moves (the settle
overlaps the Z travel; with --max-age only when a fresh frame is really taken) and restore it after the
bed is back: light on, bed move, capture, bed restore, light restore. `bed reference --capture`
(best view or direct) does the same; a saved --from frame never touches the light. The verdict gains a
`light` key (found / object / was_on / switched / restored / on_for_frame / error / text) and the
check / status / reference text a `light:` line; a printer without a light says `no light found` and is
judged as before. The `watch` monitor only reads idle frames and never touches the light.

LiveSource.grab (`3d printer watch NAME --job`) takes the frame with the light on when it was off and
restores it right after: the one moment the light is touched during a print.

bed_state also marks a verdict `occupied_confident` (a region of the normal occupied size with at least
watch.bed.auto_view_min of the plate in view), which the start gate uses in the next commit.

Refs #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y on a confident `occupied`

`3d print-preflight --printer` / `3d printer start` on an idle printer now run the whole `bed check` path
(`bed_state.py status --best-view`: best view, the printer's light on, everything put back) instead of the
watcher's verdict at whatever Z the bed rests at; `--no-bed-move` still judges at the current Z, light on.

FAIL only on a missing plate or a confident occupied (`occupied_confident`: a region of the normal
occupied size with at least watch.bed.auto_view_min of the plate in view at the Z judged). `unknown`, and
an occupied seen at a poor view (the U1 at Z 200 sees 15 % of the plate; the former misfire, 16.0 %
changed by a window light), are a WARN with the reason and the exact `3d printer bed check NAME` to run.
The line also shows what the light did. Tests: the two light_* fixtures at Z 200.2 -> WARN; the pre-#723
classifier's occupied at that view -> WARN; parts in a 89 % view -> FAIL; the demo sequence light on, bed
move, capture, bed restore, light restore; e2e through bin/3d with a fake chamber light.

docs/commands/printer.md (The light, Start gate, config, bed check, job watch) and the lib-map follow.

preflight.py: read_gcode_meta, check_slots, check_moves and check_head_sequence are split into helpers
(behaviour-preserving) so the file passes the complexity gate it is linted with whole.

Refs #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t is never `occupied`

When the brightness of the bed ROI differs from the reference's (dark/middle/bright
quantiles outside x0.8..x1.25: day vs night, light on/off, a gamma), a pixel needs the
colour difference AND a change of the local texture (luminance over its neighbourhood
spread, lib/bed_norm.py). Pixels only the colour difference flags are the light: occupied
by colour alone becomes unknown, never occupied; a real part changes both and stays
occupied. At a low view the texture channel rescues an occupied but does not call clear.
`thresholds.light_norm: 0` turns it off per printer.

Proved on the fixtures with generated gain 0.25..4 / gamma 0.5..2 / colour-cast changes of
the K1 and U1 empty frames, the window-light frames at Z 200.2, and the real parts.

Refs #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…e file to the loaded colour (Refs #728)

The slot Tn check compared colours as strings: the beige PETG sliced as #E8D8B0 and
shown by the U1 as #FEE5A5 FAILed and only --force got past it. The colour is now
within --colour-delta-e (CIEDE2000, default 10) and the line shows the distance
(this pair: 6.4); a material mismatch stays a FAIL.

--fix (needs a live --printer) writes the printer's colour into the used tools'
filament_colour/extruder_colour in the G-code and its project 3MF, keeps mode and mtime,
moves the slice-warnings sidecar to the new hash, updates the manifest and prints old/new
sha256. A head further than --fix-max-delta-e is refused (another filament).
… then point to the printer screen (Refs #728)

tg-ctl answers `ask-forward not-posted … daemon declined: reason=send-failed` when its
single sendMessage failed (2026-11-04 22:02/22:03: `getaddrinfo EAI_AGAIN api.telegram.org`,
a resolver blip; plain `tg` worked a moment later). No card is posted then, so asking again
is safe: the ask is repeated after 3, 10 and 30 s, each reported on stderr. Every other
outcome stays final at once. When it still fails the refusal says how to start from the
printer's own screen (the file is already stored) and which daemon log lines to read.
…ile and printer colour (Refs #728)

`3d printer start NAME FILE --yes --fix-colour` rewrites FILE (and its project 3MF) to the
colours the printer reports, prints old/new sha256, uploads the fixed file (its hash is
part of the stored name) and then runs the preflight and starts: no --force, no Telegram.
--colour-delta-e and --fix-max-delta-e are passed through.

`3d printer slot set NAME T<n> --material M --color #RRGGBB [--brand B]` writes the head's
filament record the other way round: it is `3d printers filament set NAME/T<n>` (U1:
SET_PRINT_FILAMENT_CONFIG through /printer/gcode/script, read back), so it refuses a
read-only/offline printer, a running job and official RFID filament, and has --dry-run.
`slot show NAME` reads the heads.

The printer command's legacy functions are split into helpers (the same decomposition as
the open thumbnail PR) so the touched file passes the complexity gate.
…ot post (Refs #728)

Through bin/3d with the hatch answering `ask-forward not-posted … reason=send-failed`: four
requests, then a refusal that names the stored file and the printer screen; nothing started.
alex-mextner and others added 14 commits November 5, 2026 01:29
…d reference --refresh`, `watch.bed.auto_reference`

A reference now records its plate type (`watch.bed.plate`), the printer's light state and its time.
A frame is judged against the reference of its own light state first; another light state is a
fallback marked `reference_light_mismatch` and judged by colour and texture. A reference older than
`watch.bed.reference_max_age_days` (30) or of another plate type never makes `occupied`: it is
`unknown`, with the age and the refresh command.

`3d printer bed reference NAME --refresh [--assume-empty WHY]` re-captures from the best view with
the light on and replaces the reference of the same plate type, light state and view, only when the
very frame judged is a confident clear in a good view with the light on (else it refuses and stores
nothing), or on the owner's recorded word. `watch.bed.auto_reference: true` does it after a confident
clear check on an idle printer and journals each refresh to NAME.refs.log.

Refs #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
… share per Z; `watch.bed.views`, `camera`, `plate_mask`

For a printer without a camera calibration (the K1 SE) the check counted the plate as in the frame
at every Z. `bed calibrate` (idle, homed, writable printer only; refused otherwise, nothing moves)
moves the bed through a Z grid in Z only — the guarded move of `bed check` within its clearance
floor and the axis ceiling, with the light on, the bed restored in a `finally` (new
`bed_view.excursion`: lock, displaced-Z marker, SIGTERM) — takes a frame per Z, follows the plate
from its outline in the empty-bed reference / `watch.bed.plate_mask` frame to frame (chained ECC
affine transforms; the largest dark region as a rough fallback) and stores the visible share per Z
and the plate polygon in NAME/calibration.json. A plate that cannot be followed ends the chain:
those Zs are listed, not stored. `bed_state.z_match` uses the shares for a printer with neither a
camera calibration nor a roi (linear between Zs, unknown beyond them).

Config: `watch.bed.views: {Z: share}` overrides the learnt ones, `camera: URL` names the bed
camera's snapshot, `plate_mask` is the alias of `roi`.

Refs #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ord that the bed is empty

`3d printer bed check NAME --assume-empty WHY [--assume-empty-ttl MIN]` runs the normal check and
records who ($USER), when, why, the camera's verdict, the frame paths, Z and light as
assume-empty-<ts>.json in the evidence dir and as NAME.assumed.json {ts, why, ttl, job_marker} in
the state dir. The preflight `bed` line is then PASS with "owner confirmed empty at <time>: <why>"
(the camera is not asked) for the ttl (default 60 min) while no job has started since (Moonraker's
newest history job id / print_stats.filename are compared). It never overrides a printing printer
or a camera that sees the plate missing; an empty WHY is refused. The preflight FAIL/WARN lines of
the bed name the command.

Refs #723

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…y (Refs #707)

run_live, metrics, build_scorer, score_bgr, run_guard, run_record, load_replay, iter_frames,
evaluate and run_eval are cut into private helpers; behaviour, output and exit codes unchanged.
Done first so the print-quality tier (Refs #733) can touch the file.
…loss, first layer (Refs #733)

print_quality.py measures a frame in the plate region: fleck_frac (white neutral specks and strands:
min-channel top-hat) and order (structure-tensor coherence). printer_ops/quality.py judges them
against the job's own first layers: notice/warning/severe per issue, persistence, one alert per
issue and level, bounded baseline drift, debris trend, first-layer floor; never critical.
quality_report.py writes the per-job monitoring report (counts per severity, worst frame).
Calibrated on the 12 U1 frames of the 2026-11-05 SIMBOL sheet job and 4 phone photos
(docs/research/print-quality-monitor.md); fixtures in tests/data/quality (640 px, < 1 MB).
…onitoring report (Refs #733)

printer watch NAME --job now measures every camera check for stringing debris, a lost bead pattern
and an uneven first layer (jobwatch_quality.py). A warning or severe level is one tg --photo with a
short Russian text (what is seen, what to change) per issue and level; it never pauses, cancels or
rings the owner. When the watch ends — job complete, lost print or --duration — DIR/<date>-NAME-
monitor-report/ gets report.json, report.md and worst-frame.jpg. --no-quality and
<printer>.watch.quality (false or any QualityConfig field) control it.
Tests replay the real U1 frames through a fixture scenario.
vision_quality.QualityTier feeds each printing frame (plate region at the print's Z from the camera
context) to the quality judge and returns Findings: quality_<issue>_warning/severe -> a WARNING
event with the debris-marked frame (tg --photo via the dispatcher), once per issue and level, never a
pause; notice -> info. --once reports the numbers and the absolute verdict of one frame. VisionCycle
calls on_print_end so the per-print monitoring report is written when a print ends or another starts.
--no-quality and watch.quality control it.
…a; scorer/once tests (Refs #733)

A K1 SE view of a part reads order 0.5 normally, so the absolute first-layer floor (0.6) fired a
false first_layer warning on the job-watch fixtures. It is now a per-model profile (Snapmaker U1,
the camera it was calibrated on) or a watch.quality key; elsewhere the first layers are judged
against the job's own baseline only. The part-check tests of test_printer_jobwatch.py run with
watch.quality: false. Adds FrameScorer / --once report tests proving the quality Finding appears next
to a spaghetti model that says p 0.0.
… U1 literal (Refs #733)

test_fleet_vision_parity forbids U1 literals in generic modules: the calibrated first-layer floor
moves to lib/data/vision/quality_profiles.json, matched on the printer's model name.
macros.py holds what may be sent to a Klipper printer: identifier-only names,
one-token values, no M112/EMERGENCY_STOP, a risk class per command and the
gates (--yes, idle printer) derived from it. MoonrakerAdapter gains
macro_catalog() and the request strings dry-runs print. parse_status is split
into _job_state/_temps (same behaviour) because the lint gate rejects its
cognitive complexity in a touched file.

Refs #718

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
lib/data/printer-aliases.yaml maps intents (home, level, load, unload, preheat,
cooldown, pause, resume, park, U1 clean-nozzle/purifier/...) to the G-code each
firmware understands; aliases.py layers adapter -> model -> the printer's own
printer-aliases.yaml (user, then project), types every argument and reports an
alias a model lacks instead of guessing.

Refs #718

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…el alias subcommands

macro list discovers a Klipper printer's macros (Moonraker objects/list + gcode/help;
--offline from registry data; --dry-run prints the requests). macro run passes ONE
macro with typed KEY=VALUE parameters through the adapter and prints exactly the line
sent: refused for read-only/offline printers, names/values with newline, ;, quotes or
M112/EMERGENCY_STOP, commands the printer does not define; --yes for a macro outside
trusted_macros and for dangerous ones; an idle printer for motion/heating, not for
pause/resume/cancel style. home, level, load, unload, preheat, cooldown, pause, resume,
park and the U1-only clean-nozzle/level-heated/idle-position/purifier resolve through
the alias table per adapter and model, and an alias a model lacks says so. No raw
`gcode` passthrough (documented). --help and docs/commands/printer.md updated.

Refs #718

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…t say so

Snapmaker/u1-klipper lava/printer.cfg defines neither, so the generic Klipper aliases
are marked unsupported for the U1 model instead of failing at run time.

Refs #718

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…to helpers and a dispatch table

Behaviour-preserving, so the whole-file lint gate (cognitive 15 / cyclomatic 20) passes without a
rig.yaml ratchet (#707, option b). _list_row -> _fleet_facts/_device_facts, _cmd_list ->
_print_no_printers/_print_list_row, _start_snapshot -> _require_stored_file/_verify_remote_identity/
_preflight_report/_require_preflight_pass/_start_and_report, run() -> _SUBCOMMANDS table. The helper
bodies are the same text as feat/gcode-thumbnails-everywhere (#725) and feat/slot-colour-fix (#730) so
the hunks merge cleanly with either. macro, aliases and the alias fallback join the table.

Refs #718

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@alex-mextner
alex-mextner force-pushed the feature/printer-macros-aliases branch from e5a1565 to a1d0e98 Compare November 5, 2026 10:27
…e print-preflight --fix adds thumbnails and aligns colours (Refs #728)
…l; bed, watch --job and slot all stay registered (Refs #723)
…nto feature/printer-macros-aliases; macro, aliases, slot, watch, bed stay registered; 0.79.0 carries the batch bump (Refs #718)
…oon' on 2026-11-05; make them relative (Refs #718)
@alex-mextner
alex-mextner merged commit e04da56 into main Nov 5, 2026
10 checks passed
@alex-mextner
alex-mextner deleted the feature/printer-macros-aliases branch November 5, 2026 23:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant